| 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,7 +72,7 @@ import { |
| 72 | type TestProviderAdapterHarness, | 72 | type TestProviderAdapterHarness, |
| 73 | } from "./TestProviderAdapter.integration.ts"; | 73 | } from "./TestProviderAdapter.integration.ts"; |
| 74 | import { deriveServerPaths, ServerConfig } from "../src/config.ts"; | 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 | import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts"; | 76 | import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts"; |
| 77 | import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; | 77 | import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; |
| 78 | import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; | 78 | import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; |
| ... | @@ -348,7 +348,7 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -348,7 +348,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 348 | }), | 348 | }), |
| 349 | ), | 349 | ), |
| 350 | Layer.provideMerge( | 350 | Layer.provideMerge( |
| 351 | WorkspaceEntriesLive.pipe( | 351 | WorkspaceEntries.layer.pipe( |
| 352 | Layer.provide(WorkspacePathsLive), | 352 | Layer.provide(WorkspacePathsLive), |
| 353 | Layer.provideMerge(VcsDriverRegistry.layer), | 353 | Layer.provideMerge(VcsDriverRegistry.layer), |
| 354 | Layer.provide(NodeServices.layer), | 354 | Layer.provide(NodeServices.layer), |
apps/server/package.json+1| ... | @@ -27,6 +27,7 @@ | ... | @@ -27,6 +27,7 @@ |
| 27 | "@effect/platform-node": "catalog:", | 27 | "@effect/platform-node": "catalog:", |
| 28 | "@effect/platform-node-shared": "catalog:", | 28 | "@effect/platform-node-shared": "catalog:", |
| 29 | "@effect/sql-sqlite-bun": "catalog:", | 29 | "@effect/sql-sqlite-bun": "catalog:", |
| 30 | "@ff-labs/fff-node": "^0.9.4", | ||
| 30 | "@opencode-ai/sdk": "^1.3.15", | 31 | "@opencode-ai/sdk": "^1.3.15", |
| 31 | "@pierre/diffs": "catalog:", | 32 | "@pierre/diffs": "catalog:", |
| 32 | "effect": "catalog:", | 33 | "effect": "catalog:", |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+2-2| ... | @@ -56,7 +56,7 @@ import { | ... | @@ -56,7 +56,7 @@ import { |
| 56 | } from "../../provider/Services/ProviderService.ts"; | 56 | } from "../../provider/Services/ProviderService.ts"; |
| 57 | import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; | 57 | import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; |
| 58 | import { ServerConfig } from "../../config.ts"; | 58 | import { ServerConfig } from "../../config.ts"; |
| 59 | import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts"; | 59 | import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; |
| 60 | import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; | 60 | import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; |
| 61 | 61 | ||
| 62 | const asProjectId = (value: string): ProjectId => ProjectId.make(value); | 62 | const asProjectId = (value: string): ProjectId => ProjectId.make(value); |
| ... | @@ -330,7 +330,7 @@ describe("CheckpointReactor", () => { | ... | @@ -330,7 +330,7 @@ describe("CheckpointReactor", () => { |
| 330 | Layer.provideMerge(vcsStatusBroadcasterLayer), | 330 | Layer.provideMerge(vcsStatusBroadcasterLayer), |
| 331 | Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))), | 331 | Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))), |
| 332 | Layer.provideMerge( | 332 | Layer.provideMerge( |
| 333 | WorkspaceEntriesLive.pipe( | 333 | WorkspaceEntries.layer.pipe( |
| 334 | Layer.provide(WorkspacePathsLive), | 334 | Layer.provide(WorkspacePathsLive), |
| 335 | Layer.provideMerge(VcsDriverRegistry.layer), | 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,7 +34,7 @@ import type { CheckpointStoreError } from "../../checkpointing/Errors.ts"; |
| 34 | import type { OrchestrationDispatchError } from "../Errors.ts"; | 34 | import type { OrchestrationDispatchError } from "../Errors.ts"; |
| 35 | import { isGitRepository } from "../../git/Utils.ts"; | 35 | import { isGitRepository } from "../../git/Utils.ts"; |
| 36 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; | 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 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | 39 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 40 | 40 | ||
| ... | @@ -83,7 +83,7 @@ const make = Effect.gen(function* () { | ... | @@ -83,7 +83,7 @@ const make = Effect.gen(function* () { |
| 83 | const providerService = yield* ProviderService; | 83 | const providerService = yield* ProviderService; |
| 84 | const checkpointStore = yield* CheckpointStore; | 84 | const checkpointStore = yield* CheckpointStore; |
| 85 | const receiptBus = yield* RuntimeReceiptBus; | 85 | const receiptBus = yield* RuntimeReceiptBus; |
| 86 | const workspaceEntries = yield* WorkspaceEntries; | 86 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; |
| 87 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; | 87 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 88 | 88 | ||
| 89 | const appendRevertFailureActivity = (input: { | 89 | const appendRevertFailureActivity = (input: { |
| ... | @@ -252,9 +252,9 @@ const make = Effect.gen(function* () { | ... | @@ -252,9 +252,9 @@ const make = Effect.gen(function* () { |
| 252 | checkpointRef: targetCheckpointRef, | 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 | // reflects files created or deleted during this turn. | 256 | // reflects files created or deleted during this turn. |
| 257 | yield* workspaceEntries.invalidate(input.cwd); | 257 | yield* workspaceEntries.refresh(input.cwd); |
| 258 | 258 | ||
| 259 | const files = yield* checkpointStore | 259 | const files = yield* checkpointStore |
| 260 | .diffCheckpoints({ | 260 | .diffCheckpoints({ |
| ... | @@ -690,9 +690,9 @@ const make = Effect.gen(function* () { | ... | @@ -690,9 +690,9 @@ const make = Effect.gen(function* () { |
| 690 | return; | 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 | // reflects the reverted filesystem state. | 694 | // reflects the reverted filesystem state. |
| 695 | yield* workspaceEntries.invalidate(sessionRuntime.value.cwd); | 695 | yield* workspaceEntries.refresh(sessionRuntime.value.cwd); |
| 696 | 696 | ||
| 697 | const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); | 697 | const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); |
| 698 | if (rolledBackTurns > 0) { | 698 | if (rolledBackTurns > 0) { |
apps/server/src/server.test.ts+6-5| ... | @@ -51,6 +51,7 @@ import * as Option from "effect/Option"; | ... | @@ -51,6 +51,7 @@ import * as Option from "effect/Option"; |
| 51 | import * as Path from "effect/Path"; | 51 | import * as Path from "effect/Path"; |
| 52 | import * as PubSub from "effect/PubSub"; | 52 | import * as PubSub from "effect/PubSub"; |
| 53 | import * as Stream from "effect/Stream"; | 53 | import * as Stream from "effect/Stream"; |
| 54 | import * as TestClock from "effect/testing/TestClock"; | ||
| 54 | import { ChildProcessSpawner } from "effect/unstable/process"; | 55 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 55 | import { | 56 | import { |
| 56 | FetchHttpClient, | 57 | FetchHttpClient, |
| ... | @@ -118,7 +119,7 @@ import { | ... | @@ -118,7 +119,7 @@ import { |
| 118 | ServerEnvironment, | 119 | ServerEnvironment, |
| 119 | type ServerEnvironmentShape, | 120 | type ServerEnvironmentShape, |
| 120 | } from "./environment/Services/ServerEnvironment.ts"; | 121 | } from "./environment/Services/ServerEnvironment.ts"; |
| 121 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; | 122 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 122 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; | 123 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; |
| 123 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | 124 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; |
| 124 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | 125 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| ... | @@ -504,7 +505,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -504,7 +505,7 @@ const buildAppUnderTest = (options?: { |
| 504 | const gitManagerLayer = Layer.mock(GitManager)({ | 505 | const gitManagerLayer = Layer.mock(GitManager)({ |
| 505 | ...options?.layers?.gitManager, | 506 | ...options?.layers?.gitManager, |
| 506 | }); | 507 | }); |
| 507 | const workspaceEntriesLayer = WorkspaceEntriesLive.pipe( | 508 | const workspaceEntriesLayer = WorkspaceEntries.layer.pipe( |
| 508 | Layer.provide(WorkspacePathsLive), | 509 | Layer.provide(WorkspacePathsLive), |
| 509 | Layer.provideMerge(vcsDriverRegistryLayer), | 510 | Layer.provideMerge(vcsDriverRegistryLayer), |
| 510 | ); | 511 | ); |
| ... | @@ -4354,7 +4355,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -4354,7 +4355,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4354 | assert.isAtLeast(response.entries.length, 1); | 4355 | assert.isAtLeast(response.entries.length, 1); |
| 4355 | assert.isTrue(response.entries.some((entry) => entry.path === "needle-file.ts")); | 4356 | assert.isTrue(response.entries.some((entry) => entry.path === "needle-file.ts")); |
| 4356 | assert.equal(response.truncated, false); | 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 | it.effect("routes websocket rpc projects.listEntries and projects.readFile", () => | 4361 | it.effect("routes websocket rpc projects.listEntries and projects.readFile", () => |
| ... | @@ -4390,7 +4391,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -4390,7 +4391,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4390 | byteLength: 26, | 4391 | byteLength: 26, |
| 4391 | truncated: false, | 4392 | truncated: false, |
| 4392 | }); | 4393 | }); |
| 4393 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 4394 | }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), |
| 4394 | ); | 4395 | ); |
| 4395 | 4396 | ||
| 4396 | it.effect("routes websocket rpc projects.searchEntries excludes gitignored files", () => | 4397 | it.effect("routes websocket rpc projects.searchEntries excludes gitignored files", () => |
| ... | @@ -4447,7 +4448,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -4447,7 +4448,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4447 | 4448 | ||
| 4448 | assert.equal(response.entries.length, 0); | 4449 | assert.equal(response.entries.length, 0); |
| 4449 | assert.equal(response.truncated, false); | 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 | it.effect("routes websocket rpc projects.searchEntries errors", () => | 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,7 +54,7 @@ import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; |
| 54 | import { ServerSettingsLive } from "./serverSettings.ts"; | 54 | import { ServerSettingsLive } from "./serverSettings.ts"; |
| 55 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; | 55 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; |
| 56 | import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; | 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 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; | 58 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; |
| 59 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | 59 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; |
| 60 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | 60 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| ... | @@ -247,10 +247,7 @@ const PreviewLayerLive = Layer.empty.pipe( | ... | @@ -247,10 +247,7 @@ const PreviewLayerLive = Layer.empty.pipe( |
| 247 | Layer.provideMerge(PortScannerLayerLive), | 247 | Layer.provideMerge(PortScannerLayerLive), |
| 248 | ); | 248 | ); |
| 249 | 249 | ||
| 250 | const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( | 250 | const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive)); |
| 251 | Layer.provide(WorkspacePathsLive), | ||
| 252 | Layer.provideMerge(VcsDriverRegistryLayerLive), | ||
| 253 | ); | ||
| 254 | 251 | ||
| 255 | const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( | 252 | const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( |
| 256 | Layer.provide(WorkspacePathsLive), | 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,20 +8,19 @@ import * as Path from "effect/Path"; |
| 8 | import { ServerConfig } from "../../config.ts"; | 8 | import { ServerConfig } from "../../config.ts"; |
| 9 | import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; | 9 | import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; |
| 10 | import * as VcsProcess from "../../vcs/VcsProcess.ts"; | 10 | import * as VcsProcess from "../../vcs/VcsProcess.ts"; |
| 11 | import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; | 11 | import * as WorkspaceEntries from "../WorkspaceEntries.ts"; |
| 12 | import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; | 12 | import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; |
| 13 | import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; | ||
| 14 | import { WorkspaceFileSystemLive } from "./WorkspaceFileSystem.ts"; | 13 | import { WorkspaceFileSystemLive } from "./WorkspaceFileSystem.ts"; |
| 15 | import { WorkspacePathsLive } from "./WorkspacePaths.ts"; | 14 | import { WorkspacePathsLive } from "./WorkspacePaths.ts"; |
| 16 | 15 | ||
| 17 | const ProjectLayer = WorkspaceFileSystemLive.pipe( | 16 | const ProjectLayer = WorkspaceFileSystemLive.pipe( |
| 18 | Layer.provide(WorkspacePathsLive), | 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 | const TestLayer = Layer.empty.pipe( | 21 | const TestLayer = Layer.empty.pipe( |
| 23 | Layer.provideMerge(ProjectLayer), | 22 | Layer.provideMerge(ProjectLayer), |
| 24 | Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), | 23 | Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), |
| 25 | Layer.provideMerge(WorkspacePathsLive), | 24 | Layer.provideMerge(WorkspacePathsLive), |
| 26 | Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), | 25 | Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), |
| 27 | Layer.provide( | 26 | Layer.provide( |
| ... | @@ -53,7 +52,7 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( | ... | @@ -53,7 +52,7 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( |
| 53 | yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); | 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 | describe("readFile", () => { | 56 | describe("readFile", () => { |
| 58 | it.effect("reads UTF-8 files relative to the workspace root", () => | 57 | it.effect("reads UTF-8 files relative to the workspace root", () => |
| 59 | Effect.gen(function* () { | 58 | Effect.gen(function* () { |
| ... | @@ -135,20 +134,15 @@ it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { | ... | @@ -135,20 +134,15 @@ it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { |
| 135 | 134 | ||
| 136 | it.effect("invalidates workspace entry search cache after writes", () => | 135 | it.effect("invalidates workspace entry search cache after writes", () => |
| 137 | Effect.gen(function* () { | 136 | Effect.gen(function* () { |
| 138 | const workspaceEntries = yield* WorkspaceEntries; | 137 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; |
| 139 | const workspaceFileSystem = yield* WorkspaceFileSystem; | 138 | const workspaceFileSystem = yield* WorkspaceFileSystem; |
| 140 | const cwd = yield* makeTempDir; | 139 | const cwd = yield* makeTempDir; |
| 141 | yield* writeTextFile(cwd, "src/existing.ts", "export {};\n"); | 140 | yield* writeTextFile(cwd, "src/existing.ts", "export {};\n"); |
| 142 | 141 | ||
| 143 | const beforeWrite = yield* workspaceEntries.search({ | 142 | const beforeWrite = yield* workspaceEntries.list({ cwd }); |
| 144 | cwd, | 143 | expect(beforeWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md")).toBe( |
| 145 | query: "rpc", | 144 | false, |
| 146 | limit: 10, | 145 | ); |
| 147 | }); | ||
| 148 | expect(beforeWrite).toEqual({ | ||
| 149 | entries: [], | ||
| 150 | truncated: false, | ||
| 151 | }); | ||
| 152 | 146 | ||
| 153 | yield* workspaceFileSystem.writeFile({ | 147 | yield* workspaceFileSystem.writeFile({ |
| 154 | cwd, | 148 | cwd, |
| ... | @@ -156,11 +150,7 @@ it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { | ... | @@ -156,11 +150,7 @@ it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { |
| 156 | contents: "# Plan\n", | 150 | contents: "# Plan\n", |
| 157 | }); | 151 | }); |
| 158 | 152 | ||
| 159 | const afterWrite = yield* workspaceEntries.search({ | 153 | const afterWrite = yield* workspaceEntries.list({ cwd }); |
| 160 | cwd, | ||
| 161 | query: "rpc", | ||
| 162 | limit: 10, | ||
| 163 | }); | ||
| 164 | expect(afterWrite.entries).toEqual( | 154 | expect(afterWrite.entries).toEqual( |
| 165 | expect.arrayContaining([expect.objectContaining({ path: "plans/effect-rpc.md" })]), | 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,7 +11,7 @@ import { |
| 11 | WorkspaceFileSystemError, | 11 | WorkspaceFileSystemError, |
| 12 | type WorkspaceFileSystemShape, | 12 | type WorkspaceFileSystemShape, |
| 13 | } from "../Services/WorkspaceFileSystem.ts"; | 13 | } from "../Services/WorkspaceFileSystem.ts"; |
| 14 | import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; | 14 | import * as WorkspaceEntries from "../WorkspaceEntries.ts"; |
| 15 | import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; | 15 | import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; |
| 16 | 16 | ||
| 17 | const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; | 17 | const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; |
| ... | @@ -20,7 +20,7 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { | ... | @@ -20,7 +20,7 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { |
| 20 | const fileSystem = yield* FileSystem.FileSystem; | 20 | const fileSystem = yield* FileSystem.FileSystem; |
| 21 | const path = yield* Path.Path; | 21 | const path = yield* Path.Path; |
| 22 | const workspacePaths = yield* WorkspacePaths; | 22 | const workspacePaths = yield* WorkspacePaths; |
| 23 | const workspaceEntries = yield* WorkspaceEntries; | 23 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; |
| 24 | 24 | ||
| 25 | const readFile: WorkspaceFileSystemShape["readFile"] = Effect.fn("WorkspaceFileSystem.readFile")( | 25 | const readFile: WorkspaceFileSystemShape["readFile"] = Effect.fn("WorkspaceFileSystem.readFile")( |
| 26 | function* (input) { | 26 | function* (input) { |
| ... | @@ -114,7 +114,7 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { | ... | @@ -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 | return { relativePath: target.relativePath }; | 118 | return { relativePath: target.relativePath }; |
| 119 | }); | 119 | }); |
| 120 | return { readFile, writeFile } satisfies WorkspaceFileSystemShape; | 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,7 +78,7 @@ import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; |
| 78 | import * as PreviewManager from "./preview/Manager.ts"; | 78 | import * as PreviewManager from "./preview/Manager.ts"; |
| 79 | import { issueAssetUrl } from "./assets/AssetAccess.ts"; | 79 | import { issueAssetUrl } from "./assets/AssetAccess.ts"; |
| 80 | import * as PortScanner from "./preview/PortScanner.ts"; | 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 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; | 82 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; |
| 83 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; | 83 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; |
| 84 | import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; | 84 | import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; |
| ... | @@ -272,7 +272,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | ... | @@ -272,7 +272,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 272 | const lifecycleEvents = yield* ServerLifecycleEvents; | 272 | const lifecycleEvents = yield* ServerLifecycleEvents; |
| 273 | const serverSettings = yield* ServerSettingsService; | 273 | const serverSettings = yield* ServerSettingsService; |
| 274 | const startup = yield* ServerRuntimeStartup; | 274 | const startup = yield* ServerRuntimeStartup; |
| 275 | const workspaceEntries = yield* WorkspaceEntries; | 275 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; |
| 276 | const workspaceFileSystem = yield* WorkspaceFileSystem; | 276 | const workspaceFileSystem = yield* WorkspaceFileSystem; |
| 277 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; | 277 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; |
| 278 | const repositoryIdentityResolver = yield* RepositoryIdentityResolver; | 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,7 +4432,6 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 4432 | { | 4432 | { |
| 4433 | path: "path/to/package.json", | 4433 | path: "path/to/package.json", |
| 4434 | kind: "file", | 4434 | kind: "file", |
| 4435 | parentPath: "path/to", | ||
| 4436 | }, | 4435 | }, |
| 4437 | ], | 4436 | ], |
| 4438 | truncated: false, | 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,7 +928,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 928 | path: entry.path, | 928 | path: entry.path, |
| 929 | pathKind: entry.kind, | 929 | pathKind: entry.kind, |
| 930 | label: basenameOfPath(entry.path), | 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 | if (composerTrigger.kind === "slash-command") { | 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,7 +8,6 @@ import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; |
| 8 | export interface ComposerPathSearchEntry { | 8 | export interface ComposerPathSearchEntry { |
| 9 | readonly path: string; | 9 | readonly path: string; |
| 10 | readonly kind: "file" | "directory"; | 10 | readonly kind: "file" | "directory"; |
| 11 | readonly parentPath?: string; | ||
| 12 | } | 11 | } |
| 13 | 12 | ||
| 14 | export interface ComposerPathSearchState { | 13 | export interface ComposerPathSearchState { |
| ... | @@ -84,11 +83,7 @@ export function getComposerPathSearchTargetKey(target: ComposerPathSearchTarget) | ... | @@ -84,11 +83,7 @@ export function getComposerPathSearchTargetKey(target: ComposerPathSearchTarget) |
| 84 | function toSearchEntries( | 83 | function toSearchEntries( |
| 85 | entries: ProjectSearchEntriesResult["entries"], | 84 | entries: ProjectSearchEntriesResult["entries"], |
| 86 | ): ReadonlyArray<ComposerPathSearchEntry> { | 85 | ): ReadonlyArray<ComposerPathSearchEntry> { |
| 87 | return entries.map((entry) => ({ | 86 | return entries; |
| 88 | path: entry.path, | ||
| 89 | kind: entry.kind === "directory" ? "directory" : "file", | ||
| 90 | ...(entry.parentPath !== undefined ? { parentPath: entry.parentPath } : {}), | ||
| 91 | })); | ||
| 92 | } | 87 | } |
| 93 | 88 | ||
| 94 | export function createComposerPathSearchManager(config: { | 89 | export function createComposerPathSearchManager(config: { |
packages/contracts/src/project.ts-1| ... | @@ -17,7 +17,6 @@ const ProjectEntryKind = Schema.Literals(["file", "directory"]); | ... | @@ -17,7 +17,6 @@ const ProjectEntryKind = Schema.Literals(["file", "directory"]); |
| 17 | export const ProjectEntry = Schema.Struct({ | 17 | export const ProjectEntry = Schema.Struct({ |
| 18 | path: TrimmedNonEmptyString, | 18 | path: TrimmedNonEmptyString, |
| 19 | kind: ProjectEntryKind, | 19 | kind: ProjectEntryKind, |
| 20 | parentPath: Schema.optional(TrimmedNonEmptyString), | ||
| 21 | }); | 20 | }); |
| 22 | export type ProjectEntry = typeof ProjectEntry.Type; | 21 | export type ProjectEntry = typeof ProjectEntry.Type; |
| 23 | 22 |
pnpm-lock.yaml+202| ... | @@ -395,6 +395,9 @@ importers: | ... | @@ -395,6 +395,9 @@ importers: |
| 395 | '@effect/sql-sqlite-bun': | 395 | '@effect/sql-sqlite-bun': |
| 396 | specifier: 4.0.0-beta.78 | 396 | specifier: 4.0.0-beta.78 |
| 397 | version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | 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 | '@opencode-ai/sdk': | 401 | '@opencode-ai/sdk': |
| 399 | specifier: ^1.3.15 | 402 | specifier: ^1.3.15 |
| 400 | version: 1.15.13 | 403 | version: 1.15.13 |
| ... | @@ -2374,6 +2377,52 @@ packages: | ... | @@ -2374,6 +2377,52 @@ packages: |
| 2374 | resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==} | 2377 | resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==} |
| 2375 | hasBin: true | 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 | '@floating-ui/core@1.7.5': | 2426 | '@floating-ui/core@1.7.5': |
| 2378 | resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} | 2427 | resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} |
| 2379 | 2428 | ||
| ... | @@ -4804,6 +4853,72 @@ packages: | ... | @@ -4804,6 +4853,72 @@ packages: |
| 4804 | '@xterm/xterm@6.0.0': | 4853 | '@xterm/xterm@6.0.0': |
| 4805 | resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} | 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 | '@zxcvbn-ts/core@3.0.4': | 4922 | '@zxcvbn-ts/core@3.0.4': |
| 4808 | resolution: {integrity: sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==} | 4923 | resolution: {integrity: sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==} |
| 4809 | 4924 | ||
| ... | @@ -6353,6 +6468,9 @@ packages: | ... | @@ -6353,6 +6468,9 @@ packages: |
| 6353 | fetch-nodeshim@0.4.10: | 6468 | fetch-nodeshim@0.4.10: |
| 6354 | resolution: {integrity: sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==} | 6469 | resolution: {integrity: sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==} |
| 6355 | 6470 | ||
| 6471 | ffi-rs@1.3.2: | ||
| 6472 | resolution: {integrity: sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ==} | ||
| 6473 | |||
| 6356 | filelist@1.0.6: | 6474 | filelist@1.0.6: |
| 6357 | resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} | 6475 | resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} |
| 6358 | 6476 | ||
| ... | @@ -11712,6 +11830,43 @@ snapshots: | ... | @@ -11712,6 +11830,43 @@ snapshots: |
| 11712 | chalk: 4.1.2 | 11830 | chalk: 4.1.2 |
| 11713 | js-yaml: 4.2.0 | 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 | '@floating-ui/core@1.7.5': | 11870 | '@floating-ui/core@1.7.5': |
| 11716 | dependencies: | 11871 | dependencies: |
| 11717 | '@floating-ui/utils': 0.2.11 | 11872 | '@floating-ui/utils': 0.2.11 |
| ... | @@ -13938,6 +14093,39 @@ snapshots: | ... | @@ -13938,6 +14093,39 @@ snapshots: |
| 13938 | 14093 | ||
| 13939 | '@xterm/xterm@6.0.0': {} | 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 | '@zxcvbn-ts/core@3.0.4': | 14129 | '@zxcvbn-ts/core@3.0.4': |
| 13942 | dependencies: | 14130 | dependencies: |
| 13943 | fastest-levenshtein: 1.0.16 | 14131 | fastest-levenshtein: 1.0.16 |
| ... | @@ -15732,6 +15920,20 @@ snapshots: | ... | @@ -15732,6 +15920,20 @@ snapshots: |
| 15732 | 15920 | ||
| 15733 | fetch-nodeshim@0.4.10: {} | 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 | filelist@1.0.6: | 15937 | filelist@1.0.6: |
| 15736 | dependencies: | 15938 | dependencies: |
| 15737 | minimatch: 5.1.9 | 15939 | minimatch: 5.1.9 |