authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-16 00:02:03-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-16 00:02:03-07:00
logd12da198ad5aed74505326e9d2ffa66d00dc9157
treed16a77b936c0c5216e2c1f19ab97cdf68a29aee5
parentd0a7d18cdbbb4ebbabf4bebe884e9835feb675a6
signature Signed by PGP key B5690EEEBB952194

Use `fff` for workspace search queries (#3099)


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";
74import { deriveServerPaths, ServerConfig } from "../src/config.ts";74import { deriveServerPaths, ServerConfig } from "../src/config.ts";
75import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts";75import * as WorkspaceEntries from "../src/workspace/WorkspaceEntries.ts";
76import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts";76import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts";
77import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts";77import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts";
78import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts";78import { 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";
57import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";57import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";
58import { ServerConfig } from "../../config.ts";58import { ServerConfig } from "../../config.ts";
59import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts";59import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";
60import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts";60import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts";
6161
62const asProjectId = (value: string): ProjectId => ProjectId.make(value);62const 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";
34import type { OrchestrationDispatchError } from "../Errors.ts";34import type { OrchestrationDispatchError } from "../Errors.ts";
35import { isGitRepository } from "../../git/Utils.ts";35import { isGitRepository } from "../../git/Utils.ts";
36import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";36import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
37import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts";37import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";
3838
39const nowIso = Effect.map(DateTime.now, DateTime.formatIso);39const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
4040
...@@ -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;
8888
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 });
254254
255 // Invalidate the workspace entry cache so the @-mention file picker255 // 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);
258258
259 const files = yield* checkpointStore259 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 }
692692
693 // Invalidate the workspace entry cache so the @-mention file picker693 // 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);
696696
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";
51import * as Path from "effect/Path";51import * as Path from "effect/Path";
52import * as PubSub from "effect/PubSub";52import * as PubSub from "effect/PubSub";
53import * as Stream from "effect/Stream";53import * as Stream from "effect/Stream";
54import * as TestClock from "effect/testing/TestClock";
54import { ChildProcessSpawner } from "effect/unstable/process";55import { ChildProcessSpawner } from "effect/unstable/process";
55import {56import {
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";
121import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";122import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
122import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";123import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
123import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";124import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
124import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";125import * 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 );
43594360
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 );
43954396
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) => {
44474448
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 );
44524453
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";
54import { ServerSettingsLive } from "./serverSettings.ts";54import { ServerSettingsLive } from "./serverSettings.ts";
55import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";55import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
56import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";56import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
57import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";57import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
58import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";58import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
59import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";59import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
60import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";60import * 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);
249249
250const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe(250const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive));
251 Layer.provide(WorkspacePathsLive),
252 Layer.provideMerge(VcsDriverRegistryLayerLive),
253);
254251
255const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe(252const 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
2import fsPromises from "node:fs/promises";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it, afterEach, describe, expect, vi } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Path from "effect/Path";
10import * as PlatformError from "effect/PlatformError";
11
12import { ServerConfig } from "../../config.ts";
13import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
14import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts";
15import * as VcsProcess from "../../vcs/VcsProcess.ts";
16import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts";
17import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts";
18import { WorkspacePathsLive } from "./WorkspacePaths.ts";
19
20const 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
33const 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
44function 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
58const 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
72const 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
78const appendSeparator = (input: string) =>
79 Effect.map(HostProcessPlatform, (platform) =>
80 input.endsWith("/") || input.endsWith("\\")
81 ? input
82 : `${input}${platform === "win32" ? "\\" : "/"}`,
83 );
84
85it.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
2import * as NodeOS from "node:os";
3import fsPromises from "node:fs/promises";
4import type { Dirent } from "node:fs";
5
6import * as Cache from "effect/Cache";
7import * as DateTime from "effect/DateTime";
8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import * as Layer from "effect/Layer";
12import * as Path from "effect/Path";
13
14import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts";
15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
16import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";
17import {
18 insertRankedSearchResult,
19 normalizeSearchQuery,
20 scoreQueryMatch,
21 type RankedSearchResult,
22} from "@t3tools/shared/searchRanking";
23
24import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts";
25import {
26 WorkspaceEntries,
27 WorkspaceEntriesBrowseError,
28 WorkspaceEntriesError,
29 type WorkspaceEntriesShape,
30} from "../Services/WorkspaceEntries.ts";
31import { WorkspacePaths } from "../Services/WorkspacePaths.ts";
32
33const WORKSPACE_CACHE_TTL_MS = 15_000;
34const WORKSPACE_CACHE_MAX_KEYS = 4;
35const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;
36const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32;
37const 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
50interface WorkspaceIndex {
51 scannedAt: number;
52 entries: SearchableWorkspaceEntry[];
53 truncated: boolean;
54}
55
56interface SearchableWorkspaceEntry extends ProjectEntry {
57 normalizedPath: string;
58 normalizedName: string;
59}
60
61type RankedWorkspaceEntry = RankedSearchResult<SearchableWorkspaceEntry>;
62
63function toPosixPath(input: string): string {
64 return input.replaceAll("\\", "/");
65}
66
67function 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
77function 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
85function 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
93function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEntry {
94 const normalizedPath = entry.path.toLowerCase();
95 return {
96 ...entry,
97 normalizedPath,
98 normalizedName: basenameOf(normalizedPath),
99 };
100}
101
102function 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
137function isPathInIgnoredDirectory(relativePath: string): boolean {
138 const firstSegment = relativePath.split("/")[0];
139 if (!firstSegment) return false;
140 return IGNORED_DIRECTORY_NAMES.has(firstSegment);
141}
142
143function 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
154const 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
185export 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
555export 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";
8import { ServerConfig } from "../../config.ts";8import { ServerConfig } from "../../config.ts";
9import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts";9import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts";
10import * as VcsProcess from "../../vcs/VcsProcess.ts";10import * as VcsProcess from "../../vcs/VcsProcess.ts";
11import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts";11import * as WorkspaceEntries from "../WorkspaceEntries.ts";
12import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts";12import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts";
13import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts";
14import { WorkspaceFileSystemLive } from "./WorkspaceFileSystem.ts";13import { WorkspaceFileSystemLive } from "./WorkspaceFileSystem.ts";
15import { WorkspacePathsLive } from "./WorkspacePaths.ts";14import { WorkspacePathsLive } from "./WorkspacePaths.ts";
1615
17const ProjectLayer = WorkspaceFileSystemLive.pipe(16const 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);
2120
22const TestLayer = Layer.empty.pipe(21const 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});
5554
56it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => {55it.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) => {
135134
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");
142141
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 });
152146
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 });
158152
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";
14import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts";14import * as WorkspaceEntries from "../WorkspaceEntries.ts";
15import { WorkspacePaths } from "../Services/WorkspacePaths.ts";15import { WorkspacePaths } from "../Services/WorkspacePaths.ts";
1616
17const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024;17const 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;
2424
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 */
9import * as Schema from "effect/Schema";
10import * as Context from "effect/Context";
11import type * as Effect from "effect/Effect";
12
13import type {
14 FilesystemBrowseInput,
15 FilesystemBrowseResult,
16 ProjectListEntriesInput,
17 ProjectListEntriesResult,
18 ProjectSearchEntriesInput,
19 ProjectSearchEntriesResult,
20} from "@t3tools/contracts";
21
22export 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
32export 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 */
47export 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 */
79export 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
2import fsPromises from "node:fs/promises";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { FileFinder } from "@ff-labs/fff-node";
5import { it, afterEach, describe, expect, vi } from "@effect/vitest";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Path from "effect/Path";
10import * as PlatformError from "effect/PlatformError";
11
12import { ServerConfig } from "../config.ts";
13import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
14import * as VcsProcess from "../vcs/VcsProcess.ts";
15import * as WorkspaceEntries from "./WorkspaceEntries.ts";
16import { WorkspacePathsLive } from "./Layers/WorkspacePaths.ts";
17
18const 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
30const 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
41function 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
55const 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
69const 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
75const appendSeparator = (input: string) =>
76 Effect.map(HostProcessPlatform, (platform) =>
77 input.endsWith("/") || input.endsWith("\\")
78 ? input
79 : `${input}${platform === "win32" ? "\\" : "/"}`,
80 );
81
82it.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
2import * as NodeFSP from "node:fs/promises";
3import * as NodeOS from "node:os";
4
5import * as Context from "effect/Context";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";
9import * as RcMap from "effect/RcMap";
10import * as Schema from "effect/Schema";
11
12import type {
13 FilesystemBrowseInput,
14 FilesystemBrowseResult,
15 ProjectListEntriesInput,
16 ProjectListEntriesResult,
17 ProjectSearchEntriesInput,
18 ProjectSearchEntriesResult,
19} from "@t3tools/contracts";
20import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
21import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";
22
23import * as WorkspacePaths from "./Services/WorkspacePaths.ts";
24import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";
25
26export 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
36export 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
47export 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
63function 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
73const 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
104const 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
250export 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 @@
1import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as LayerMap from "effect/LayerMap";
6import * as Schedule from "effect/Schedule";
7import * as Schema from "effect/Schema";
8
9import type {
10 ProjectEntry,
11 ProjectListEntriesResult,
12 ProjectSearchEntriesResult,
13} from "@t3tools/contracts";
14
15const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;
16const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2;
17const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds";
18const WORKSPACE_INDEX_IDLE_TTL = "15 minutes";
19const WORKSPACE_INDEX_SCAN_POLL_INTERVAL = "50 millis";
20
21export 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
33export 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
45export 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
57export 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
69export type WorkspaceSearchIndexError =
70 | WorkspaceSearchIndexCreateFailed
71 | WorkspaceSearchIndexScanTimedOut
72 | WorkspaceSearchIndexSearchFailed
73 | WorkspaceSearchIndexRefreshFailed;
74
75export 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
90function toPosixPath(input: string): string {
91 return input.replaceAll("\\", "/");
92}
93
94function trimDirectorySeparator(input: string): string {
95 return input.endsWith("/") ? input.slice(0, -1) : input;
96}
97
98function parentPathOf(input: string): string | undefined {
99 const separatorIndex = input.lastIndexOf("/");
100 return separatorIndex === -1 ? undefined : input.slice(0, separatorIndex);
101}
102
103function 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
115function 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
141function 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
155const 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
168const 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
185const 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
236const workspaceSearchIndexLayer = (cwd: string) =>
237 Layer.effect(WorkspaceSearchIndex, makeWorkspaceSearchIndex(cwd));
238
239export 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";
78import * as PreviewManager from "./preview/Manager.ts";78import * as PreviewManager from "./preview/Manager.ts";
79import { issueAssetUrl } from "./assets/AssetAccess.ts";79import { issueAssetUrl } from "./assets/AssetAccess.ts";
80import * as PortScanner from "./preview/PortScanner.ts";80import * as PortScanner from "./preview/PortScanner.ts";
81import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";81import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
82import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";82import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";
83import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts";83import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts";
84import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts";84import { 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";
8export interface ComposerPathSearchEntry {8export 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}
1312
14export interface ComposerPathSearchState {13export interface ComposerPathSearchState {
...@@ -84,11 +83,7 @@ export function getComposerPathSearchTargetKey(target: ComposerPathSearchTarget)...@@ -84,11 +83,7 @@ export function getComposerPathSearchTargetKey(target: ComposerPathSearchTarget)
84function toSearchEntries(83function 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}
9388
94export function createComposerPathSearchManager(config: {89export 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"]);
17export const ProjectEntry = Schema.Struct({17export const ProjectEntry = Schema.Struct({
18 path: TrimmedNonEmptyString,18 path: TrimmedNonEmptyString,
19 kind: ProjectEntryKind,19 kind: ProjectEntryKind,
20 parentPath: Schema.optional(TrimmedNonEmptyString),
21});20});
22export type ProjectEntry = typeof ProjectEntry.Type;21export type ProjectEntry = typeof ProjectEntry.Type;
2322
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.78396 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.15402 specifier: ^1.3.15
400 version: 1.15.13403 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: true2378 hasBin: true
23762379
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==}
23792428
...@@ -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==}
48064855
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==}
48094924
...@@ -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==}
63556470
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==}
63586476
...@@ -11712,6 +11830,43 @@ snapshots:...@@ -11712,6 +11830,43 @@ snapshots:
11712 chalk: 4.1.211830 chalk: 4.1.2
11713 js-yaml: 4.2.011831 js-yaml: 4.2.0
1171411832
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.1111872 '@floating-ui/utils': 0.2.11
...@@ -13938,6 +14093,39 @@ snapshots:...@@ -13938,6 +14093,39 @@ snapshots:
1393814093
13939 '@xterm/xterm@6.0.0': {}14094 '@xterm/xterm@6.0.0': {}
1394014095
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.1614131 fastest-levenshtein: 1.0.16
...@@ -15732,6 +15920,20 @@ snapshots:...@@ -15732,6 +15920,20 @@ snapshots:
1573215920
15733 fetch-nodeshim@0.4.10: {}15921 fetch-nodeshim@0.4.10: {}
1573415922
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.915939 minimatch: 5.1.9