| author | |
| committer | |
| log | 0442f5bfcca6eca556ee79d41f4addb7a2022188 |
| tree | 0d344f67f6d6b16c2b48bad2d8a83293403de153 |
| parent | f92afe79c921dd7de20204e83d62fed0491b4e54 |
| signature | Signed by PGP key B5690EEEBB952194 |
34 files changed, 1565 insertions(+), 33 deletions(-)
apps/server/src/auth/RpcAuthorization.ts+1| ... | @@ -154,6 +154,7 @@ export const RPC_REQUIRED_SCOPES = { | ... | @@ -154,6 +154,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 154 | [WS_METHODS.projectsCreateNew]: AuthOrchestrationOperateScope, | 154 | [WS_METHODS.projectsCreateNew]: AuthOrchestrationOperateScope, |
| 155 | [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, | 155 | [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, |
| 156 | [WS_METHODS.filesystemBrowse]: AuthFilesystemReadScope, | 156 | [WS_METHODS.filesystemBrowse]: AuthFilesystemReadScope, |
| 157 | [WS_METHODS.filesystemGetMetadata]: AuthFilesystemReadScope, | ||
| 157 | [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, | 158 | [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, |
| 158 | [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope, | 159 | [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope, |
| 159 | [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, | 160 | [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, |
apps/server/src/observability/RpcInstrumentation.ts+1| ... | @@ -140,6 +140,7 @@ const RPC_AGGREGATES = { | ... | @@ -140,6 +140,7 @@ const RPC_AGGREGATES = { |
| 140 | [WS_METHODS.projectsCreateNew]: "orchestration", | 140 | [WS_METHODS.projectsCreateNew]: "orchestration", |
| 141 | [WS_METHODS.shellOpenInEditor]: "workspace", | 141 | [WS_METHODS.shellOpenInEditor]: "workspace", |
| 142 | [WS_METHODS.filesystemBrowse]: "workspace", | 142 | [WS_METHODS.filesystemBrowse]: "workspace", |
| 143 | [WS_METHODS.filesystemGetMetadata]: "workspace", | ||
| 143 | [WS_METHODS.agentSessionsScan]: "workspace", | 144 | [WS_METHODS.agentSessionsScan]: "workspace", |
| 144 | [WS_METHODS.agentSessionsImport]: "workspace", | 145 | [WS_METHODS.agentSessionsImport]: "workspace", |
| 145 | [WS_METHODS.assetsCreateUrl]: "workspace", | 146 | [WS_METHODS.assetsCreateUrl]: "workspace", |
apps/server/src/workspace/WorkspaceFileSystem.test.ts+92| ... | @@ -57,6 +57,95 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( | ... | @@ -57,6 +57,95 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( |
| 57 | }); | 57 | }); |
| 58 | 58 | ||
| 59 | it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { | 59 | it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { |
| 60 | describe("getMetadata", () => { | ||
| 61 | it.effect("expands home-relative paths on the server", () => | ||
| 62 | Effect.gen(function* () { | ||
| 63 | const service = yield* WorkspaceFileSystem.WorkspaceFileSystem; | ||
| 64 | const home = yield* makeTempDir; | ||
| 65 | yield* writeTextFile(home, "file", "hello"); | ||
| 66 | expect( | ||
| 67 | (yield* service | ||
| 68 | .getMetadata({ paths: ["~/file"] }) | ||
| 69 | .pipe(Effect.provideService(HostProcess.HomeDirectory, home))).entries, | ||
| 70 | ).toEqual([{ kind: "file", byteLength: 5 }]); | ||
| 71 | }), | ||
| 72 | ); | ||
| 73 | |||
| 74 | it.effect( | ||
| 75 | "reports actual kinds for extensionless files, dotfiles, and dotted directories", | ||
| 76 | () => | ||
| 77 | Effect.gen(function* () { | ||
| 78 | const service = yield* WorkspaceFileSystem.WorkspaceFileSystem; | ||
| 79 | const fs = yield* FileSystem.FileSystem; | ||
| 80 | const path = yield* Path.Path; | ||
| 81 | const cwd = yield* makeTempDir; | ||
| 82 | yield* writeTextFile(cwd, "custom-name", "hello"); | ||
| 83 | yield* writeTextFile(cwd, ".npmrc", "registry=test"); | ||
| 84 | yield* fs.makeDirectory(path.join(cwd, "folder.ts")); | ||
| 85 | const file = path.join(cwd, "custom-name"); | ||
| 86 | const result = yield* service.getMetadata({ | ||
| 87 | paths: [ | ||
| 88 | file, | ||
| 89 | path.join(cwd, ".npmrc"), | ||
| 90 | path.join(cwd, "folder.ts"), | ||
| 91 | file, | ||
| 92 | path.join(cwd, "missing"), | ||
| 93 | "relative-path", | ||
| 94 | ], | ||
| 95 | }); | ||
| 96 | expect(result.entries).toEqual([ | ||
| 97 | { kind: "file", byteLength: 5 }, | ||
| 98 | { kind: "file", byteLength: 13 }, | ||
| 99 | { kind: "directory" }, | ||
| 100 | { kind: "file", byteLength: 5 }, | ||
| 101 | null, | ||
| 102 | null, | ||
| 103 | ]); | ||
| 104 | }), | ||
| 105 | ); | ||
| 106 | |||
| 107 | it.effect("detects an extensionless script and image without returning their contents", () => | ||
| 108 | Effect.gen(function* () { | ||
| 109 | const service = yield* WorkspaceFileSystem.WorkspaceFileSystem; | ||
| 110 | const fs = yield* FileSystem.FileSystem; | ||
| 111 | const path = yield* Path.Path; | ||
| 112 | const cwd = yield* makeTempDir; | ||
| 113 | const script = "#!/usr/bin/env python3\nprint('hello')\n"; | ||
| 114 | yield* writeTextFile(cwd, "run", script); | ||
| 115 | const image = path.join(cwd, "image"); | ||
| 116 | yield* fs.writeFile( | ||
| 117 | image, | ||
| 118 | new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), | ||
| 119 | ); | ||
| 120 | expect( | ||
| 121 | (yield* service.getMetadata({ paths: [path.join(cwd, "run"), image] })).entries, | ||
| 122 | ).toEqual([ | ||
| 123 | { kind: "file", byteLength: script.length, mimeType: "text/x-python" }, | ||
| 124 | { kind: "file", byteLength: 8, mimeType: "image/png" }, | ||
| 125 | ]); | ||
| 126 | }), | ||
| 127 | ); | ||
| 128 | |||
| 129 | it.effect.skipIf(!symlinksSupported)("follows file and directory symlinks", () => | ||
| 130 | Effect.gen(function* () { | ||
| 131 | const service = yield* WorkspaceFileSystem.WorkspaceFileSystem; | ||
| 132 | const fs = yield* FileSystem.FileSystem; | ||
| 133 | const path = yield* Path.Path; | ||
| 134 | const cwd = yield* makeTempDir; | ||
| 135 | yield* writeTextFile(cwd, "target", "text"); | ||
| 136 | yield* fs.makeDirectory(path.join(cwd, "directory")); | ||
| 137 | const fileLink = path.join(cwd, "file-link"); | ||
| 138 | const directoryLink = path.join(cwd, "directory-link"); | ||
| 139 | yield* fs.symlink(path.join(cwd, "target"), fileLink); | ||
| 140 | yield* fs.symlink(path.join(cwd, "directory"), directoryLink); | ||
| 141 | expect((yield* service.getMetadata({ paths: [fileLink, directoryLink] })).entries).toEqual([ | ||
| 142 | { kind: "file", byteLength: 4 }, | ||
| 143 | { kind: "directory" }, | ||
| 144 | ]); | ||
| 145 | }), | ||
| 146 | ); | ||
| 147 | }); | ||
| 148 | |||
| 60 | describe("readFile", () => { | 149 | describe("readFile", () => { |
| 61 | it.effect("reads UTF-8 files relative to the workspace root", () => | 150 | it.effect("reads UTF-8 files relative to the workspace root", () => |
| 62 | Effect.gen(function* () { | 151 | Effect.gen(function* () { |
| ... | @@ -125,6 +214,9 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i | ... | @@ -125,6 +214,9 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i |
| 125 | .pipe(Effect.flip); | 214 | .pipe(Effect.flip); |
| 126 | 215 | ||
| 127 | expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError); | 216 | expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError); |
| 217 | expect((yield* workspaceFileSystem.getMetadata({ paths: [fifoPath] })).entries).toEqual([ | ||
| 218 | { kind: "other" }, | ||
| 219 | ]); | ||
| 128 | }), | 220 | }), |
| 129 | ); | 221 | ); |
| 130 | 222 |
apps/server/src/workspace/WorkspaceFileSystem.ts+56-1| ... | @@ -13,11 +13,16 @@ import * as NodeFS from "node:fs"; | ... | @@ -13,11 +13,16 @@ import * as NodeFS from "node:fs"; |
| 13 | import * as NodeFSP from "node:fs/promises"; | 13 | import * as NodeFSP from "node:fs/promises"; |
| 14 | 14 | ||
| 15 | import type { | 15 | import type { |
| 16 | FilesystemEntryMetadata, | ||
| 17 | FilesystemGetMetadataInput, | ||
| 18 | FilesystemGetMetadataResult, | ||
| 16 | ProjectReadFileInput, | 19 | ProjectReadFileInput, |
| 17 | ProjectReadFileResult, | 20 | ProjectReadFileResult, |
| 18 | ProjectWriteFileInput, | 21 | ProjectWriteFileInput, |
| 19 | ProjectWriteFileResult, | 22 | ProjectWriteFileResult, |
| 20 | } from "@t3tools/contracts"; | 23 | } from "@t3tools/contracts"; |
| 24 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; | ||
| 25 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 21 | import * as Context from "effect/Context"; | 26 | import * as Context from "effect/Context"; |
| 22 | import * as Effect from "effect/Effect"; | 27 | import * as Effect from "effect/Effect"; |
| 23 | import * as FileSystem from "effect/FileSystem"; | 28 | import * as FileSystem from "effect/FileSystem"; |
| ... | @@ -27,6 +32,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -27,6 +32,7 @@ import * as Schema from "effect/Schema"; |
| 27 | 32 | ||
| 28 | import * as WorkspaceEntries from "./WorkspaceEntries.ts"; | 33 | import * as WorkspaceEntries from "./WorkspaceEntries.ts"; |
| 29 | import * as WorkspacePaths from "./WorkspacePaths.ts"; | 34 | import * as WorkspacePaths from "./WorkspacePaths.ts"; |
| 35 | import { fileHeaderMimeType } from "./fileHeaderMimeType.ts"; | ||
| 30 | 36 | ||
| 31 | const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; | 37 | const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; |
| 32 | 38 | ||
| ... | @@ -107,6 +113,10 @@ export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; | ... | @@ -107,6 +113,10 @@ export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; |
| 107 | export class WorkspaceFileSystem extends Context.Service< | 113 | export class WorkspaceFileSystem extends Context.Service< |
| 108 | WorkspaceFileSystem, | 114 | WorkspaceFileSystem, |
| 109 | { | 115 | { |
| 116 | readonly getMetadata: ( | ||
| 117 | input: FilesystemGetMetadataInput, | ||
| 118 | ) => Effect.Effect<FilesystemGetMetadataResult>; | ||
| 119 | |||
| 110 | /** | 120 | /** |
| 111 | * Read a UTF-8 text file relative to the workspace root, or any host file by | 121 | * Read a UTF-8 text file relative to the workspace root, or any host file by |
| 112 | * absolute path. | 122 | * absolute path. |
| ... | @@ -139,6 +149,51 @@ export const make = Effect.gen(function* () { | ... | @@ -139,6 +149,51 @@ export const make = Effect.gen(function* () { |
| 139 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; | 149 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; |
| 140 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | 150 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; |
| 141 | 151 | ||
| 152 | const metadataForPath = Effect.fnUntraced(function* (requestedPath: string) { | ||
| 153 | const expandedPath = expandHomePath(requestedPath, yield* HostProcess.HomeDirectory); | ||
| 154 | if (!path.isAbsolute(expandedPath)) return null; | ||
| 155 | const stat = yield* fileSystem.stat(expandedPath).pipe(Effect.option); | ||
| 156 | if (stat._tag === "None") return null; | ||
| 157 | if (stat.value.type === "Directory") return { kind: "directory" } as const; | ||
| 158 | if (stat.value.type !== "File") return { kind: "other" } as const; | ||
| 159 | let mimeType: string | undefined; | ||
| 160 | // Named extensions already choose the icon without a content read. For an | ||
| 161 | // extensionless regular file, read at most 512 bytes, never the whole file. | ||
| 162 | if (path.extname(expandedPath) === "" && stat.value.size > 0) { | ||
| 163 | mimeType = yield* Effect.tryPromise({ | ||
| 164 | try: async () => { | ||
| 165 | const handle = await NodeFSP.open( | ||
| 166 | expandedPath, | ||
| 167 | NodeFS.constants.O_RDONLY | NodeFS.constants.O_NONBLOCK, | ||
| 168 | ); | ||
| 169 | try { | ||
| 170 | if (!(await handle.stat()).isFile()) return undefined; | ||
| 171 | const buffer = Buffer.alloc(512); | ||
| 172 | const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); | ||
| 173 | return fileHeaderMimeType(buffer.subarray(0, bytesRead)); | ||
| 174 | } finally { | ||
| 175 | await handle.close(); | ||
| 176 | } | ||
| 177 | }, | ||
| 178 | catch: () => undefined, | ||
| 179 | }).pipe(Effect.catch(() => Effect.succeed(undefined))); | ||
| 180 | } | ||
| 181 | return { | ||
| 182 | kind: "file", | ||
| 183 | byteLength: Number(stat.value.size), | ||
| 184 | ...(mimeType === undefined ? {} : { mimeType }), | ||
| 185 | } satisfies FilesystemEntryMetadata; | ||
| 186 | }); | ||
| 187 | |||
| 188 | const getMetadata: WorkspaceFileSystem["Service"]["getMetadata"] = Effect.fn( | ||
| 189 | "WorkspaceFileSystem.getMetadata", | ||
| 190 | )(function* (input) { | ||
| 191 | const paths = [...new Set(input.paths)]; | ||
| 192 | const entries = yield* Effect.forEach(paths, metadataForPath, { concurrency: 8 }); | ||
| 193 | const byPath = new Map(paths.map((value, index) => [value, entries[index] ?? null])); | ||
| 194 | return { entries: input.paths.map((value) => byPath.get(value) ?? null) }; | ||
| 195 | }); | ||
| 196 | |||
| 142 | /** | 197 | /** |
| 143 | * Resolves the file a read targets. Workspace-relative paths must stay inside the | 198 | * Resolves the file a read targets. Workspace-relative paths must stay inside the |
| 144 | * root, symlinks included. An absolute path reads a host file in place, such as a | 199 | * root, symlinks included. An absolute path reads a host file in place, such as a |
| ... | @@ -340,7 +395,7 @@ export const make = Effect.gen(function* () { | ... | @@ -340,7 +395,7 @@ export const make = Effect.gen(function* () { |
| 340 | return { relativePath: target.relativePath }; | 395 | return { relativePath: target.relativePath }; |
| 341 | }); | 396 | }); |
| 342 | 397 | ||
| 343 | return WorkspaceFileSystem.of({ readFile, writeFile }); | 398 | return WorkspaceFileSystem.of({ getMetadata, readFile, writeFile }); |
| 344 | }); | 399 | }); |
| 345 | 400 | ||
| 346 | export const layer = Layer.effect(WorkspaceFileSystem, make); | 401 | export const layer = Layer.effect(WorkspaceFileSystem, make); |
apps/server/src/workspace/fileHeaderMimeType.test.ts created+30| ... | @@ -0,0 +1,30 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { fileHeaderMimeType } from "./fileHeaderMimeType.ts"; | ||
| 3 | |||
| 4 | describe("fileHeaderMimeType", () => { | ||
| 5 | it.each([ | ||
| 6 | [[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], "image/png"], | ||
| 7 | [[0xff, 0xd8, 0xff], "image/jpeg"], | ||
| 8 | [[0x50, 0x4b, 0x03, 0x04], "application/zip"], | ||
| 9 | [[0x52, 0x49, 0x46, 0x46, 0xc2, 0xa0, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50], "image/webp"], | ||
| 10 | [[0x89, 0x50, 0x4e], undefined], | ||
| 11 | ])("classifies binary header %j", (header, expected) => { | ||
| 12 | expect(fileHeaderMimeType(new Uint8Array(header))).toBe(expected); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it.each([ | ||
| 16 | ["%PDF-1.7", "application/pdf"], | ||
| 17 | ["GIF89a", "image/gif"], | ||
| 18 | ["SQLite format 3\0", "application/vnd.sqlite3"], | ||
| 19 | ["RIFF1234WEBP", "image/webp"], | ||
| 20 | ["#!/usr/bin/env python3\n", "text/x-python"], | ||
| 21 | ["#!/usr/bin/node\n", "text/javascript"], | ||
| 22 | ["#!/bin/bash\n", "text/x-shellscript"], | ||
| 23 | ["#!/usr/bin/env ruby\n", "text/x-ruby"], | ||
| 24 | ["hello", undefined], | ||
| 25 | ["", undefined], | ||
| 26 | ["#!/usr/bin/env custom\n", undefined], | ||
| 27 | ])("classifies %j", (header, expected) => { | ||
| 28 | expect(fileHeaderMimeType(new TextEncoder().encode(header))).toBe(expected); | ||
| 29 | }); | ||
| 30 | }); | ||
apps/server/src/workspace/fileHeaderMimeType.ts created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | /** Formats whose headers give extensionless files a useful icon. This is a | ||
| 2 | * presentation hint, never a validation of a file's contents or safety. */ | ||
| 3 | export function fileHeaderMimeType(bytes: Uint8Array): string | undefined { | ||
| 4 | const startsWith = (signature: readonly number[], offset = 0) => | ||
| 5 | signature.every((byte, index) => bytes[offset + index] === byte); | ||
| 6 | if (startsWith([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"; | ||
| 7 | if (startsWith([0xff, 0xd8, 0xff])) return "image/jpeg"; | ||
| 8 | if (startsWith([0x50, 0x4b, 0x03, 0x04])) return "application/zip"; | ||
| 9 | if (startsWith([0x52, 0x49, 0x46, 0x46]) && startsWith([0x57, 0x45, 0x42, 0x50], 8)) | ||
| 10 | return "image/webp"; | ||
| 11 | const header = new TextDecoder().decode(bytes); | ||
| 12 | if (header.startsWith("GIF87a") || header.startsWith("GIF89a")) return "image/gif"; | ||
| 13 | if (header.startsWith("%PDF-")) return "application/pdf"; | ||
| 14 | if (header.startsWith("SQLite format 3\0")) return "application/vnd.sqlite3"; | ||
| 15 | if (!header.startsWith("#!")) return undefined; | ||
| 16 | const interpreter = header.split("\n", 1)[0] ?? ""; | ||
| 17 | if (/\bpython[\d.]*\b/.test(interpreter)) return "text/x-python"; | ||
| 18 | if (/\b(?:node|nodejs)\b/.test(interpreter)) return "text/javascript"; | ||
| 19 | if (/\bruby\b/.test(interpreter)) return "text/x-ruby"; | ||
| 20 | if (/\b(?:sh|bash|zsh|fish)\b/.test(interpreter)) return "text/x-shellscript"; | ||
| 21 | return undefined; | ||
| 22 | } | ||
apps/server/src/ws.ts+1| ... | @@ -2681,6 +2681,7 @@ const layerWsRpc = ( | ... | @@ -2681,6 +2681,7 @@ const layerWsRpc = ( |
| 2681 | ), | 2681 | ), |
| 2682 | ), | 2682 | ), |
| 2683 | [WS_METHODS.shellOpenInEditor]: (input) => externalLauncher.launchEditor(input), | 2683 | [WS_METHODS.shellOpenInEditor]: (input) => externalLauncher.launchEditor(input), |
| 2684 | [WS_METHODS.filesystemGetMetadata]: (input) => workspaceFileSystem.getMetadata(input), | ||
| 2684 | [WS_METHODS.filesystemBrowse]: (input) => | 2685 | [WS_METHODS.filesystemBrowse]: (input) => |
| 2685 | workspaceEntries.browse(input).pipe( | 2686 | workspaceEntries.browse(input).pipe( |
| 2686 | Effect.mapError( | 2687 | Effect.mapError( |
apps/web/src/components/ChatMarkdown.assets.test.tsx+1| ... | @@ -7,6 +7,7 @@ import { expect, it, vi } from "vite-plus/test"; | ... | @@ -7,6 +7,7 @@ import { expect, it, vi } from "vite-plus/test"; |
| 7 | const mint = vi.hoisted(() => vi.fn()); | 7 | const mint = vi.hoisted(() => vi.fn()); |
| 8 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); | 8 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); |
| 9 | vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); | 9 | vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); |
| 10 | vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => undefined })); | ||
| 10 | vi.mock("../hooks/useSettings", async (importOriginal) => { | 11 | vi.mock("../hooks/useSettings", async (importOriginal) => { |
| 11 | const actual = await importOriginal<typeof import("../hooks/useSettings")>(); | 12 | const actual = await importOriginal<typeof import("../hooks/useSettings")>(); |
| 12 | const settings = actual.getClientSettings(); | 13 | const settings = actual.getClientSettings(); |
apps/web/src/components/ChatMarkdown.permissions.test.tsx+1| ... | @@ -29,6 +29,7 @@ const state = vi.hoisted(() => ({ | ... | @@ -29,6 +29,7 @@ const state = vi.hoisted(() => ({ |
| 29 | 29 | ||
| 30 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => serverConfig })); | 30 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => serverConfig })); |
| 31 | vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); | 31 | vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); |
| 32 | vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => undefined })); | ||
| 32 | vi.mock("../hooks/useSettings", () => ({ | 33 | vi.mock("../hooks/useSettings", () => ({ |
| 33 | getClientSettings: () => DEFAULT_CLIENT_SETTINGS, | 34 | getClientSettings: () => DEFAULT_CLIENT_SETTINGS, |
| 34 | useClientSettings: (select?: (value: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => | 35 | useClientSettings: (select?: (value: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => |
apps/web/src/components/ChatMarkdown.test.tsx+1| ... | @@ -17,6 +17,7 @@ import { GitHubIcon } from "./Icons"; | ... | @@ -17,6 +17,7 @@ import { GitHubIcon } from "./Icons"; |
| 17 | import { Button } from "./ui/button"; | 17 | import { Button } from "./ui/button"; |
| 18 | import { setMarkdownTaskChecked } from "./files/filePreviewMode"; | 18 | import { setMarkdownTaskChecked } from "./files/filePreviewMode"; |
| 19 | 19 | ||
| 20 | vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null })); | ||
| 20 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); | 21 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); |
| 21 | vi.mock("./chat/MermaidDiagram", () => ({ | 22 | vi.mock("./chat/MermaidDiagram", () => ({ |
| 22 | // Real Mermaid needs layout APIs jsdom lacks; a rendered diagram is an SVG. | 23 | // Real Mermaid needs layout APIs jsdom lacks; a rendered diagram is an SVG. |
apps/web/src/components/ChatMarkdown.tsx+19-3| ... | @@ -121,6 +121,7 @@ import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGa | ... | @@ -121,6 +121,7 @@ import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGa |
| 121 | import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; | 121 | import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; |
| 122 | import { MediaActions, type MediaActionSource } from "./media/MediaActions"; | 122 | import { MediaActions, type MediaActionSource } from "./media/MediaActions"; |
| 123 | import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; | 123 | import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; |
| 124 | import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; | ||
| 124 | import { FileTagChipContent } from "./chat/FileTagChip"; | 125 | import { FileTagChipContent } from "./chat/FileTagChip"; |
| 125 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; | 126 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; |
| 126 | import { | 127 | import { |
| ... | @@ -1192,6 +1193,7 @@ function UncachedShikiCodeBlock({ | ... | @@ -1192,6 +1193,7 @@ function UncachedShikiCodeBlock({ |
| 1192 | } | 1193 | } |
| 1193 | 1194 | ||
| 1194 | interface MarkdownFileLinkProps { | 1195 | interface MarkdownFileLinkProps { |
| 1196 | environmentId: EnvironmentId | null; | ||
| 1195 | href: string; | 1197 | href: string; |
| 1196 | targetPath: string; | 1198 | targetPath: string; |
| 1197 | iconPath: string; | 1199 | iconPath: string; |
| ... | @@ -1929,6 +1931,7 @@ function MarkdownExternalLinkContent({ | ... | @@ -1929,6 +1931,7 @@ function MarkdownExternalLinkContent({ |
| 1929 | } | 1931 | } |
| 1930 | 1932 | ||
| 1931 | const MarkdownFileLink = memo(function MarkdownFileLink({ | 1933 | const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1934 | environmentId, | ||
| 1932 | href, | 1935 | href, |
| 1933 | targetPath, | 1936 | targetPath, |
| 1934 | iconPath, | 1937 | iconPath, |
| ... | @@ -2235,7 +2238,12 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -2235,7 +2238,12 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 2235 | }} | 2238 | }} |
| 2236 | onContextMenu={handleContextMenu} | 2239 | onContextMenu={handleContextMenu} |
| 2237 | > | 2240 | > |
| 2238 | <FileTagChipContent path={iconPath} label={label} theme={theme} /> | 2241 | <FileTagChipContent |
| 2242 | path={iconPath} | ||
| 2243 | environmentId={environmentId} | ||
| 2244 | label={label} | ||
| 2245 | theme={theme} | ||
| 2246 | /> | ||
| 2239 | </ContextChip> | 2247 | </ContextChip> |
| 2240 | ) : ( | 2248 | ) : ( |
| 2241 | <ContextChip | 2249 | <ContextChip |
| ... | @@ -2248,7 +2256,12 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -2248,7 +2256,12 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 2248 | onClick={handleContextMenu} | 2256 | onClick={handleContextMenu} |
| 2249 | onContextMenu={handleContextMenu} | 2257 | onContextMenu={handleContextMenu} |
| 2250 | > | 2258 | > |
| 2251 | <FileTagChipContent path={iconPath} label={label} theme={theme} /> | 2259 | <FileTagChipContent |
| 2260 | path={iconPath} | ||
| 2261 | environmentId={environmentId} | ||
| 2262 | label={label} | ||
| 2263 | theme={theme} | ||
| 2264 | /> | ||
| 2252 | </ContextChip> | 2265 | </ContextChip> |
| 2253 | ) | 2266 | ) |
| 2254 | } | 2267 | } |
| ... | @@ -2257,7 +2270,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -2257,7 +2270,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 2257 | {/* The full path: the chip already shows the shortened form, and a link | 2270 | {/* The full path: the chip already shows the shortened form, and a link |
| 2258 | to the workspace root collapses to a bare label that repeats it. */} | 2271 | to the workspace root collapses to a bare label that repeats it. */} |
| 2259 | <div className="overflow-x-auto whitespace-nowrap scrollbar-thumb-border/78 scrollbar-track-transparent [scrollbar-width:thin] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/78 [&::-webkit-scrollbar-track]:bg-transparent"> | 2272 | <div className="overflow-x-auto whitespace-nowrap scrollbar-thumb-border/78 scrollbar-track-transparent [scrollbar-width:thin] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/78 [&::-webkit-scrollbar-track]:bg-transparent"> |
| 2260 | {targetPath} | 2273 | <WorkspaceEntryTooltip path={iconPath} label={targetPath} environmentId={environmentId} /> |
| 2261 | </div> | 2274 | </div> |
| 2262 | </TooltipPopup> | 2275 | </TooltipPopup> |
| 2263 | </Tooltip> | 2276 | </Tooltip> |
| ... | @@ -2272,6 +2285,7 @@ function areMarkdownFileLinkPropsEqual( | ... | @@ -2272,6 +2285,7 @@ function areMarkdownFileLinkPropsEqual( |
| 2272 | previous.href === next.href && | 2285 | previous.href === next.href && |
| 2273 | previous.targetPath === next.targetPath && | 2286 | previous.targetPath === next.targetPath && |
| 2274 | previous.iconPath === next.iconPath && | 2287 | previous.iconPath === next.iconPath && |
| 2288 | previous.environmentId === next.environmentId && | ||
| 2275 | previous.displayPath === next.displayPath && | 2289 | previous.displayPath === next.displayPath && |
| 2276 | previous.panelPath === next.panelPath && | 2290 | previous.panelPath === next.panelPath && |
| 2277 | previous.line === next.line && | 2291 | previous.line === next.line && |
| ... | @@ -2642,6 +2656,7 @@ function useChatMarkdownState({ | ... | @@ -2642,6 +2656,7 @@ function useChatMarkdownState({ |
| 2642 | 2656 | ||
| 2643 | return ( | 2657 | return ( |
| 2644 | <MarkdownFileLink | 2658 | <MarkdownFileLink |
| 2659 | environmentId={environmentId} | ||
| 2645 | href={fileLinkMeta.targetPath} | 2660 | href={fileLinkMeta.targetPath} |
| 2646 | targetPath={fileLinkMeta.targetPath} | 2661 | targetPath={fileLinkMeta.targetPath} |
| 2647 | iconPath={fileLinkMeta.filePath} | 2662 | iconPath={fileLinkMeta.filePath} |
| ... | @@ -2687,6 +2702,7 @@ function useChatMarkdownState({ | ... | @@ -2687,6 +2702,7 @@ function useChatMarkdownState({ |
| 2687 | [ | 2702 | [ |
| 2688 | canUseShellActions, | 2703 | canUseShellActions, |
| 2689 | canOperatePreview, | 2704 | canOperatePreview, |
| 2705 | environmentId, | ||
| 2690 | fileLinkParentSuffixByPath, | 2706 | fileLinkParentSuffixByPath, |
| 2691 | openFileInPanel, | 2707 | openFileInPanel, |
| 2692 | openInPreferredEditor, | 2708 | openInPreferredEditor, |
apps/web/src/components/ChatView.tsx+11-1| ... | @@ -241,6 +241,7 @@ import { isCommandPaletteOpen } from "../commandPaletteBus"; | ... | @@ -241,6 +241,7 @@ import { isCommandPaletteOpen } from "../commandPaletteBus"; |
| 241 | import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; | 241 | import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; |
| 242 | import { useMediaQuery } from "../hooks/useMediaQuery"; | 242 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 243 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 243 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 244 | import { FileMetadataThreadProvider } from "../hooks/FileMetadataThreadProvider"; | ||
| 244 | import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize"; | 245 | import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize"; |
| 245 | import { | 246 | import { |
| 246 | RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY, | 247 | RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY, |
| ... | @@ -11277,7 +11278,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -11277,7 +11278,7 @@ export default function ChatView(props: ChatViewProps) { |
| 11277 | addFolders: (folders) => composerRef.current?.addDroppedFolders(folders), | 11278 | addFolders: (folders) => composerRef.current?.addDroppedFolders(folders), |
| 11278 | }); | 11279 | }); |
| 11279 | 11280 | ||
| 11280 | return ( | 11281 | const content = ( |
| 11281 | <div | 11282 | <div |
| 11282 | ref={setWorkspaceLayoutElement} | 11283 | ref={setWorkspaceLayoutElement} |
| 11283 | className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background" | 11284 | className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background" |
| ... | @@ -12117,6 +12118,15 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -12117,6 +12118,15 @@ export default function ChatView(props: ChatViewProps) { |
| 12117 | )} | 12118 | )} |
| 12118 | </div> | 12119 | </div> |
| 12119 | ); | 12120 | ); |
| 12121 | return ( | ||
| 12122 | <FileMetadataThreadProvider | ||
| 12123 | threadRef={activeThreadRef} | ||
| 12124 | cwd={activeWorkspaceRoot} | ||
| 12125 | checkpoints={serverProjection?.checkpoints} | ||
| 12126 | > | ||
| 12127 | {content} | ||
| 12128 | </FileMetadataThreadProvider> | ||
| 12129 | ); | ||
| 12120 | } | 12130 | } |
| 12121 | 12131 | ||
| 12122 | /** Keeps the thread's preview tabs synced while no browser panel is mounted. */ | 12132 | /** Keeps the thread's preview tabs synced while no browser panel is mounted. */ |
apps/web/src/components/ComposerPromptEditorTiptap.tsx+10-1| ... | @@ -91,6 +91,7 @@ import { collectInlineContextIds } from "~/lib/composerContextReferences"; | ... | @@ -91,6 +91,7 @@ import { collectInlineContextIds } from "~/lib/composerContextReferences"; |
| 91 | import { resolveDiffThemeName } from "~/lib/diffRendering"; | 91 | import { resolveDiffThemeName } from "~/lib/diffRendering"; |
| 92 | import { cn, isMacPlatform } from "~/lib/utils"; | 92 | import { cn, isMacPlatform } from "~/lib/utils"; |
| 93 | import { basenameOfPath } from "~/pierre-icons"; | 93 | import { basenameOfPath } from "~/pierre-icons"; |
| 94 | import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; | ||
| 94 | import { FileTagChipContent } from "./chat/FileTagChip"; | 95 | import { FileTagChipContent } from "./chat/FileTagChip"; |
| 95 | import { SkillChipIcon } from "./chat/SkillInlineText"; | 96 | import { SkillChipIcon } from "./chat/SkillInlineText"; |
| 96 | import { AssistantCitationChip } from "./chat/AssistantCitationChip"; | 97 | import { AssistantCitationChip } from "./chat/AssistantCitationChip"; |
| ... | @@ -273,6 +274,8 @@ function ComposerMentionNodeView({ node }: NodeViewProps) { | ... | @@ -273,6 +274,8 @@ function ComposerMentionNodeView({ node }: NodeViewProps) { |
| 273 | > | 274 | > |
| 274 | <FileTagChipContent | 275 | <FileTagChipContent |
| 275 | path={path} | 276 | path={path} |
| 277 | environmentId={actions.environmentId} | ||
| 278 | cwd={actions.cwd} | ||
| 276 | label={basenameOfPath(path)} | 279 | label={basenameOfPath(path)} |
| 277 | theme={resolvedThemeFromDocument()} | 280 | theme={resolvedThemeFromDocument()} |
| 278 | /> | 281 | /> |
| ... | @@ -282,7 +285,13 @@ function ComposerMentionNodeView({ node }: NodeViewProps) { | ... | @@ -282,7 +285,13 @@ function ComposerMentionNodeView({ node }: NodeViewProps) { |
| 282 | <NodeViewWrapper as="span" className={CHIP_NODE_SELECTION_CLASS_NAME}> | 285 | <NodeViewWrapper as="span" className={CHIP_NODE_SELECTION_CLASS_NAME}> |
| 283 | <Tooltip> | 286 | <Tooltip> |
| 284 | <TooltipTrigger render={chip} /> | 287 | <TooltipTrigger render={chip} /> |
| 285 | <TooltipPopup side="top">{path}</TooltipPopup> | 288 | <TooltipPopup side="top"> |
| 289 | <WorkspaceEntryTooltip | ||
| 290 | path={path} | ||
| 291 | environmentId={actions.environmentId} | ||
| 292 | cwd={actions.cwd} | ||
| 293 | /> | ||
| 294 | </TooltipPopup> | ||
| 286 | </Tooltip> | 295 | </Tooltip> |
| 287 | </NodeViewWrapper> | 296 | </NodeViewWrapper> |
| 288 | ); | 297 | ); |
apps/web/src/components/chat/ChatComposer.tsx+10-1| ... | @@ -1917,6 +1917,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1917,6 +1917,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1917 | const composerContextActions = useMemo( | 1917 | const composerContextActions = useMemo( |
| 1918 | () => ({ | 1918 | () => ({ |
| 1919 | environmentId, | 1919 | environmentId, |
| 1920 | cwd: gitCwd ?? undefined, | ||
| 1920 | expandImage: (imageId: string) => { | 1921 | expandImage: (imageId: string) => { |
| 1921 | const preview = buildExpandedImagePreview(composerImages, imageId); | 1922 | const preview = buildExpandedImagePreview(composerImages, imageId); |
| 1922 | if (preview) onExpandImage(preview); | 1923 | if (preview) onExpandImage(preview); |
| ... | @@ -1947,7 +1948,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1947,7 +1948,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1947 | openPrLink(event, url); | 1948 | openPrLink(event, url); |
| 1948 | }, | 1949 | }, |
| 1949 | }), | 1950 | }), |
| 1950 | [composerFiles, composerImages, environmentId, onExpandImage, openPrLink, routeThreadRef], | 1951 | [ |
| 1952 | composerFiles, | ||
| 1953 | composerImages, | ||
| 1954 | gitCwd, | ||
| 1955 | environmentId, | ||
| 1956 | onExpandImage, | ||
| 1957 | openPrLink, | ||
| 1958 | routeThreadRef, | ||
| 1959 | ], | ||
| 1951 | ); | 1960 | ); |
| 1952 | const composerContextRecords = useMemo( | 1961 | const composerContextRecords = useMemo( |
| 1953 | () => | 1962 | () => |
apps/web/src/components/chat/FileTagChip.tsx+8-5| ... | @@ -1,18 +1,21 @@ | ... | @@ -1,18 +1,21 @@ |
| 1 | import { inferEntryKindFromPath } from "../../pierre-icons"; | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { ContextChipLabel } from "../ContextChip"; | 2 | import { ContextChipLabel } from "../ContextChip"; |
| 3 | import { PierreEntryIcon } from "./PierreEntryIcon"; | 3 | import { WorkspaceEntryIcon } from "./WorkspaceEntryIcon"; |
| 4 | 4 | ||
| 5 | /** Icon and label for a file mention; render inside `<ContextChip kind="mention">`. */ | 5 | /** Icon and label for a file mention; render inside `<ContextChip kind="mention">`. */ |
| 6 | export function FileTagChipContent(props: { | 6 | export function FileTagChipContent(props: { |
| 7 | path: string; | 7 | path: string; |
| 8 | label: string; | 8 | label: string; |
| 9 | theme: "light" | "dark"; | 9 | theme: "light" | "dark"; |
| 10 | environmentId?: EnvironmentId | null | undefined; | ||
| 11 | cwd?: string | undefined; | ||
| 10 | }) { | 12 | }) { |
| 11 | return ( | 13 | return ( |
| 12 | <> | 14 | <> |
| 13 | <PierreEntryIcon | 15 | <WorkspaceEntryIcon |
| 14 | pathValue={props.path} | 16 | path={props.path} |
| 15 | kind={inferEntryKindFromPath(props.path)} | 17 | environmentId={props.environmentId} |
| 18 | cwd={props.cwd} | ||
| 16 | theme={props.theme} | 19 | theme={props.theme} |
| 17 | /> | 20 | /> |
| 18 | <ContextChipLabel>{props.label}</ContextChipLabel> | 21 | <ContextChipLabel>{props.label}</ContextChipLabel> |
apps/web/src/components/chat/MessagesTimeline.tsx+12-5| ... | @@ -187,7 +187,7 @@ import { | ... | @@ -187,7 +187,7 @@ import { |
| 187 | } from "./timelineScrollAnchoring"; | 187 | } from "./timelineScrollAnchoring"; |
| 188 | import { MessageCopyButton } from "./MessageCopyButton"; | 188 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 189 | import { PierreEntryIcon } from "./PierreEntryIcon"; | 189 | import { PierreEntryIcon } from "./PierreEntryIcon"; |
| 190 | import { inferEntryKindFromPath } from "../../pierre-icons"; | 190 | import { WorkspaceEntryIcon, WorkspaceEntryTooltip } from "./WorkspaceEntryIcon"; |
| 191 | import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; | 191 | import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; |
| 192 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; | 192 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; |
| 193 | import { | 193 | import { |
| ... | @@ -4264,16 +4264,23 @@ function UserMessageMentionChip(props: { | ... | @@ -4264,16 +4264,23 @@ function UserMessageMentionChip(props: { |
| 4264 | useRightPanelStore.getState().openFile(ctx.threadRef, props.record.path); | 4264 | useRightPanelStore.getState().openFile(ctx.threadRef, props.record.path); |
| 4265 | }} | 4265 | }} |
| 4266 | > | 4266 | > |
| 4267 | <PierreEntryIcon | 4267 | <WorkspaceEntryIcon |
| 4268 | pathValue={props.record.path} | 4268 | path={props.record.path} |
| 4269 | kind={inferEntryKindFromPath(props.record.path)} | 4269 | environmentId={ctx.activeThreadEnvironmentId} |
| 4270 | cwd={ctx.workspaceRoot} | ||
| 4270 | theme={ctx.resolvedTheme} | 4271 | theme={ctx.resolvedTheme} |
| 4271 | /> | 4272 | /> |
| 4272 | <ContextChipLabel>{props.record.label}</ContextChipLabel> | 4273 | <ContextChipLabel>{props.record.label}</ContextChipLabel> |
| 4273 | </ContextChip> | 4274 | </ContextChip> |
| 4274 | } | 4275 | } |
| 4275 | /> | 4276 | /> |
| 4276 | <TooltipPopup>{props.record.path}</TooltipPopup> | 4277 | <TooltipPopup> |
| 4278 | <WorkspaceEntryTooltip | ||
| 4279 | path={props.record.path} | ||
| 4280 | environmentId={ctx.activeThreadEnvironmentId} | ||
| 4281 | cwd={ctx.workspaceRoot} | ||
| 4282 | /> | ||
| 4283 | </TooltipPopup> | ||
| 4277 | </Tooltip> | 4284 | </Tooltip> |
| 4278 | ); | 4285 | ); |
| 4279 | } | 4286 | } |
apps/web/src/components/chat/PierreEntryIcon.tsx+3-2| ... | @@ -62,13 +62,14 @@ const ICON_COLORS: Record<string, readonly [light: string, dark: string]> = { | ... | @@ -62,13 +62,14 @@ const ICON_COLORS: Record<string, readonly [light: string, dark: string]> = { |
| 62 | export const PierreEntryIcon = memo(function PierreEntryIcon(props: { | 62 | export const PierreEntryIcon = memo(function PierreEntryIcon(props: { |
| 63 | pathValue: string; | 63 | pathValue: string; |
| 64 | kind: "file" | "directory"; | 64 | kind: "file" | "directory"; |
| 65 | mimeType?: string | undefined; | ||
| 65 | theme: "light" | "dark"; | 66 | theme: "light" | "dark"; |
| 66 | className?: string; | 67 | className?: string; |
| 67 | }) { | 68 | }) { |
| 68 | useInsertionEffect(ensurePierreIconSprite, []); | 69 | useInsertionEffect(ensurePierreIconSprite, []); |
| 69 | const icon = useMemo( | 70 | const icon = useMemo( |
| 70 | () => resolvePierreIconForEntry(props.pathValue, props.kind), | 71 | () => resolvePierreIconForEntry(props.pathValue, props.kind, props.mimeType), |
| 71 | [props.kind, props.pathValue], | 72 | [props.kind, props.pathValue, props.mimeType], |
| 72 | ); | 73 | ); |
| 73 | 74 | ||
| 74 | if (!icon) { | 75 | if (!icon) { |
apps/web/src/components/chat/WorkspaceEntryIcon.tsx created+41| ... | @@ -0,0 +1,41 @@ | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; | ||
| 3 | |||
| 4 | import { useFileMetadata } from "../../hooks/useFileMetadata"; | ||
| 5 | import { PierreEntryIcon } from "./PierreEntryIcon"; | ||
| 6 | |||
| 7 | /** Path chips share metadata queries across messages and the composer. A file | ||
| 8 | * icon is neutral while the environment is offline or the path is unknown. */ | ||
| 9 | export function WorkspaceEntryIcon(props: { | ||
| 10 | path: string; | ||
| 11 | environmentId?: EnvironmentId | null | undefined; | ||
| 12 | cwd?: string | undefined; | ||
| 13 | theme: "light" | "dark"; | ||
| 14 | }) { | ||
| 15 | const metadata = useFileMetadata(props.environmentId, props.path, props.cwd); | ||
| 16 | return ( | ||
| 17 | <PierreEntryIcon | ||
| 18 | pathValue={props.path} | ||
| 19 | kind={metadata?.kind === "directory" ? "directory" : "file"} | ||
| 20 | mimeType={metadata?.mimeType} | ||
| 21 | theme={props.theme} | ||
| 22 | /> | ||
| 23 | ); | ||
| 24 | } | ||
| 25 | |||
| 26 | export function WorkspaceEntryTooltip(props: { | ||
| 27 | path: string; | ||
| 28 | label?: string; | ||
| 29 | environmentId?: EnvironmentId | null | undefined; | ||
| 30 | cwd?: string | undefined; | ||
| 31 | }) { | ||
| 32 | const metadata = useFileMetadata(props.environmentId, props.path, props.cwd); | ||
| 33 | return ( | ||
| 34 | <> | ||
| 35 | {props.label ?? props.path} | ||
| 36 | {metadata?.byteLength === undefined | ||
| 37 | ? null | ||
| 38 | : ` · ${formatAttachmentSize(metadata.byteLength)}`} | ||
| 39 | </> | ||
| 40 | ); | ||
| 41 | } | ||
apps/web/src/components/composerContextPresentation.tsx+1| ... | @@ -68,6 +68,7 @@ export type ComposerDraftContextRecord = | ... | @@ -68,6 +68,7 @@ export type ComposerDraftContextRecord = |
| 68 | /** What a chip can do beyond showing itself; the composer supplies the handlers. */ | 68 | /** What a chip can do beyond showing itself; the composer supplies the handlers. */ |
| 69 | export interface ComposerContextActions { | 69 | export interface ComposerContextActions { |
| 70 | environmentId: EnvironmentId | null; | 70 | environmentId: EnvironmentId | null; |
| 71 | cwd?: string | undefined; | ||
| 71 | expandImage: (imageId: string) => void; | 72 | expandImage: (imageId: string) => void; |
| 72 | expandVideo: (fileId: string) => void; | 73 | expandVideo: (fileId: string) => void; |
| 73 | openFile: (fileId: string) => void; | 74 | openFile: (fileId: string) => void; |
apps/web/src/hooks/FileMetadataThreadProvider.tsx created+64| ... | @@ -0,0 +1,64 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { OrchestrationV2ThreadProjection, ScopedThreadRef } from "@t3tools/contracts"; | ||
| 3 | import { resolveWorkspaceFilePath } from "@t3tools/shared/path"; | ||
| 4 | import { Atom } from "effect/reactivity"; | ||
| 5 | import { createContext, useEffect, useRef, type ReactNode } from "react"; | ||
| 6 | |||
| 7 | import { projectEnvironment } from "../state/projects"; | ||
| 8 | import { useAtomCommand } from "../state/use-atom-command"; | ||
| 9 | |||
| 10 | export const FileMetadataThreadContext = createContext<ScopedThreadRef | null>(null); | ||
| 11 | const EMPTY_RETENTION = Atom.make(undefined); | ||
| 12 | |||
| 13 | /** The chat view owns this lifetime, so virtualized message rows can unmount | ||
| 14 | * without losing the metadata they already fetched. */ | ||
| 15 | export function FileMetadataThreadProvider(props: { | ||
| 16 | threadRef: ScopedThreadRef | null; | ||
| 17 | cwd: string | undefined; | ||
| 18 | checkpoints: OrchestrationV2ThreadProjection["checkpoints"] | undefined; | ||
| 19 | children?: ReactNode; | ||
| 20 | }) { | ||
| 21 | const { threadRef, cwd, checkpoints, children } = props; | ||
| 22 | useAtomValue(threadRef ? projectEnvironment.fileMetadataThread(threadRef) : EMPTY_RETENTION); | ||
| 23 | const invalidate = useAtomCommand(projectEnvironment.invalidateFileMetadata, { | ||
| 24 | reportFailure: false, | ||
| 25 | }); | ||
| 26 | const previous = useRef<{ | ||
| 27 | environmentId: ScopedThreadRef["environmentId"]; | ||
| 28 | threadId: ScopedThreadRef["threadId"]; | ||
| 29 | cwd: string; | ||
| 30 | checkpoints: OrchestrationV2ThreadProjection["checkpoints"]; | ||
| 31 | } | null>(null); | ||
| 32 | |||
| 33 | useEffect(() => { | ||
| 34 | if (!threadRef || !cwd || !checkpoints) { | ||
| 35 | previous.current = null; | ||
| 36 | return; | ||
| 37 | } | ||
| 38 | const before = previous.current; | ||
| 39 | previous.current = { ...threadRef, cwd, checkpoints }; | ||
| 40 | if ( | ||
| 41 | !before || | ||
| 42 | before.environmentId !== threadRef.environmentId || | ||
| 43 | before.threadId !== threadRef.threadId || | ||
| 44 | before.cwd !== cwd | ||
| 45 | ) | ||
| 46 | return; | ||
| 47 | const oldById = new Map(before.checkpoints.map((checkpoint) => [checkpoint.id, checkpoint])); | ||
| 48 | const paths = new Set<string>(); | ||
| 49 | for (const checkpoint of checkpoints) { | ||
| 50 | const old = oldById.get(checkpoint.id); | ||
| 51 | oldById.delete(checkpoint.id); | ||
| 52 | if (old?.files === checkpoint.files && old?.status === checkpoint.status) continue; | ||
| 53 | for (const file of checkpoint.files) paths.add(resolveWorkspaceFilePath(file.path, cwd)); | ||
| 54 | for (const file of old?.files ?? []) paths.add(resolveWorkspaceFilePath(file.path, cwd)); | ||
| 55 | } | ||
| 56 | // Rewinds can remove checkpoints as well as add them. | ||
| 57 | for (const checkpoint of oldById.values()) | ||
| 58 | for (const file of checkpoint.files) paths.add(resolveWorkspaceFilePath(file.path, cwd)); | ||
| 59 | if (paths.size > 0) | ||
| 60 | void invalidate({ environmentId: threadRef.environmentId, input: { paths: [...paths] } }); | ||
| 61 | }, [threadRef, cwd, checkpoints, invalidate]); | ||
| 62 | |||
| 63 | return <FileMetadataThreadContext value={threadRef}>{children}</FileMetadataThreadContext>; | ||
| 64 | } | ||
apps/web/src/hooks/useFileMetadata.ts created+34| ... | @@ -0,0 +1,34 @@ | ||
| 1 | import { AuthFilesystemReadScope, type EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { resolvePathLinkTarget, splitFilePathPosition } from "@t3tools/shared/fileLinks"; | ||
| 3 | import { normalizeProjectPathForComparison, isAbsolutePath } from "@t3tools/shared/path"; | ||
| 4 | import { useContext } from "react"; | ||
| 5 | |||
| 6 | import { useEnvironmentScope } from "../state/session"; | ||
| 7 | import { projectEnvironment } from "../state/projects"; | ||
| 8 | import { useEnvironmentQuery } from "../state/query"; | ||
| 9 | import { FileMetadataThreadContext } from "./FileMetadataThreadProvider"; | ||
| 10 | |||
| 11 | export function useFileMetadata( | ||
| 12 | environmentId: EnvironmentId | null | undefined, | ||
| 13 | path: string, | ||
| 14 | cwd?: string, | ||
| 15 | ) { | ||
| 16 | const canRead = useEnvironmentScope(environmentId ?? null, AuthFilesystemReadScope); | ||
| 17 | const threadRef = useContext(FileMetadataThreadContext); | ||
| 18 | const hostPath = splitFilePathPosition(path).path; | ||
| 19 | const targetPath = normalizeProjectPathForComparison( | ||
| 20 | hostPath.startsWith("~/") || !cwd ? hostPath : resolvePathLinkTarget(hostPath, cwd), | ||
| 21 | ); | ||
| 22 | const absolute = isAbsolutePath(targetPath) || targetPath.startsWith("~/"); | ||
| 23 | return useEnvironmentQuery( | ||
| 24 | environmentId && canRead && absolute && targetPath.length <= 512 | ||
| 25 | ? projectEnvironment.fileMetadata({ | ||
| 26 | environmentId, | ||
| 27 | input: { | ||
| 28 | path: targetPath, | ||
| 29 | ...(threadRef?.environmentId === environmentId ? { threadId: threadRef.threadId } : {}), | ||
| 30 | }, | ||
| 31 | }) | ||
| 32 | : null, | ||
| 33 | ).data; | ||
| 34 | } | ||
apps/web/src/pierre-icons.test.ts+16| ... | @@ -8,6 +8,22 @@ import { | ... | @@ -8,6 +8,22 @@ import { |
| 8 | } from "./pierre-icons"; | 8 | } from "./pierre-icons"; |
| 9 | 9 | ||
| 10 | describe("Pierre file icons", () => { | 10 | describe("Pierre file icons", () => { |
| 11 | it("uses detected types for extensionless files while keeping known filename icons", () => { | ||
| 12 | assert.equal( | ||
| 13 | resolvePierreIconForEntry("custom-script", "file", "text/x-python")?.token, | ||
| 14 | "python", | ||
| 15 | ); | ||
| 16 | assert.equal(resolvePierreIconForEntry("image", "file", "image/png")?.token, "image"); | ||
| 17 | assert.equal( | ||
| 18 | resolvePierreIconForEntry("Dockerfile", "file", "text/x-shellscript")?.token, | ||
| 19 | "docker", | ||
| 20 | ); | ||
| 21 | assert.isNull(resolvePierreIconForEntry("folder.png", "directory", "image/png")); | ||
| 22 | assert.equal( | ||
| 23 | resolvePierreIconForEntry("unknown", "file", "application/octet-stream")?.token, | ||
| 24 | "default", | ||
| 25 | ); | ||
| 26 | }); | ||
| 11 | it("uses Pierre exact filename and complete-set extension mappings", () => { | 27 | it("uses Pierre exact filename and complete-set extension mappings", () => { |
| 12 | assert.equal(resolvePierreIconForEntry("Dockerfile", "file")?.token, "docker"); | 28 | assert.equal(resolvePierreIconForEntry("Dockerfile", "file")?.token, "docker"); |
| 13 | assert.equal(resolvePierreIconForEntry("src/Button.tsx", "file")?.token, "react"); | 29 | assert.equal(resolvePierreIconForEntry("src/Button.tsx", "file")?.token, "react"); |
apps/web/src/pierre-icons.ts+9-7| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | type FileTreeIcons, | 4 | type FileTreeIcons, |
| 5 | } from "@pierre/trees"; | 5 | } from "@pierre/trees"; |
| 6 | import { VIDEO_FILE_EXTENSIONS } from "@t3tools/shared/video"; | 6 | import { VIDEO_FILE_EXTENSIONS } from "@t3tools/shared/video"; |
| 7 | import { fileIconPathForMimeType } from "@t3tools/shared/filePreview"; | ||
| 7 | 8 | ||
| 8 | export interface PierreIconResolution { | 9 | export interface PierreIconResolution { |
| 9 | name: string; | 10 | name: string; |
| ... | @@ -149,12 +150,6 @@ export function basenameOfPath(pathValue: string): string { | ... | @@ -149,12 +150,6 @@ export function basenameOfPath(pathValue: string): string { |
| 149 | return slashIndex === -1 ? pathValue : pathValue.slice(slashIndex + 1); | 150 | return slashIndex === -1 ? pathValue : pathValue.slice(slashIndex + 1); |
| 150 | } | 151 | } |
| 151 | 152 | ||
| 152 | export function inferEntryKindFromPath(pathValue: string): "file" | "directory" { | ||
| 153 | const base = basenameOfPath(pathValue); | ||
| 154 | if (base.startsWith(".") && !base.slice(1).includes(".")) return "directory"; | ||
| 155 | return base.includes(".") ? "file" : "directory"; | ||
| 156 | } | ||
| 157 | |||
| 158 | /** Languages whose files are recognised by name rather than by extension. */ | 153 | /** Languages whose files are recognised by name rather than by extension. */ |
| 159 | const LANGUAGE_FILE_NAMES: Record<string, string> = { | 154 | const LANGUAGE_FILE_NAMES: Record<string, string> = { |
| 160 | dockerfile: "Dockerfile", | 155 | dockerfile: "Dockerfile", |
| ... | @@ -171,9 +166,16 @@ export function syntheticFileNameForLanguageId(languageId: string): string { | ... | @@ -171,9 +166,16 @@ export function syntheticFileNameForLanguageId(languageId: string): string { |
| 171 | export function resolvePierreIconForEntry( | 166 | export function resolvePierreIconForEntry( |
| 172 | pathValue: string, | 167 | pathValue: string, |
| 173 | kind: "file" | "directory", | 168 | kind: "file" | "directory", |
| 169 | mimeType?: string, | ||
| 174 | ): PierreIconResolution | null { | 170 | ): PierreIconResolution | null { |
| 175 | if (kind === "directory") return null; | 171 | if (kind === "directory") return null; |
| 176 | return completeIconResolver.resolveIcon("file-tree-icon-file", pathValue); | 172 | const icon = completeIconResolver.resolveIcon("file-tree-icon-file", pathValue); |
| 173 | return icon?.token === "default" && mimeType | ||
| 174 | ? completeIconResolver.resolveIcon( | ||
| 175 | "file-tree-icon-file", | ||
| 176 | fileIconPathForMimeType(pathValue, mimeType), | ||
| 177 | ) | ||
| 178 | : icon; | ||
| 177 | } | 179 | } |
| 178 | 180 | ||
| 179 | export function hasSpecificPierreIconForFileName(fileName: string): boolean { | 181 | export function hasSpecificPierreIconForFileName(fileName: string): boolean { |
packages/client-runtime/src/state/fileMetadata.test.ts created+458| ... | @@ -0,0 +1,458 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | EnvironmentId, | ||
| 4 | ThreadId, | ||
| 5 | AuthFilesystemReadScope, | ||
| 6 | EnvironmentAuthorizationError, | ||
| 7 | type FilesystemEntryMetadata, | ||
| 8 | type FilesystemGetMetadataInput, | ||
| 9 | WS_METHODS, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import * as Clock from "effect/Clock"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Deferred from "effect/Deferred"; | ||
| 14 | import * as Fiber from "effect/Fiber"; | ||
| 15 | import * as Layer from "effect/Layer"; | ||
| 16 | import * as Option from "effect/Option"; | ||
| 17 | import * as Stream from "effect/Stream"; | ||
| 18 | import * as Schema from "effect/Schema"; | ||
| 19 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 20 | import * as TestClock from "effect/testing/TestClock"; | ||
| 21 | import { Atom, AtomRegistry } from "effect/reactivity"; | ||
| 22 | |||
| 23 | import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts"; | ||
| 24 | import * as EnvironmentRegistry from "../connection/registry.ts"; | ||
| 25 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | ||
| 26 | import type { RpcSession } from "../rpc/session.ts"; | ||
| 27 | import { createFileMetadataAtoms } from "./fileMetadata.ts"; | ||
| 28 | |||
| 29 | const ENVIRONMENT_ID = EnvironmentId.make("metadata-environment"); | ||
| 30 | const OTHER_ENVIRONMENT_ID = EnvironmentId.make("metadata-other-environment"); | ||
| 31 | |||
| 32 | const makeHarness = Effect.fnUntraced(function* () { | ||
| 33 | const batches: string[][] = []; | ||
| 34 | const values = new Map<string, FilesystemEntryMetadata | null>(); | ||
| 35 | const failure = { denied: false }; | ||
| 36 | const response = { wait: Effect.void as Effect.Effect<void> }; | ||
| 37 | const makeSession = () => | ||
| 38 | ({ | ||
| 39 | client: { | ||
| 40 | [WS_METHODS.filesystemGetMetadata]: (input: FilesystemGetMetadataInput) => | ||
| 41 | Effect.gen(function* () { | ||
| 42 | batches.push([...input.paths]); | ||
| 43 | if (failure.denied) | ||
| 44 | return yield* new EnvironmentAuthorizationError({ | ||
| 45 | requiredScope: AuthFilesystemReadScope, | ||
| 46 | message: "File access is not granted.", | ||
| 47 | }); | ||
| 48 | const entries = input.paths.map((path) => values.get(path) ?? null); | ||
| 49 | yield* response.wait; | ||
| 50 | return { entries }; | ||
| 51 | }), | ||
| 52 | }, | ||
| 53 | }) as unknown as RpcSession; | ||
| 54 | const session = yield* SubscriptionRef.make(Option.some(makeSession())); | ||
| 55 | const state = yield* SubscriptionRef.make({ | ||
| 56 | ...AVAILABLE_CONNECTION_STATE, | ||
| 57 | phase: "connected" as const, | ||
| 58 | }); | ||
| 59 | const supervisor = { | ||
| 60 | target: { environmentId: ENVIRONMENT_ID }, | ||
| 61 | session, | ||
| 62 | state, | ||
| 63 | } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]; | ||
| 64 | const otherSupervisor = { | ||
| 65 | ...supervisor, | ||
| 66 | target: { environmentId: OTHER_ENVIRONMENT_ID }, | ||
| 67 | session: yield* SubscriptionRef.make(Option.some(makeSession())), | ||
| 68 | } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]; | ||
| 69 | const getSupervisor = (environmentId: EnvironmentId) => | ||
| 70 | environmentId === OTHER_ENVIRONMENT_ID ? otherSupervisor : supervisor; | ||
| 71 | const environmentRegistry = { | ||
| 72 | run: (environmentId, effect) => | ||
| 73 | Effect.provideService( | ||
| 74 | effect, | ||
| 75 | EnvironmentSupervisor.EnvironmentSupervisor, | ||
| 76 | getSupervisor(environmentId), | ||
| 77 | ), | ||
| 78 | followStream: (environmentId, stream) => | ||
| 79 | Stream.provideService( | ||
| 80 | stream, | ||
| 81 | EnvironmentSupervisor.EnvironmentSupervisor, | ||
| 82 | getSupervisor(environmentId), | ||
| 83 | ), | ||
| 84 | } as EnvironmentRegistry.EnvironmentRegistry["Service"]; | ||
| 85 | const clock = yield* Clock.Clock; | ||
| 86 | const runtime = Atom.runtime( | ||
| 87 | Layer.merge( | ||
| 88 | Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), | ||
| 89 | Layer.succeed(Clock.Clock, clock), | ||
| 90 | ), | ||
| 91 | ); | ||
| 92 | const atoms = createFileMetadataAtoms(runtime); | ||
| 93 | const registry = AtomRegistry.make(); | ||
| 94 | yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); | ||
| 95 | const atom = (path: string, environmentId = ENVIRONMENT_ID, threadId?: ThreadId) => | ||
| 96 | atoms.metadata({ environmentId, input: { path, ...(threadId ? { threadId } : {}) } }); | ||
| 97 | const read = (path: string, environmentId = ENVIRONMENT_ID, threadId?: ThreadId) => | ||
| 98 | AtomRegistry.getResult(registry, atom(path, environmentId, threadId), { | ||
| 99 | suspendOnWaiting: true, | ||
| 100 | }).pipe(Effect.tap(() => Effect.yieldNow)); | ||
| 101 | const seed = (path: string, kind: "file" | "directory") => | ||
| 102 | atoms | ||
| 103 | .rememberEntries("/workspace", [{ path, kind }]) | ||
| 104 | .pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); | ||
| 105 | return { | ||
| 106 | batches, | ||
| 107 | values, | ||
| 108 | registry, | ||
| 109 | atom, | ||
| 110 | read, | ||
| 111 | seed, | ||
| 112 | session, | ||
| 113 | makeSession, | ||
| 114 | atoms, | ||
| 115 | supervisor, | ||
| 116 | failure, | ||
| 117 | response, | ||
| 118 | }; | ||
| 119 | }); | ||
| 120 | |||
| 121 | describe("file metadata", () => { | ||
| 122 | it.effect("does not seed a different path from a literal name with a numeric suffix", () => | ||
| 123 | Effect.gen(function* () { | ||
| 124 | const h = yield* makeHarness(); | ||
| 125 | yield* h.seed("report:123", "directory"); | ||
| 126 | h.values.set("/workspace/report", { kind: "file", byteLength: 5 }); | ||
| 127 | expect(yield* h.read("/workspace/report")).toEqual({ kind: "file", byteLength: 5 }); | ||
| 128 | expect(yield* h.read("/workspace/report:123")).toEqual({ kind: "directory" }); | ||
| 129 | yield* h.atoms | ||
| 130 | .rememberFile("/workspace", { | ||
| 131 | relativePath: "/workspace/read:12:3", | ||
| 132 | byteLength: 100, | ||
| 133 | }) | ||
| 134 | .pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, h.supervisor)); | ||
| 135 | expect(yield* h.read("/workspace/read:12:3")).toEqual({ kind: "file", byteLength: 100 }); | ||
| 136 | expect(yield* h.read("/workspace/read")).toBeNull(); | ||
| 137 | expect(h.batches).toEqual([["/workspace/report"], ["/workspace/read"]]); | ||
| 138 | }).pipe(Effect.scoped), | ||
| 139 | ); | ||
| 140 | |||
| 141 | it.effect("retries only paths invalidated while their response is in flight", () => | ||
| 142 | Effect.gen(function* () { | ||
| 143 | const h = yield* makeHarness(); | ||
| 144 | const threadId = ThreadId.make("in-flight-thread"); | ||
| 145 | h.registry.mount(h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId })); | ||
| 146 | const started = yield* Deferred.make<void>(); | ||
| 147 | const release = yield* Deferred.make<void>(); | ||
| 148 | h.response.wait = Deferred.succeed(started, undefined).pipe( | ||
| 149 | Effect.andThen(Deferred.await(release)), | ||
| 150 | ); | ||
| 151 | h.values.set("/workspace/file", { kind: "file", byteLength: 5 }); | ||
| 152 | h.values.set("/workspace/unchanged", { kind: "directory" }); | ||
| 153 | const read = yield* Effect.all( | ||
| 154 | [h.read("/workspace/file", ENVIRONMENT_ID, threadId), h.read("/workspace/unchanged")], | ||
| 155 | { concurrency: "unbounded" }, | ||
| 156 | ).pipe(Effect.forkChild); | ||
| 157 | yield* Deferred.await(started); | ||
| 158 | yield* h.atoms | ||
| 159 | .invalidate("/workspace/file") | ||
| 160 | .pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, h.supervisor)); | ||
| 161 | h.values.set("/workspace/file", { kind: "file", byteLength: 10 }); | ||
| 162 | h.response.wait = Effect.void; | ||
| 163 | yield* Deferred.succeed(release, undefined); | ||
| 164 | expect(yield* Fiber.join(read)).toEqual([ | ||
| 165 | { kind: "file", byteLength: 10 }, | ||
| 166 | { kind: "directory" }, | ||
| 167 | ]); | ||
| 168 | expect(h.batches).toEqual([["/workspace/file", "/workspace/unchanged"], ["/workspace/file"]]); | ||
| 169 | h.registry.refresh(h.atom("/workspace/file", ENVIRONMENT_ID, threadId)); | ||
| 170 | expect(yield* h.read("/workspace/file", ENVIRONMENT_ID, threadId)).toEqual({ | ||
| 171 | kind: "file", | ||
| 172 | byteLength: 10, | ||
| 173 | }); | ||
| 174 | expect(h.batches).toHaveLength(2); | ||
| 175 | }).pipe(Effect.scoped), | ||
| 176 | ); | ||
| 177 | |||
| 178 | it.effect("does not reuse expired metadata retained by an unrelated thread", () => | ||
| 179 | Effect.gen(function* () { | ||
| 180 | const h = yield* makeHarness(); | ||
| 181 | const threadId = ThreadId.make("unrelated-thread"); | ||
| 182 | h.registry.mount(h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId })); | ||
| 183 | h.values.set("/workspace/file", { kind: "file", byteLength: 5 }); | ||
| 184 | yield* h.read("/workspace/file", ENVIRONMENT_ID, threadId); | ||
| 185 | yield* TestClock.adjust("1 hour"); | ||
| 186 | h.values.set("/workspace/file", { kind: "directory" }); | ||
| 187 | expect(yield* h.read("/workspace/file")).toEqual({ kind: "directory" }); | ||
| 188 | expect(h.batches).toHaveLength(2); | ||
| 189 | }).pipe(Effect.scoped), | ||
| 190 | ); | ||
| 191 | |||
| 192 | it.effect("uses a file listing kind without requesting optional metadata", () => | ||
| 193 | Effect.gen(function* () { | ||
| 194 | const h = yield* makeHarness(); | ||
| 195 | yield* h.seed("run", "file"); | ||
| 196 | expect(yield* h.read("/workspace/run")).toEqual({ kind: "file" }); | ||
| 197 | expect(h.batches).toHaveLength(0); | ||
| 198 | }).pipe(Effect.scoped), | ||
| 199 | ); | ||
| 200 | |||
| 201 | it.effect("tree refreshes preserve MIME hints retained by an open thread", () => | ||
| 202 | Effect.gen(function* () { | ||
| 203 | const h = yield* makeHarness(); | ||
| 204 | const threadId = ThreadId.make("rich-metadata-thread"); | ||
| 205 | h.registry.mount(h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId })); | ||
| 206 | const value = { kind: "file" as const, byteLength: 5, mimeType: "text/x-python" }; | ||
| 207 | h.values.set("/workspace/run", value); | ||
| 208 | yield* h.read("/workspace/run", ENVIRONMENT_ID, threadId); | ||
| 209 | yield* TestClock.adjust("1 hour"); | ||
| 210 | yield* h.seed("run", "file"); | ||
| 211 | h.registry.refresh(h.atom("/workspace/run", ENVIRONMENT_ID, threadId)); | ||
| 212 | expect(yield* h.read("/workspace/run", ENVIRONMENT_ID, threadId)).toEqual(value); | ||
| 213 | expect(h.batches).toHaveLength(1); | ||
| 214 | }).pipe(Effect.scoped), | ||
| 215 | ); | ||
| 216 | |||
| 217 | it.effect("an offscreen invalidation waits until that path is read again", () => | ||
| 218 | Effect.gen(function* () { | ||
| 219 | const h = yield* makeHarness(); | ||
| 220 | const threadId = ThreadId.make("offscreen-thread"); | ||
| 221 | h.registry.mount(h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId })); | ||
| 222 | yield* h.read("/workspace/file", ENVIRONMENT_ID, threadId); | ||
| 223 | yield* h.atoms | ||
| 224 | .invalidate("/workspace/file") | ||
| 225 | .pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, h.supervisor)); | ||
| 226 | h.atoms.refreshPath(ENVIRONMENT_ID, "/workspace/file", h.registry); | ||
| 227 | yield* Effect.yieldNow; | ||
| 228 | expect(h.batches).toHaveLength(1); | ||
| 229 | h.values.set("/workspace/file", { kind: "directory" }); | ||
| 230 | expect(yield* h.read("/workspace/file", ENVIRONMENT_ID, threadId)).toEqual({ | ||
| 231 | kind: "directory", | ||
| 232 | }); | ||
| 233 | expect(h.batches).toHaveLength(2); | ||
| 234 | }).pipe(Effect.scoped), | ||
| 235 | ); | ||
| 236 | |||
| 237 | it.effect("retains more than 1024 visited paths until the thread closes", () => | ||
| 238 | Effect.gen(function* () { | ||
| 239 | const h = yield* makeHarness(); | ||
| 240 | const threadId = ThreadId.make("large-thread"); | ||
| 241 | const release = h.registry.mount( | ||
| 242 | h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId }), | ||
| 243 | ); | ||
| 244 | const paths = Array.from({ length: 1300 }, (_, i) => `/workspace/visited-${i}`); | ||
| 245 | yield* Effect.forEach(paths, (path) => h.read(path, ENVIRONMENT_ID, threadId), { | ||
| 246 | concurrency: "unbounded", | ||
| 247 | }); | ||
| 248 | expect(h.batches).toHaveLength(21); | ||
| 249 | yield* TestClock.adjust("1 hour"); | ||
| 250 | for (const path of paths) h.registry.refresh(h.atom(path, ENVIRONMENT_ID, threadId)); | ||
| 251 | yield* Effect.forEach(paths, (path) => h.read(path, ENVIRONMENT_ID, threadId), { | ||
| 252 | concurrency: "unbounded", | ||
| 253 | }); | ||
| 254 | expect(h.batches).toHaveLength(21); | ||
| 255 | release(); | ||
| 256 | yield* Effect.yieldNow; | ||
| 257 | h.registry.refresh(h.atom(paths[0]!, ENVIRONMENT_ID, threadId)); | ||
| 258 | yield* h.read(paths[0]!, ENVIRONMENT_ID, threadId); | ||
| 259 | expect(h.batches).toHaveLength(22); | ||
| 260 | }).pipe(Effect.scoped), | ||
| 261 | ); | ||
| 262 | |||
| 263 | it.effect("closing one thread does not release another thread's retained paths", () => | ||
| 264 | Effect.gen(function* () { | ||
| 265 | const h = yield* makeHarness(); | ||
| 266 | const a = ThreadId.make("thread-a"); | ||
| 267 | const b = ThreadId.make("thread-b"); | ||
| 268 | const releaseA = h.registry.mount( | ||
| 269 | h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId: a }), | ||
| 270 | ); | ||
| 271 | h.registry.mount(h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId: b })); | ||
| 272 | yield* h.read("/workspace/shared", ENVIRONMENT_ID, a); | ||
| 273 | yield* h.read("/workspace/shared", ENVIRONMENT_ID, b); | ||
| 274 | expect(h.batches).toHaveLength(1); | ||
| 275 | releaseA(); | ||
| 276 | yield* Effect.yieldNow; | ||
| 277 | yield* TestClock.adjust("1 hour"); | ||
| 278 | h.registry.refresh(h.atom("/workspace/shared", ENVIRONMENT_ID, b)); | ||
| 279 | yield* h.read("/workspace/shared", ENVIRONMENT_ID, b); | ||
| 280 | expect(h.batches).toHaveLength(1); | ||
| 281 | }).pipe(Effect.scoped), | ||
| 282 | ); | ||
| 283 | |||
| 284 | it.effect("invalidates a changed retained path without refetching other paths", () => | ||
| 285 | Effect.gen(function* () { | ||
| 286 | const h = yield* makeHarness(); | ||
| 287 | const threadId = ThreadId.make("changing-thread"); | ||
| 288 | h.registry.mount(h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId })); | ||
| 289 | h.values.set("/workspace/file", { kind: "file", byteLength: 5 }); | ||
| 290 | const file = h.atom("/workspace/file", ENVIRONMENT_ID, threadId); | ||
| 291 | h.registry.mount(file); | ||
| 292 | yield* h.read("/workspace/file", ENVIRONMENT_ID, threadId); | ||
| 293 | yield* h.read("/workspace/unchanged", ENVIRONMENT_ID, threadId); | ||
| 294 | const before = h.batches.length; | ||
| 295 | h.values.set("/workspace/file", { kind: "file", byteLength: 10 }); | ||
| 296 | yield* h.atoms | ||
| 297 | .invalidate("/workspace/file") | ||
| 298 | .pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, h.supervisor)); | ||
| 299 | h.atoms.refreshPath(ENVIRONMENT_ID, "/workspace/file", h.registry); | ||
| 300 | expect(yield* h.read("/workspace/file", ENVIRONMENT_ID, threadId)).toEqual({ | ||
| 301 | kind: "file", | ||
| 302 | byteLength: 10, | ||
| 303 | }); | ||
| 304 | h.registry.refresh(h.atom("/workspace/unchanged", ENVIRONMENT_ID, threadId)); | ||
| 305 | yield* h.read("/workspace/unchanged", ENVIRONMENT_ID, threadId); | ||
| 306 | expect(h.batches.slice(before)).toEqual([["/workspace/file"]]); | ||
| 307 | }).pipe(Effect.scoped), | ||
| 308 | ); | ||
| 309 | |||
| 310 | it.effect("a reconnect discards retained metadata from the previous session", () => | ||
| 311 | Effect.gen(function* () { | ||
| 312 | const h = yield* makeHarness(); | ||
| 313 | const threadId = ThreadId.make("reconnected-thread"); | ||
| 314 | h.registry.mount(h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId })); | ||
| 315 | h.values.set("/workspace/file", { kind: "file" }); | ||
| 316 | yield* h.read("/workspace/file", ENVIRONMENT_ID, threadId); | ||
| 317 | h.values.set("/workspace/file", { kind: "directory" }); | ||
| 318 | yield* SubscriptionRef.set(h.session, Option.some(h.makeSession())); | ||
| 319 | h.registry.refresh(h.atom("/workspace/file", ENVIRONMENT_ID, threadId)); | ||
| 320 | expect(yield* h.read("/workspace/file", ENVIRONMENT_ID, threadId)).toEqual({ | ||
| 321 | kind: "directory", | ||
| 322 | }); | ||
| 323 | expect(h.batches).toHaveLength(2); | ||
| 324 | }).pipe(Effect.scoped), | ||
| 325 | ); | ||
| 326 | |||
| 327 | it.effect("keeps batches and cached paths separate across environments", () => | ||
| 328 | Effect.gen(function* () { | ||
| 329 | const h = yield* makeHarness(); | ||
| 330 | yield* h.seed("known", "directory"); | ||
| 331 | expect(yield* h.read("/workspace/known", OTHER_ENVIRONMENT_ID)).toBeNull(); | ||
| 332 | expect(h.batches).toEqual([["/workspace/known"]]); | ||
| 333 | yield* Effect.all( | ||
| 334 | [h.read("/workspace/new"), h.read("/workspace/new", OTHER_ENVIRONMENT_ID)], | ||
| 335 | { concurrency: "unbounded" }, | ||
| 336 | ); | ||
| 337 | expect(h.batches.slice(1)).toEqual([["/workspace/new"], ["/workspace/new"]]); | ||
| 338 | }).pipe(Effect.scoped), | ||
| 339 | ); | ||
| 340 | |||
| 341 | it.effect("settles a failed batch and can retry after access is granted", () => | ||
| 342 | Effect.gen(function* () { | ||
| 343 | const h = yield* makeHarness(); | ||
| 344 | h.failure.denied = true; | ||
| 345 | const failures = yield* Effect.all( | ||
| 346 | [h.read("/workspace/a").pipe(Effect.flip), h.read("/workspace/b").pipe(Effect.flip)], | ||
| 347 | { concurrency: "unbounded" }, | ||
| 348 | ); | ||
| 349 | expect(failures.every(Schema.is(EnvironmentAuthorizationError))).toBe(true); | ||
| 350 | h.failure.denied = false; | ||
| 351 | h.values.set("/workspace/a", { kind: "directory" }); | ||
| 352 | h.registry.refresh(h.atom("/workspace/a")); | ||
| 353 | expect(yield* h.read("/workspace/a")).toEqual({ kind: "directory" }); | ||
| 354 | }).pipe(Effect.scoped), | ||
| 355 | ); | ||
| 356 | |||
| 357 | it.effect("expires cached metadata and refreshes a changed kind", () => | ||
| 358 | Effect.gen(function* () { | ||
| 359 | const h = yield* makeHarness(); | ||
| 360 | h.values.set("/workspace/path", { kind: "file" }); | ||
| 361 | yield* h.read("/workspace/path"); | ||
| 362 | h.values.set("/workspace/path", { kind: "directory" }); | ||
| 363 | yield* TestClock.adjust("61 seconds"); | ||
| 364 | h.registry.refresh(h.atom("/workspace/path")); | ||
| 365 | expect(yield* h.read("/workspace/path")).toEqual({ kind: "directory" }); | ||
| 366 | expect(h.batches).toHaveLength(2); | ||
| 367 | }).pipe(Effect.scoped), | ||
| 368 | ); | ||
| 369 | |||
| 370 | it.effect("reuses file read sizes and normalizes Windows cache keys", () => | ||
| 371 | Effect.gen(function* () { | ||
| 372 | const h = yield* makeHarness(); | ||
| 373 | yield* h.atoms | ||
| 374 | .rememberFile("C:\\Workspace", { | ||
| 375 | relativePath: "file", | ||
| 376 | byteLength: 100, | ||
| 377 | }) | ||
| 378 | .pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, h.supervisor)); | ||
| 379 | expect(yield* h.read("c:/workspace/file/")).toEqual({ kind: "file", byteLength: 100 }); | ||
| 380 | expect(h.batches).toHaveLength(0); | ||
| 381 | }).pipe(Effect.scoped), | ||
| 382 | ); | ||
| 383 | |||
| 384 | it.effect("batches mounted paths and deduplicates repeated chips", () => | ||
| 385 | Effect.gen(function* () { | ||
| 386 | const h = yield* makeHarness(); | ||
| 387 | h.values.set("/workspace/file", { kind: "file", byteLength: 5 }); | ||
| 388 | h.values.set("/workspace/folder.ts", { kind: "directory" }); | ||
| 389 | const results = yield* Effect.all( | ||
| 390 | [h.read("/workspace/file"), h.read("/workspace/folder.ts"), h.read("/workspace/file")], | ||
| 391 | { concurrency: "unbounded" }, | ||
| 392 | ); | ||
| 393 | expect(h.batches).toEqual([["/workspace/file", "/workspace/folder.ts"]]); | ||
| 394 | expect(results).toEqual([ | ||
| 395 | { kind: "file", byteLength: 5 }, | ||
| 396 | { kind: "directory" }, | ||
| 397 | { kind: "file", byteLength: 5 }, | ||
| 398 | ]); | ||
| 399 | yield* h.read("/workspace/file"); | ||
| 400 | expect(h.batches).toHaveLength(1); | ||
| 401 | }).pipe(Effect.scoped), | ||
| 402 | ); | ||
| 403 | |||
| 404 | it.effect("uses search and tree kinds without another request", () => | ||
| 405 | Effect.gen(function* () { | ||
| 406 | const h = yield* makeHarness(); | ||
| 407 | yield* h.seed("folder.ts", "directory"); | ||
| 408 | expect(yield* h.read("/workspace/folder.ts")).toEqual({ kind: "directory" }); | ||
| 409 | expect(h.batches).toHaveLength(0); | ||
| 410 | }).pipe(Effect.scoped), | ||
| 411 | ); | ||
| 412 | |||
| 413 | it.effect("splits large mounts into bounded batches and caches misses", () => | ||
| 414 | Effect.gen(function* () { | ||
| 415 | const h = yield* makeHarness(); | ||
| 416 | yield* Effect.all( | ||
| 417 | Array.from({ length: 130 }, (_, i) => h.read(`/workspace/${i}`)), | ||
| 418 | { concurrency: "unbounded" }, | ||
| 419 | ); | ||
| 420 | expect(h.batches.flat()).toHaveLength(130); | ||
| 421 | expect(h.batches.map((batch) => batch.length)).toEqual([64, 64, 2]); | ||
| 422 | expect(h.batches.every((batch) => batch.length <= 64)).toBe(true); | ||
| 423 | const count = h.batches.length; | ||
| 424 | yield* h.read("/workspace/0"); | ||
| 425 | expect(h.batches).toHaveLength(count); | ||
| 426 | }).pipe(Effect.scoped), | ||
| 427 | ); | ||
| 428 | |||
| 429 | it.effect("batches synchronous mounts after the runtime is warm", () => | ||
| 430 | Effect.gen(function* () { | ||
| 431 | const h = yield* makeHarness(); | ||
| 432 | yield* h.read("/workspace/warmup"); | ||
| 433 | h.batches.length = 0; | ||
| 434 | const paths = Array.from({ length: 130 }, (_, i) => `/workspace/mounted-${i}`); | ||
| 435 | yield* Effect.sync(() => { | ||
| 436 | for (const path of paths) h.registry.get(h.atom(path)); | ||
| 437 | }); | ||
| 438 | yield* Effect.all( | ||
| 439 | paths.map((path) => h.read(path)), | ||
| 440 | { concurrency: "unbounded" }, | ||
| 441 | ); | ||
| 442 | expect(h.batches.map((batch) => batch.length)).toEqual([64, 64, 2]); | ||
| 443 | }).pipe(Effect.scoped), | ||
| 444 | ); | ||
| 445 | |||
| 446 | it.effect("rechecks metadata after the environment session changes", () => | ||
| 447 | Effect.gen(function* () { | ||
| 448 | const h = yield* makeHarness(); | ||
| 449 | h.values.set("/workspace/path", { kind: "file" }); | ||
| 450 | expect(yield* h.read("/workspace/path")).toEqual({ kind: "file" }); | ||
| 451 | h.values.set("/workspace/path", { kind: "directory" }); | ||
| 452 | yield* SubscriptionRef.set(h.session, Option.some(h.makeSession())); | ||
| 453 | h.registry.refresh(h.atom("/workspace/path")); | ||
| 454 | expect(yield* h.read("/workspace/path")).toEqual({ kind: "directory" }); | ||
| 455 | expect(h.batches).toHaveLength(2); | ||
| 456 | }).pipe(Effect.scoped), | ||
| 457 | ); | ||
| 458 | }); | ||
packages/client-runtime/src/state/fileMetadata.ts created+338| ... | @@ -0,0 +1,338 @@ | ||
| 1 | import { | ||
| 2 | FILESYSTEM_METADATA_BATCH_LIMIT, | ||
| 3 | type EnvironmentId, | ||
| 4 | type FilesystemEntryMetadata, | ||
| 5 | type ProjectEntry, | ||
| 6 | type ScopedThreadRef, | ||
| 7 | type ThreadId, | ||
| 8 | WS_METHODS, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { normalizeProjectPathForComparison, resolveWorkspaceFilePath } from "@t3tools/shared/path"; | ||
| 11 | import * as Clock from "effect/Clock"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Exit from "effect/Exit"; | ||
| 14 | import * as Request from "effect/Request"; | ||
| 15 | import * as RequestResolver from "effect/RequestResolver"; | ||
| 16 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 17 | import { Atom, AtomRegistry } from "effect/reactivity"; | ||
| 18 | |||
| 19 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | ||
| 20 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 21 | import { | ||
| 22 | request, | ||
| 23 | type EnvironmentRpcFailure, | ||
| 24 | EnvironmentRpcUnavailableError, | ||
| 25 | } from "../rpc/client.ts"; | ||
| 26 | import type { RpcSession } from "../rpc/session.ts"; | ||
| 27 | import { createEnvironmentQueryAtomFamily } from "./runtime.ts"; | ||
| 28 | |||
| 29 | const METADATA_STALE_TIME_MS = 60_000; | ||
| 30 | const METADATA_CACHE_LIMIT = 1024; | ||
| 31 | |||
| 32 | interface CachedMetadata { | ||
| 33 | value: FilesystemEntryMetadata | null; | ||
| 34 | expires: number; | ||
| 35 | } | ||
| 36 | |||
| 37 | type RetainedMetadata = Map<string, CachedMetadata>; | ||
| 38 | |||
| 39 | interface FileMetadataInput { | ||
| 40 | path: string; | ||
| 41 | threadId?: ThreadId; | ||
| 42 | } | ||
| 43 | |||
| 44 | class FileMetadataRequest extends Request.Class< | ||
| 45 | { | ||
| 46 | path: string; | ||
| 47 | session: RpcSession; | ||
| 48 | supervisor: EnvironmentSupervisor.EnvironmentSupervisor["Service"]; | ||
| 49 | retained?: RetainedMetadata; | ||
| 50 | }, | ||
| 51 | FilesystemEntryMetadata | null, | ||
| 52 | EnvironmentRpcFailure<typeof WS_METHODS.filesystemGetMetadata> | EnvironmentRpcUnavailableError | ||
| 53 | > {} | ||
| 54 | |||
| 55 | /** Shared by file chips, search results, and the file tree in one client. Cache | ||
| 56 | * entries belong to an RPC session so reconnects and grant changes recheck them. */ | ||
| 57 | export function createFileMetadataAtoms<R, E>( | ||
| 58 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | ||
| 59 | ) { | ||
| 60 | const cache = new WeakMap<RpcSession, Map<string, CachedMetadata>>(); | ||
| 61 | const invalidationVersions = new WeakMap<RpcSession, number>(); | ||
| 62 | const inFlight = new WeakMap<RpcSession, Map<string, Set<{ valid: boolean }>>>(); | ||
| 63 | const retainedThreads = new Map< | ||
| 64 | string, | ||
| 65 | { readers: number; sessions: WeakMap<RpcSession, RetainedMetadata> } | ||
| 66 | >(); | ||
| 67 | const threadKey = (ref: ScopedThreadRef) => JSON.stringify([ref.environmentId, ref.threadId]); | ||
| 68 | const retainThreadFamily = Atom.family((key: string) => | ||
| 69 | Atom.make((get) => { | ||
| 70 | let retained = retainedThreads.get(key); | ||
| 71 | if (!retained) { | ||
| 72 | retained = { readers: 0, sessions: new WeakMap() }; | ||
| 73 | retainedThreads.set(key, retained); | ||
| 74 | } | ||
| 75 | retained.readers++; | ||
| 76 | get.addFinalizer(() => { | ||
| 77 | if (--retained.readers === 0) retainedThreads.delete(key); | ||
| 78 | }); | ||
| 79 | }).pipe(Atom.setIdleTTL(0), Atom.withLabel(`file-metadata-thread:${key}`)), | ||
| 80 | ); | ||
| 81 | const revisions = Atom.family((key: string) => | ||
| 82 | Atom.make(0).pipe(Atom.withLabel(`file-metadata-revision:${key}`)), | ||
| 83 | ); | ||
| 84 | const revision = (environmentId: EnvironmentId, path: string) => | ||
| 85 | revisions(JSON.stringify([environmentId, normalizeProjectPathForComparison(path)])); | ||
| 86 | const knownEntry = (session: RpcSession, path: string, now: number) => { | ||
| 87 | const key = normalizeProjectPathForComparison(path); | ||
| 88 | const cached = cache.get(session)?.get(key); | ||
| 89 | return cached && cached.expires > now ? cached : undefined; | ||
| 90 | }; | ||
| 91 | const remember = ( | ||
| 92 | session: RpcSession, | ||
| 93 | path: string, | ||
| 94 | value: FilesystemEntryMetadata | null, | ||
| 95 | now: number, | ||
| 96 | partial = false, | ||
| 97 | ) => { | ||
| 98 | let entries = cache.get(session); | ||
| 99 | if (!entries) { | ||
| 100 | entries = new Map(); | ||
| 101 | cache.set(session, entries); | ||
| 102 | } | ||
| 103 | const key = normalizeProjectPathForComparison(path); | ||
| 104 | entries.delete(key); | ||
| 105 | const entry = { value, expires: now + METADATA_STALE_TIME_MS }; | ||
| 106 | entries.set(key, entry); | ||
| 107 | for (const thread of retainedThreads.values()) { | ||
| 108 | const retained = thread.sessions.get(session); | ||
| 109 | const previous = retained?.get(key); | ||
| 110 | if (retained && previous) | ||
| 111 | retained.set(key, { | ||
| 112 | ...entry, | ||
| 113 | value: | ||
| 114 | partial && value && previous.value?.kind === value.kind | ||
| 115 | ? { ...previous.value, ...value } | ||
| 116 | : value, | ||
| 117 | }); | ||
| 118 | } | ||
| 119 | if (entries.size > METADATA_CACHE_LIMIT) { | ||
| 120 | const oldest = entries.keys().next().value; | ||
| 121 | if (oldest !== undefined) entries.delete(oldest); | ||
| 122 | } | ||
| 123 | }; | ||
| 124 | const resolver = RequestResolver.makeWith<FileMetadataRequest>({ | ||
| 125 | batchKey: (entry) => entry.request.session, | ||
| 126 | delay: Effect.yieldNow, | ||
| 127 | collectWhile: (entries) => entries.size < FILESYSTEM_METADATA_BATCH_LIMIT, | ||
| 128 | runAll: Effect.fnUntraced(function* (entries) { | ||
| 129 | const session = entries[0].request.session; | ||
| 130 | let pending = [...entries]; | ||
| 131 | while (pending.length > 0) { | ||
| 132 | const paths = [...new Set(pending.map((entry) => entry.request.path))]; | ||
| 133 | let requests = inFlight.get(session); | ||
| 134 | if (!requests) { | ||
| 135 | requests = new Map(); | ||
| 136 | inFlight.set(session, requests); | ||
| 137 | } | ||
| 138 | const tokens = new Map(paths.map((path) => [path, { valid: true }])); | ||
| 139 | for (const [path, token] of tokens) { | ||
| 140 | let active = requests.get(path); | ||
| 141 | if (!active) requests.set(path, (active = new Set())); | ||
| 142 | active.add(token); | ||
| 143 | } | ||
| 144 | const activeRequests = requests; | ||
| 145 | pending = yield* Effect.gen(function* () { | ||
| 146 | const result = yield* request(WS_METHODS.filesystemGetMetadata, { paths }).pipe( | ||
| 147 | Effect.provideService( | ||
| 148 | EnvironmentSupervisor.EnvironmentSupervisor, | ||
| 149 | entries[0].request.supervisor, | ||
| 150 | ), | ||
| 151 | ); | ||
| 152 | const now = yield* Clock.currentTimeMillis; | ||
| 153 | const byPath = new Map(paths.map((path, index) => [path, result.entries[index] ?? null])); | ||
| 154 | const retry: typeof pending = []; | ||
| 155 | for (const entry of pending) { | ||
| 156 | // Retry only paths invalidated during this request. Never retain the | ||
| 157 | // old response or complete a waiting chip with pre-write metadata. | ||
| 158 | if (!tokens.get(entry.request.path)!.valid) { | ||
| 159 | retry.push(entry); | ||
| 160 | continue; | ||
| 161 | } | ||
| 162 | const value = byPath.get(entry.request.path) ?? null; | ||
| 163 | remember(session, entry.request.path, value, now); | ||
| 164 | entry.request.retained?.set(entry.request.path, { | ||
| 165 | value, | ||
| 166 | expires: now + METADATA_STALE_TIME_MS, | ||
| 167 | }); | ||
| 168 | entry.completeUnsafe(Exit.succeed(value)); | ||
| 169 | } | ||
| 170 | return retry; | ||
| 171 | }).pipe( | ||
| 172 | Effect.ensuring( | ||
| 173 | Effect.sync(() => { | ||
| 174 | for (const [path, token] of tokens) { | ||
| 175 | const active = activeRequests.get(path); | ||
| 176 | active?.delete(token); | ||
| 177 | if (active?.size === 0) activeRequests.delete(path); | ||
| 178 | } | ||
| 179 | }), | ||
| 180 | ), | ||
| 181 | ); | ||
| 182 | } | ||
| 183 | }), | ||
| 184 | }); | ||
| 185 | |||
| 186 | const metadataFamily = createEnvironmentQueryAtomFamily(runtime, { | ||
| 187 | label: "environment-data:filesystem:metadata", | ||
| 188 | staleTimeMs: METADATA_STALE_TIME_MS, | ||
| 189 | idleTtlMs: METADATA_STALE_TIME_MS, | ||
| 190 | execute: Effect.fnUntraced(function* (input: FileMetadataInput) { | ||
| 191 | const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; | ||
| 192 | const session = yield* SubscriptionRef.get(supervisor.session); | ||
| 193 | if (session._tag === "None") return null; | ||
| 194 | const thread = input.threadId | ||
| 195 | ? retainedThreads.get( | ||
| 196 | threadKey({ environmentId: supervisor.target.environmentId, threadId: input.threadId }), | ||
| 197 | ) | ||
| 198 | : undefined; | ||
| 199 | let retained = thread?.sessions.get(session.value); | ||
| 200 | if (thread && !retained) { | ||
| 201 | retained = new Map(); | ||
| 202 | thread.sessions.set(session.value, retained); | ||
| 203 | } | ||
| 204 | const retainedEntry = retained?.get(input.path); | ||
| 205 | if (retainedEntry) return retainedEntry.value; | ||
| 206 | const now = yield* Clock.currentTimeMillis; | ||
| 207 | const cached = knownEntry(session.value, input.path, now); | ||
| 208 | if (cached) { | ||
| 209 | retained?.set(input.path, cached); | ||
| 210 | return cached.value; | ||
| 211 | } | ||
| 212 | return yield* Effect.request( | ||
| 213 | new FileMetadataRequest({ | ||
| 214 | path: input.path, | ||
| 215 | session: session.value, | ||
| 216 | supervisor, | ||
| 217 | ...(retained ? { retained } : {}), | ||
| 218 | }), | ||
| 219 | resolver, | ||
| 220 | ); | ||
| 221 | }), | ||
| 222 | refreshTrigger: ({ | ||
| 223 | environmentId, | ||
| 224 | input, | ||
| 225 | }: { | ||
| 226 | environmentId: EnvironmentId; | ||
| 227 | input: FileMetadataInput; | ||
| 228 | }) => revision(environmentId, input.path), | ||
| 229 | }); | ||
| 230 | const metadata = (target: { | ||
| 231 | environmentId: EnvironmentId; | ||
| 232 | input: { path: string; threadId?: ThreadId }; | ||
| 233 | }) => | ||
| 234 | metadataFamily({ | ||
| 235 | ...target, | ||
| 236 | input: { ...target.input, path: normalizeProjectPathForComparison(target.input.path) }, | ||
| 237 | }); | ||
| 238 | |||
| 239 | const rememberEntries = Effect.fnUntraced(function* ( | ||
| 240 | cwd: string, | ||
| 241 | entries: ReadonlyArray<ProjectEntry>, | ||
| 242 | isCurrent?: (session: RpcSession) => boolean, | ||
| 243 | ) { | ||
| 244 | const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; | ||
| 245 | const session = yield* SubscriptionRef.get(supervisor.session); | ||
| 246 | if (session._tag === "None") return; | ||
| 247 | const now = yield* Clock.currentTimeMillis; | ||
| 248 | if (isCurrent && !isCurrent(session.value)) return; | ||
| 249 | for (const entry of entries) { | ||
| 250 | const path = resolveWorkspaceFilePath(entry.path, cwd); | ||
| 251 | const previous = knownEntry(session.value, path, now); | ||
| 252 | if (previous?.value?.kind === entry.kind) continue; | ||
| 253 | // Listing kinds avoid another request; size and MIME hints are optional. | ||
| 254 | remember(session.value, path, { kind: entry.kind }, now, true); | ||
| 255 | } | ||
| 256 | }); | ||
| 257 | |||
| 258 | const invalidate = Effect.fnUntraced(function* (path: string) { | ||
| 259 | const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; | ||
| 260 | const session = yield* SubscriptionRef.get(supervisor.session); | ||
| 261 | if (session._tag === "Some") { | ||
| 262 | invalidationVersions.set(session.value, (invalidationVersions.get(session.value) ?? 0) + 1); | ||
| 263 | const key = normalizeProjectPathForComparison(path); | ||
| 264 | for (const token of inFlight.get(session.value)?.get(key) ?? []) token.valid = false; | ||
| 265 | cache.get(session.value)?.delete(key); | ||
| 266 | for (const thread of retainedThreads.values()) | ||
| 267 | thread.sessions.get(session.value)?.delete(normalizeProjectPathForComparison(path)); | ||
| 268 | } | ||
| 269 | }); | ||
| 270 | |||
| 271 | const rememberFile = Effect.fnUntraced(function* ( | ||
| 272 | cwd: string, | ||
| 273 | file: { relativePath: string; byteLength: number }, | ||
| 274 | isCurrent?: (session: RpcSession) => boolean, | ||
| 275 | ) { | ||
| 276 | const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; | ||
| 277 | const session = yield* SubscriptionRef.get(supervisor.session); | ||
| 278 | if (session._tag === "None") return; | ||
| 279 | const path = resolveWorkspaceFilePath(file.relativePath, cwd); | ||
| 280 | const now = yield* Clock.currentTimeMillis; | ||
| 281 | if (isCurrent && !isCurrent(session.value)) return; | ||
| 282 | const previous = knownEntry(session.value, path, now)?.value; | ||
| 283 | remember( | ||
| 284 | session.value, | ||
| 285 | path, | ||
| 286 | { | ||
| 287 | ...previous, | ||
| 288 | kind: "file", | ||
| 289 | byteLength: file.byteLength, | ||
| 290 | }, | ||
| 291 | now, | ||
| 292 | true, | ||
| 293 | ); | ||
| 294 | }); | ||
| 295 | |||
| 296 | const seedAfterRead = <A, ReadError, ReadServices>( | ||
| 297 | read: Effect.Effect<A, ReadError, ReadServices>, | ||
| 298 | seed: ( | ||
| 299 | result: A, | ||
| 300 | isCurrent: (session: RpcSession) => boolean, | ||
| 301 | ) => Effect.Effect<void, never, EnvironmentSupervisor.EnvironmentSupervisor>, | ||
| 302 | ) => | ||
| 303 | Effect.gen(function* () { | ||
| 304 | const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; | ||
| 305 | const session = yield* SubscriptionRef.get(supervisor.session); | ||
| 306 | const version = session._tag === "Some" ? (invalidationVersions.get(session.value) ?? 0) : 0; | ||
| 307 | const result = yield* read; | ||
| 308 | // Seeding is optional. Any intervening invalidation or reconnect makes | ||
| 309 | // the old response unsuitable for the current metadata cache. | ||
| 310 | yield* seed( | ||
| 311 | result, | ||
| 312 | (current) => | ||
| 313 | session._tag === "Some" && | ||
| 314 | current === session.value && | ||
| 315 | (invalidationVersions.get(current) ?? 0) === version, | ||
| 316 | ); | ||
| 317 | return result; | ||
| 318 | }); | ||
| 319 | |||
| 320 | const refreshPath = ( | ||
| 321 | environmentId: EnvironmentId, | ||
| 322 | path: string, | ||
| 323 | registry: AtomRegistry.AtomRegistry, | ||
| 324 | ) => { | ||
| 325 | const signal = revision(environmentId, path); | ||
| 326 | registry.set(signal, registry.get(signal) + 1); | ||
| 327 | }; | ||
| 328 | |||
| 329 | return { | ||
| 330 | metadata, | ||
| 331 | retainThread: (ref: ScopedThreadRef) => retainThreadFamily(threadKey(ref)), | ||
| 332 | rememberEntries, | ||
| 333 | rememberFile, | ||
| 334 | seedAfterRead, | ||
| 335 | invalidate, | ||
| 336 | refreshPath, | ||
| 337 | }; | ||
| 338 | } | ||
packages/client-runtime/src/state/projectCommands.test.ts+171-3| ... | @@ -1,12 +1,24 @@ | ... | @@ -1,12 +1,24 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { EnvironmentId, ProjectId, type ScopedProjectRef, WS_METHODS } from "@t3tools/contracts"; | 2 | import { |
| 3 | AuthFilesystemWriteScope, | ||
| 4 | EnvironmentId, | ||
| 5 | ProjectId, | ||
| 6 | ThreadId, | ||
| 7 | type AuthSessionState, | ||
| 8 | type ScopedProjectRef, | ||
| 9 | WS_METHODS, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import { vi } from "vite-plus/test"; | ||
| 3 | import * as Crypto from "effect/Crypto"; | 12 | import * as Crypto from "effect/Crypto"; |
| 4 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as Deferred from "effect/Deferred"; | ||
| 5 | import * as Fiber from "effect/Fiber"; | 15 | import * as Fiber from "effect/Fiber"; |
| 6 | import * as Layer from "effect/Layer"; | 16 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; | 17 | import * as Option from "effect/Option"; |
| 8 | import * as SubscriptionRef from "effect/SubscriptionRef"; | 18 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 9 | import { Atom, AtomRegistry } from "effect/reactivity"; | 19 | import * as Stream from "effect/Stream"; |
| 20 | import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity"; | ||
| 21 | import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts"; | ||
| 10 | 22 | ||
| 11 | import * as EnvironmentRegistry from "../connection/registry.ts"; | 23 | import * as EnvironmentRegistry from "../connection/registry.ts"; |
| 12 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | 24 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| ... | @@ -15,6 +27,12 @@ import type { EnvironmentProject } from "./models.ts"; | ... | @@ -15,6 +27,12 @@ import type { EnvironmentProject } from "./models.ts"; |
| 15 | import { createProjectEnvironmentAtoms } from "./projectCommands.ts"; | 27 | import { createProjectEnvironmentAtoms } from "./projectCommands.ts"; |
| 16 | 28 | ||
| 17 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); | 29 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); |
| 30 | vi.mock("./session.ts", () => ({ | ||
| 31 | createEnvironmentSessionAtoms: () => ({ sessionStateAtom: sessions }), | ||
| 32 | })); | ||
| 33 | const sessions = Atom.family((_id: EnvironmentId) => | ||
| 34 | Atom.make<AsyncResult.AsyncResult<AuthSessionState>>(AsyncResult.initial()), | ||
| 35 | ); | ||
| 18 | const PROJECT_ID = ProjectId.make("scratch"); | 36 | const PROJECT_ID = ProjectId.make("scratch"); |
| 19 | const PROJECT = { | 37 | const PROJECT = { |
| 20 | id: PROJECT_ID, | 38 | id: PROJECT_ID, |
| ... | @@ -23,21 +41,51 @@ const PROJECT = { | ... | @@ -23,21 +41,51 @@ const PROJECT = { |
| 23 | } as EnvironmentProject; | 41 | } as EnvironmentProject; |
| 24 | 42 | ||
| 25 | const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { | 43 | const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { |
| 44 | const metadataRequests: string[][] = []; | ||
| 45 | const file = { byteLength: 5 }; | ||
| 46 | const readResponse = { wait: Effect.void as Effect.Effect<void> }; | ||
| 26 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | 47 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ |
| 27 | target: { environmentId: ENVIRONMENT_ID }, | 48 | target: { environmentId: ENVIRONMENT_ID }, |
| 28 | session: yield* SubscriptionRef.make( | 49 | session: yield* SubscriptionRef.make( |
| 29 | Option.some({ | 50 | Option.some({ |
| 30 | client: { | 51 | client: { |
| 31 | [WS_METHODS.projectsEnsureScratch]: () => Effect.succeed({ projectId: PROJECT_ID }), | 52 | [WS_METHODS.projectsEnsureScratch]: () => Effect.succeed({ projectId: PROJECT_ID }), |
| 53 | [WS_METHODS.projectsReadFile]: () => | ||
| 54 | Effect.gen(function* () { | ||
| 55 | const byteLength = file.byteLength; | ||
| 56 | yield* readResponse.wait; | ||
| 57 | return { relativePath: "foo", byteLength, contents: "old", truncated: false }; | ||
| 58 | }), | ||
| 59 | [WS_METHODS.projectsWriteFile]: () => | ||
| 60 | Effect.sync(() => { | ||
| 61 | file.byteLength = 10; | ||
| 62 | return { relativePath: "foo" }; | ||
| 63 | }), | ||
| 64 | [WS_METHODS.filesystemGetMetadata]: (input: { paths: string[] }) => | ||
| 65 | Effect.sync(() => { | ||
| 66 | metadataRequests.push(input.paths); | ||
| 67 | return { | ||
| 68 | entries: input.paths.map(() => ({ | ||
| 69 | kind: "file" as const, | ||
| 70 | byteLength: file.byteLength, | ||
| 71 | })), | ||
| 72 | }; | ||
| 73 | }), | ||
| 32 | }, | 74 | }, |
| 33 | } as unknown as RpcSession), | 75 | } as unknown as RpcSession), |
| 34 | ), | 76 | ), |
| 77 | state: yield* SubscriptionRef.make({ | ||
| 78 | ...AVAILABLE_CONNECTION_STATE, | ||
| 79 | phase: "connected" as const, | ||
| 80 | }), | ||
| 35 | } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | 81 | } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]); |
| 36 | const runtime = Atom.runtime( | 82 | const runtime = Atom.runtime( |
| 37 | Layer.mergeAll( | 83 | Layer.mergeAll( |
| 38 | Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, { | 84 | Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, { |
| 39 | run: (_environmentId, effect) => | 85 | run: (_environmentId, effect) => |
| 40 | Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | 86 | Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 87 | followStream: (_environmentId, stream) => | ||
| 88 | Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 41 | } as EnvironmentRegistry.EnvironmentRegistry["Service"]), | 89 | } as EnvironmentRegistry.EnvironmentRegistry["Service"]), |
| 42 | Layer.succeed( | 90 | Layer.succeed( |
| 43 | Crypto.Crypto, | 91 | Crypto.Crypto, |
| ... | @@ -57,10 +105,130 @@ const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { | ... | @@ -57,10 +105,130 @@ const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { |
| 57 | }); | 105 | }); |
| 58 | const registry = AtomRegistry.make(); | 106 | const registry = AtomRegistry.make(); |
| 59 | yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); | 107 | yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); |
| 108 | registry.set( | ||
| 109 | sessions(ENVIRONMENT_ID), | ||
| 110 | AsyncResult.success({ | ||
| 111 | authenticated: true, | ||
| 112 | auth: { | ||
| 113 | policy: "remote-reachable" as const, | ||
| 114 | bootstrapMethods: [], | ||
| 115 | sessionMethods: [], | ||
| 116 | sessionCookieName: "test", | ||
| 117 | }, | ||
| 118 | scopes: [AuthFilesystemWriteScope], | ||
| 119 | permissions: [AuthFilesystemWriteScope], | ||
| 120 | }), | ||
| 121 | ); | ||
| 60 | const openScratch = Effect.promise(() => | 122 | const openScratch = Effect.promise(() => |
| 61 | commands.openScratch.run(registry, { environmentId: ENVIRONMENT_ID, input: {} }), | 123 | commands.openScratch.run(registry, { environmentId: ENVIRONMENT_ID, input: {} }), |
| 62 | ); | 124 | ); |
| 63 | return { registry, storedProject, openScratch }; | 125 | return { registry, storedProject, openScratch, commands, metadataRequests, readResponse }; |
| 126 | }); | ||
| 127 | |||
| 128 | describe("writeFile metadata", () => { | ||
| 129 | it.effect("reuses a current file read without another metadata request", () => | ||
| 130 | Effect.gen(function* () { | ||
| 131 | const h = yield* makeHarness(); | ||
| 132 | yield* AtomRegistry.getResult( | ||
| 133 | h.registry, | ||
| 134 | h.commands.readFile({ | ||
| 135 | environmentId: ENVIRONMENT_ID, | ||
| 136 | input: { cwd: "/repo", relativePath: "foo" }, | ||
| 137 | }), | ||
| 138 | { suspendOnWaiting: true }, | ||
| 139 | ); | ||
| 140 | const metadata = h.commands.fileMetadata({ | ||
| 141 | environmentId: ENVIRONMENT_ID, | ||
| 142 | input: { path: "/repo/foo" }, | ||
| 143 | }); | ||
| 144 | expect( | ||
| 145 | yield* AtomRegistry.getResult(h.registry, metadata, { suspendOnWaiting: true }), | ||
| 146 | ).toEqual({ kind: "file", byteLength: 5 }); | ||
| 147 | expect(h.metadataRequests).toHaveLength(0); | ||
| 148 | }).pipe(Effect.scoped), | ||
| 149 | ); | ||
| 150 | |||
| 151 | it.effect("does not restore old metadata from a read completed after a write", () => | ||
| 152 | Effect.gen(function* () { | ||
| 153 | const h = yield* makeHarness(); | ||
| 154 | const threadId = ThreadId.make("stale-read-thread"); | ||
| 155 | h.registry.mount(h.commands.fileMetadataThread({ environmentId: ENVIRONMENT_ID, threadId })); | ||
| 156 | const metadata = h.commands.fileMetadata({ | ||
| 157 | environmentId: ENVIRONMENT_ID, | ||
| 158 | input: { path: "/repo/foo", threadId }, | ||
| 159 | }); | ||
| 160 | h.registry.mount(metadata); | ||
| 161 | const readMetadata = () => | ||
| 162 | AtomRegistry.getResult(h.registry, metadata, { suspendOnWaiting: true }); | ||
| 163 | yield* readMetadata(); | ||
| 164 | const started = yield* Deferred.make<void>(); | ||
| 165 | const release = yield* Deferred.make<void>(); | ||
| 166 | h.readResponse.wait = Deferred.succeed(started, undefined).pipe( | ||
| 167 | Effect.andThen(Deferred.await(release)), | ||
| 168 | ); | ||
| 169 | const fileRead = yield* AtomRegistry.getResult( | ||
| 170 | h.registry, | ||
| 171 | h.commands.readFile({ | ||
| 172 | environmentId: ENVIRONMENT_ID, | ||
| 173 | input: { cwd: "/repo", relativePath: "foo" }, | ||
| 174 | }), | ||
| 175 | { suspendOnWaiting: true }, | ||
| 176 | ).pipe(Effect.forkChild); | ||
| 177 | yield* Deferred.await(started); | ||
| 178 | const write = yield* Effect.promise(() => | ||
| 179 | h.commands.writeFile.run(h.registry, { | ||
| 180 | environmentId: ENVIRONMENT_ID, | ||
| 181 | input: { cwd: "/repo", relativePath: "foo", contents: "new" }, | ||
| 182 | }), | ||
| 183 | ); | ||
| 184 | expect(write._tag).toBe("Success"); | ||
| 185 | expect(yield* readMetadata()).toEqual({ kind: "file", byteLength: 10 }); | ||
| 186 | yield* Deferred.succeed(release, undefined); | ||
| 187 | expect((yield* Fiber.join(fileRead)).byteLength).toBe(5); | ||
| 188 | h.registry.refresh(metadata); | ||
| 189 | expect(yield* readMetadata()).toEqual({ kind: "file", byteLength: 10 }); | ||
| 190 | expect(h.metadataRequests).toHaveLength(2); | ||
| 191 | }).pipe(Effect.scoped), | ||
| 192 | ); | ||
| 193 | |||
| 194 | it.effect("refreshes retained canonical and original paths after an aliased write", () => | ||
| 195 | Effect.gen(function* () { | ||
| 196 | const h = yield* makeHarness(); | ||
| 197 | const threadId = ThreadId.make("write-metadata-thread"); | ||
| 198 | h.registry.mount(h.commands.fileMetadataThread({ environmentId: ENVIRONMENT_ID, threadId })); | ||
| 199 | const atoms = ["/repo/foo", "/repo/./foo"].map((path) => | ||
| 200 | h.commands.fileMetadata({ environmentId: ENVIRONMENT_ID, input: { path, threadId } }), | ||
| 201 | ); | ||
| 202 | for (const atom of atoms) h.registry.mount(atom); | ||
| 203 | const read = () => | ||
| 204 | Effect.forEach( | ||
| 205 | atoms, | ||
| 206 | (atom) => AtomRegistry.getResult(h.registry, atom, { suspendOnWaiting: true }), | ||
| 207 | { concurrency: "unbounded" }, | ||
| 208 | ); | ||
| 209 | expect(yield* read()).toEqual([ | ||
| 210 | { kind: "file", byteLength: 5 }, | ||
| 211 | { kind: "file", byteLength: 5 }, | ||
| 212 | ]); | ||
| 213 | const result = yield* Effect.promise(() => | ||
| 214 | h.commands.writeFile.run(h.registry, { | ||
| 215 | environmentId: ENVIRONMENT_ID, | ||
| 216 | input: { cwd: "/repo", relativePath: "./foo", contents: "new" }, | ||
| 217 | }), | ||
| 218 | ); | ||
| 219 | expect(result).toMatchObject({ _tag: "Success", value: { relativePath: "foo" } }); | ||
| 220 | expect(yield* read()).toEqual([ | ||
| 221 | { kind: "file", byteLength: 10 }, | ||
| 222 | { kind: "file", byteLength: 10 }, | ||
| 223 | ]); | ||
| 224 | expect(h.metadataRequests.flat()).toEqual([ | ||
| 225 | "/repo/foo", | ||
| 226 | "/repo/./foo", | ||
| 227 | "/repo/foo", | ||
| 228 | "/repo/./foo", | ||
| 229 | ]); | ||
| 230 | }).pipe(Effect.scoped), | ||
| 231 | ); | ||
| 64 | }); | 232 | }); |
| 65 | 233 | ||
| 66 | describe("openScratch", () => { | 234 | describe("openScratch", () => { |
packages/client-runtime/src/state/projectCommands.ts+61-1| ... | @@ -13,7 +13,12 @@ import * as Schema from "effect/Schema"; | ... | @@ -13,7 +13,12 @@ import * as Schema from "effect/Schema"; |
| 13 | import * as Stream from "effect/Stream"; | 13 | import * as Stream from "effect/Stream"; |
| 14 | import { Atom, AtomRegistry } from "effect/reactivity"; | 14 | import { Atom, AtomRegistry } from "effect/reactivity"; |
| 15 | 15 | ||
| 16 | import { type EnvironmentRpcInput, request } from "../rpc/client.ts"; | 16 | import { |
| 17 | type EnvironmentRpcInput, | ||
| 18 | type EnvironmentRpcSuccess, | ||
| 19 | request, | ||
| 20 | requestGuarded, | ||
| 21 | } from "../rpc/client.ts"; | ||
| 17 | import type { EnvironmentProject } from "./models.ts"; | 22 | import type { EnvironmentProject } from "./models.ts"; |
| 18 | import { | 23 | import { |
| 19 | createAtomCommandScheduler, | 24 | createAtomCommandScheduler, |
| ... | @@ -30,6 +35,8 @@ import { | ... | @@ -30,6 +35,8 @@ import { |
| 30 | updateProject, | 35 | updateProject, |
| 31 | } from "../operations/commands.ts"; | 36 | } from "../operations/commands.ts"; |
| 32 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | 37 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 38 | import { createFileMetadataAtoms } from "./fileMetadata.ts"; | ||
| 39 | import { resolveWorkspaceFilePath } from "@t3tools/shared/path"; | ||
| 33 | 40 | ||
| 34 | export type { | 41 | export type { |
| 35 | CreateProjectInput, | 42 | CreateProjectInput, |
| ... | @@ -52,6 +59,17 @@ function optimisticProjectFileKey(target: OptimisticProjectFileTarget): string { | ... | @@ -52,6 +59,17 @@ function optimisticProjectFileKey(target: OptimisticProjectFileTarget): string { |
| 52 | return JSON.stringify([target.environmentId, target.cwd, target.relativePath]); | 59 | return JSON.stringify([target.environmentId, target.cwd, target.relativePath]); |
| 53 | } | 60 | } |
| 54 | 61 | ||
| 62 | function writtenFilePaths( | ||
| 63 | input: EnvironmentRpcInput<typeof WS_METHODS.projectsWriteFile>, | ||
| 64 | result: EnvironmentRpcSuccess<typeof WS_METHODS.projectsWriteFile>, | ||
| 65 | ) { | ||
| 66 | return new Set( | ||
| 67 | [result.relativePath, input.relativePath].map((path) => | ||
| 68 | resolveWorkspaceFilePath(path, input.cwd), | ||
| 69 | ), | ||
| 70 | ); | ||
| 71 | } | ||
| 72 | |||
| 55 | /** The Scratch project was created, but its event never reached this client. */ | 73 | /** The Scratch project was created, but its event never reached this client. */ |
| 56 | export class ScratchProjectNotLoadedError extends Schema.TaggedError<ScratchProjectNotLoadedError>()( | 74 | export class ScratchProjectNotLoadedError extends Schema.TaggedError<ScratchProjectNotLoadedError>()( |
| 57 | "ScratchProjectNotLoadedError", | 75 | "ScratchProjectNotLoadedError", |
| ... | @@ -69,6 +87,7 @@ export function createProjectEnvironmentAtoms<R, E>( | ... | @@ -69,6 +87,7 @@ export function createProjectEnvironmentAtoms<R, E>( |
| 69 | readonly projectAtom: (ref: ScopedProjectRef) => Atom.Atom<EnvironmentProject | null>; | 87 | readonly projectAtom: (ref: ScopedProjectRef) => Atom.Atom<EnvironmentProject | null>; |
| 70 | }, | 88 | }, |
| 71 | ) { | 89 | ) { |
| 90 | const fileMetadata = createFileMetadataAtoms(runtime); | ||
| 72 | const projectScheduler = createAtomCommandScheduler(); | 91 | const projectScheduler = createAtomCommandScheduler(); |
| 73 | const fileScheduler = createAtomCommandScheduler(); | 92 | const fileScheduler = createAtomCommandScheduler(); |
| 74 | const optimisticFileFamily = Atom.family((key: string) => | 93 | const optimisticFileFamily = Atom.family((key: string) => |
| ... | @@ -82,22 +101,52 @@ export function createProjectEnvironmentAtoms<R, E>( | ... | @@ -82,22 +101,52 @@ export function createProjectEnvironmentAtoms<R, E>( |
| 82 | JSON.stringify([environmentId, input.projectId]), | 101 | JSON.stringify([environmentId, input.projectId]), |
| 83 | }; | 102 | }; |
| 84 | return { | 103 | return { |
| 104 | fileMetadata: fileMetadata.metadata, | ||
| 105 | fileMetadataThread: fileMetadata.retainThread, | ||
| 106 | invalidateFileMetadata: createEnvironmentCommand(runtime, { | ||
| 107 | label: "environment-data:filesystem:invalidate-metadata", | ||
| 108 | execute: (input: { paths: ReadonlyArray<string> }, registry, environmentId) => | ||
| 109 | Effect.forEach([...new Set(input.paths)], (path) => | ||
| 110 | fileMetadata | ||
| 111 | .invalidate(path) | ||
| 112 | .pipe( | ||
| 113 | Effect.tap(() => | ||
| 114 | Effect.sync(() => fileMetadata.refreshPath(environmentId, path, registry)), | ||
| 115 | ), | ||
| 116 | ), | ||
| 117 | ), | ||
| 118 | }), | ||
| 85 | searchEntries: createEnvironmentRpcQueryAtomFamily(runtime, { | 119 | searchEntries: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 86 | label: "environment-data:projects:search-entries", | 120 | label: "environment-data:projects:search-entries", |
| 87 | tag: WS_METHODS.projectsSearchEntries, | 121 | tag: WS_METHODS.projectsSearchEntries, |
| 88 | staleTimeMs: 15_000, | 122 | staleTimeMs: 15_000, |
| 123 | execute: (input) => | ||
| 124 | fileMetadata.seedAfterRead( | ||
| 125 | request(WS_METHODS.projectsSearchEntries, input), | ||
| 126 | (result, isCurrent) => fileMetadata.rememberEntries(input.cwd, result.entries, isCurrent), | ||
| 127 | ), | ||
| 89 | }), | 128 | }), |
| 90 | listEntries: createEnvironmentRpcQueryAtomFamily(runtime, { | 129 | listEntries: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 91 | label: "environment-data:projects:list-entries", | 130 | label: "environment-data:projects:list-entries", |
| 92 | tag: WS_METHODS.projectsListEntries, | 131 | tag: WS_METHODS.projectsListEntries, |
| 93 | staleTimeMs: 30_000, | 132 | staleTimeMs: 30_000, |
| 94 | idleTtlMs: 5 * 60_000, | 133 | idleTtlMs: 5 * 60_000, |
| 134 | execute: (input) => | ||
| 135 | fileMetadata.seedAfterRead( | ||
| 136 | request(WS_METHODS.projectsListEntries, input), | ||
| 137 | (result, isCurrent) => fileMetadata.rememberEntries(input.cwd, result.entries, isCurrent), | ||
| 138 | ), | ||
| 95 | }), | 139 | }), |
| 96 | readFile: createEnvironmentRpcQueryAtomFamily(runtime, { | 140 | readFile: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 97 | label: "environment-data:projects:read-file", | 141 | label: "environment-data:projects:read-file", |
| 98 | tag: WS_METHODS.projectsReadFile, | 142 | tag: WS_METHODS.projectsReadFile, |
| 99 | staleTimeMs: 30_000, | 143 | staleTimeMs: 30_000, |
| 100 | idleTtlMs: 5 * 60_000, | 144 | idleTtlMs: 5 * 60_000, |
| 145 | execute: (input) => | ||
| 146 | fileMetadata.seedAfterRead( | ||
| 147 | request(WS_METHODS.projectsReadFile, input), | ||
| 148 | (result, isCurrent) => fileMetadata.rememberFile(input.cwd, result, isCurrent), | ||
| 149 | ), | ||
| 101 | }), | 150 | }), |
| 102 | optimisticFile: (target: OptimisticProjectFileTarget) => | 151 | optimisticFile: (target: OptimisticProjectFileTarget) => |
| 103 | optimisticFileFamily(optimisticProjectFileKey(target)), | 152 | optimisticFileFamily(optimisticProjectFileKey(target)), |
| ... | @@ -163,6 +212,17 @@ export function createProjectEnvironmentAtoms<R, E>( | ... | @@ -163,6 +212,17 @@ export function createProjectEnvironmentAtoms<R, E>( |
| 163 | key: ({ environmentId, input }) => | 212 | key: ({ environmentId, input }) => |
| 164 | JSON.stringify([environmentId, input.cwd, input.relativePath]), | 213 | JSON.stringify([environmentId, input.cwd, input.relativePath]), |
| 165 | }, | 214 | }, |
| 215 | execute: (input) => | ||
| 216 | requestGuarded(WS_METHODS.projectsWriteFile, input).pipe( | ||
| 217 | Effect.tap((result) => | ||
| 218 | Effect.forEach(writtenFilePaths(input, result), fileMetadata.invalidate), | ||
| 219 | ), | ||
| 220 | ), | ||
| 221 | onSuccess: ({ environmentId, input }, registry, result) => | ||
| 222 | Effect.sync(() => { | ||
| 223 | for (const path of writtenFilePaths(input, result)) | ||
| 224 | fileMetadata.refreshPath(environmentId, path, registry); | ||
| 225 | }), | ||
| 166 | }), | 226 | }), |
| 167 | }; | 227 | }; |
| 168 | } | 228 | } |
packages/client-runtime/src/state/runtime.ts+2-1| ... | @@ -769,6 +769,7 @@ export function createEnvironmentRpcCommand< | ... | @@ -769,6 +769,7 @@ export function createEnvironmentRpcCommand< |
| 769 | readonly input: NoInfer<Input>; | 769 | readonly input: NoInfer<Input>; |
| 770 | }, | 770 | }, |
| 771 | registry: AtomRegistry.AtomRegistry, | 771 | registry: AtomRegistry.AtomRegistry, |
| 772 | result: EnvironmentRpcSuccess<TTag>, | ||
| 772 | ) => Effect.Effect<void, never, R>; | 773 | ) => Effect.Effect<void, never, R>; |
| 773 | readonly onSettled?: ( | 774 | readonly onSettled?: ( |
| 774 | target: { | 775 | target: { |
| ... | @@ -797,7 +798,7 @@ export function createEnvironmentRpcCommand< | ... | @@ -797,7 +798,7 @@ export function createEnvironmentRpcCommand< |
| 797 | authorize: (id, method, payload) => | 798 | authorize: (id, method, payload) => |
| 798 | createCommandPermissions(runtime, method).authorize(registry, id, payload), | 799 | createCommandPermissions(runtime, method).authorize(registry, id, payload), |
| 799 | }), | 800 | }), |
| 800 | Effect.tap(() => options.onSuccess?.(target, registry) ?? Effect.void), | 801 | Effect.tap((result) => options.onSuccess?.(target, registry, result) ?? Effect.void), |
| 801 | Effect.ensuring(options.onSettled?.(target, registry) ?? Effect.void), | 802 | Effect.ensuring(options.onSettled?.(target, registry) ?? Effect.void), |
| 802 | ); | 803 | ); |
| 803 | }, | 804 | }, |
packages/contracts/src/filesystem.test.ts+11-1| ... | @@ -1,7 +1,17 @@ | ... | @@ -1,7 +1,17 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { FilesystemBrowseError } from "./filesystem.ts"; | 4 | import { FilesystemBrowseError, FilesystemGetMetadataInput } from "./filesystem.ts"; |
| 5 | |||
| 6 | describe("FilesystemGetMetadataInput", () => { | ||
| 7 | const decode = Schema.decodeUnknownSync(FilesystemGetMetadataInput); | ||
| 8 | it("bounds request size and path lengths", () => { | ||
| 9 | expect(() => decode({ paths: [] })).toThrow(); | ||
| 10 | expect(() => decode({ paths: Array.from({ length: 65 }, () => "/file") })).toThrow(); | ||
| 11 | expect(() => decode({ paths: ["/" + "x".repeat(512)] })).toThrow(); | ||
| 12 | expect(decode({ paths: Array.from({ length: 64 }, () => "/file") }).paths).toHaveLength(64); | ||
| 13 | }); | ||
| 14 | }); | ||
| 5 | 15 | ||
| 6 | describe("FilesystemBrowseError", () => { | 16 | describe("FilesystemBrowseError", () => { |
| 7 | it("derives a stable message from browse context while retaining the cause", () => { | 17 | it("derives a stable message from browse context while retaining the cause", () => { |
packages/contracts/src/filesystem.ts+26-1| ... | @@ -1,8 +1,33 @@ | ... | @@ -1,8 +1,33 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; | 2 | import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 3 | 3 | ||
| 4 | const FILESYSTEM_PATH_MAX_LENGTH = 512; | 4 | const FILESYSTEM_PATH_MAX_LENGTH = 512; |
| 5 | 5 | ||
| 6 | export const FILESYSTEM_METADATA_BATCH_LIMIT = 64; | ||
| 7 | |||
| 8 | export const FilesystemGetMetadataInput = Schema.Struct({ | ||
| 9 | // Absolute host paths, including ~/ paths. Relative paths need a workspace | ||
| 10 | // anchor on the client; the server never resolves them against its own cwd. | ||
| 11 | paths: Schema.Array( | ||
| 12 | TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)), | ||
| 13 | ).check(Schema.isMinLength(1), Schema.isMaxLength(FILESYSTEM_METADATA_BATCH_LIMIT)), | ||
| 14 | }); | ||
| 15 | export type FilesystemGetMetadataInput = typeof FilesystemGetMetadataInput.Type; | ||
| 16 | |||
| 17 | export const FilesystemEntryMetadata = Schema.Struct({ | ||
| 18 | kind: Schema.Literals(["file", "directory", "other"]), | ||
| 19 | byteLength: Schema.optionalKey(NonNegativeInt), | ||
| 20 | mimeType: Schema.optionalKey(TrimmedNonEmptyString), | ||
| 21 | }); | ||
| 22 | export type FilesystemEntryMetadata = typeof FilesystemEntryMetadata.Type; | ||
| 23 | |||
| 24 | // Results follow request order so paths do not have to travel back over the wire. | ||
| 25 | // A null entry means the path is missing, unreadable, or unsupported. | ||
| 26 | export const FilesystemGetMetadataResult = Schema.Struct({ | ||
| 27 | entries: Schema.Array(Schema.NullOr(FilesystemEntryMetadata)), | ||
| 28 | }); | ||
| 29 | export type FilesystemGetMetadataResult = typeof FilesystemGetMetadataResult.Type; | ||
| 30 | |||
| 6 | export const FilesystemBrowseInput = Schema.Struct({ | 31 | export const FilesystemBrowseInput = Schema.Struct({ |
| 7 | partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)), | 32 | partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)), |
| 8 | cwd: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))), | 33 | cwd: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))), |
packages/contracts/src/rpc.ts+10| ... | @@ -77,6 +77,8 @@ import { | ... | @@ -77,6 +77,8 @@ import { |
| 77 | HostPowerSnapshot, | 77 | HostPowerSnapshot, |
| 78 | } from "./background.ts"; | 78 | } from "./background.ts"; |
| 79 | import { | 79 | import { |
| 80 | FilesystemGetMetadataInput, | ||
| 81 | FilesystemGetMetadataResult, | ||
| 80 | FilesystemBrowseInput, | 82 | FilesystemBrowseInput, |
| 81 | FilesystemBrowseResult, | 83 | FilesystemBrowseResult, |
| 82 | FilesystemBrowseError, | 84 | FilesystemBrowseError, |
| ... | @@ -382,6 +384,7 @@ export const WS_METHODS = { | ... | @@ -382,6 +384,7 @@ export const WS_METHODS = { |
| 382 | 384 | ||
| 383 | // Filesystem methods | 385 | // Filesystem methods |
| 384 | filesystemBrowse: "filesystem.browse", | 386 | filesystemBrowse: "filesystem.browse", |
| 387 | filesystemGetMetadata: "filesystem.getMetadata", | ||
| 385 | agentSessionsScan: "agentSessions.scan", | 388 | agentSessionsScan: "agentSessions.scan", |
| 386 | agentSessionsImport: "agentSessions.import", | 389 | agentSessionsImport: "agentSessions.import", |
| 387 | assetsCreateUrl: "assets.createUrl", | 390 | assetsCreateUrl: "assets.createUrl", |
| ... | @@ -1236,6 +1239,12 @@ const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { | ... | @@ -1236,6 +1239,12 @@ const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { |
| 1236 | error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), | 1239 | error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), |
| 1237 | }); | 1240 | }); |
| 1238 | 1241 | ||
| 1242 | const WsFilesystemGetMetadataRpc = Rpc.make(WS_METHODS.filesystemGetMetadata, { | ||
| 1243 | payload: FilesystemGetMetadataInput, | ||
| 1244 | success: FilesystemGetMetadataResult, | ||
| 1245 | error: EnvironmentAuthorizationError, | ||
| 1246 | }); | ||
| 1247 | |||
| 1239 | const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { | 1248 | const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { |
| 1240 | payload: FilesystemBrowseInput, | 1249 | payload: FilesystemBrowseInput, |
| 1241 | success: FilesystemBrowseResult, | 1250 | success: FilesystemBrowseResult, |
| ... | @@ -1947,6 +1956,7 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -1947,6 +1956,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 1947 | WsProjectsMutateRpc, | 1956 | WsProjectsMutateRpc, |
| 1948 | WsShellOpenInEditorRpc, | 1957 | WsShellOpenInEditorRpc, |
| 1949 | WsFilesystemBrowseRpc, | 1958 | WsFilesystemBrowseRpc, |
| 1959 | WsFilesystemGetMetadataRpc, | ||
| 1950 | WsAgentSessionsScanRpc, | 1960 | WsAgentSessionsScanRpc, |
| 1951 | WsAgentSessionsImportRpc, | 1961 | WsAgentSessionsImportRpc, |
| 1952 | WsAssetsCreateUrlRpc, | 1962 | WsAssetsCreateUrlRpc, |
packages/shared/src/filePreview.ts+21| ... | @@ -1,5 +1,26 @@ | ... | @@ -1,5 +1,26 @@ |
| 1 | import { videoMimeType } from "./video.ts"; | 1 | import { videoMimeType } from "./video.ts"; |
| 2 | 2 | ||
| 3 | const ICON_EXTENSION_BY_MIME_TYPE: Readonly<Record<string, string>> = { | ||
| 4 | "image/png": "png", | ||
| 5 | "image/jpeg": "jpg", | ||
| 6 | "image/gif": "gif", | ||
| 7 | "image/webp": "webp", | ||
| 8 | "application/pdf": "pdf", | ||
| 9 | "application/zip": "zip", | ||
| 10 | "application/vnd.sqlite3": "sqlite", | ||
| 11 | "text/x-python": "py", | ||
| 12 | "text/javascript": "js", | ||
| 13 | "text/x-ruby": "rb", | ||
| 14 | "text/x-shellscript": "sh", | ||
| 15 | }; | ||
| 16 | |||
| 17 | /** A detected type fills in an unknown filename's icon; callers keep any | ||
| 18 | * specific icon already supplied by the filename. */ | ||
| 19 | export function fileIconPathForMimeType(path: string, mimeType?: string): string { | ||
| 20 | const extension = ICON_EXTENSION_BY_MIME_TYPE[mimeType ?? ""]; | ||
| 21 | return extension ? `file.${extension}` : path; | ||
| 22 | } | ||
| 23 | |||
| 3 | export type FilePreviewKind = | 24 | export type FilePreviewKind = |
| 4 | | "image" | 25 | | "image" |
| 5 | | "video" | 26 | | "video" |
packages/shared/src/path.test.ts+13| ... | @@ -9,9 +9,22 @@ import { | ... | @@ -9,9 +9,22 @@ import { |
| 9 | newProjectFolderName, | 9 | newProjectFolderName, |
| 10 | normalizeProjectPathForComparison, | 10 | normalizeProjectPathForComparison, |
| 11 | normalizeProjectPathForDispatch, | 11 | normalizeProjectPathForDispatch, |
| 12 | resolveWorkspaceFilePath, | ||
| 12 | } from "./path.ts"; | 13 | } from "./path.ts"; |
| 13 | 14 | ||
| 14 | describe("path helpers", () => { | 15 | describe("path helpers", () => { |
| 16 | it.each([ | ||
| 17 | ["report:123", "/workspace", "/workspace/report:123"], | ||
| 18 | ["/workspace/report:12:3", "/other", "/workspace/report:12:3"], | ||
| 19 | ["src/file", "C:\\Workspace\\", "C:\\Workspace\\src\\file"], | ||
| 20 | ["C:/Workspace/file", "/other", "C:/Workspace/file"], | ||
| 21 | ["file", "\\\\host\\share\\", "\\\\host\\share\\file"], | ||
| 22 | ["file", "/", "/file"], | ||
| 23 | ["~/report:123", "/workspace", "~/report:123"], | ||
| 24 | ])("resolves literal path %s within %s", (path, root, expected) => { | ||
| 25 | expect(resolveWorkspaceFilePath(path, root)).toBe(expected); | ||
| 26 | }); | ||
| 27 | |||
| 15 | it("detects windows drive paths", () => { | 28 | it("detects windows drive paths", () => { |
| 16 | expect(isWindowsDrivePath("C:\\repo")).toBe(true); | 29 | expect(isWindowsDrivePath("C:\\repo")).toBe(true); |
| 17 | expect(isWindowsDrivePath("D:/repo")).toBe(true); | 30 | expect(isWindowsDrivePath("D:/repo")).toBe(true); |
packages/shared/src/path.ts+10| ... | @@ -111,3 +111,13 @@ export function workspaceRelativeFilePath( | ... | @@ -111,3 +111,13 @@ export function workspaceRelativeFilePath( |
| 111 | export function isAbsolutePath(value: string): boolean { | 111 | export function isAbsolutePath(value: string): boolean { |
| 112 | return value.startsWith("/") || isWindowsAbsolutePath(value); | 112 | return value.startsWith("/") || isWindowsAbsolutePath(value); |
| 113 | } | 113 | } |
| 114 | |||
| 115 | /** Resolve a literal filesystem path without interpreting file-link positions. | ||
| 116 | * Home-relative paths stay intact for the server to expand. */ | ||
| 117 | export function resolveWorkspaceFilePath(path: string, workspaceRoot: string): string { | ||
| 118 | if (isAbsolutePath(path) || path.startsWith("~/") || path.startsWith("~\\")) return path; | ||
| 119 | const base = workspaceRoot.replace(/[/\\]+$/, ""); | ||
| 120 | return isWindowsAbsolutePath(workspaceRoot) | ||
| 121 | ? `${base}\\${path.replaceAll("/", "\\")}` | ||
| 122 | : `${base}/${path}`; | ||
| 123 | } |