| 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 | 154 | [WS_METHODS.projectsCreateNew]: AuthOrchestrationOperateScope, |
| 155 | 155 | [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, |
| 156 | 156 | [WS_METHODS.filesystemBrowse]: AuthFilesystemReadScope, |
| 157 | [WS_METHODS.filesystemGetMetadata]: AuthFilesystemReadScope, | |
| 157 | 158 | [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, |
| 158 | 159 | [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope, |
| 159 | 160 | [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, |
apps/server/src/observability/RpcInstrumentation.ts+1| ... | ... | @@ -140,6 +140,7 @@ const RPC_AGGREGATES = { |
| 140 | 140 | [WS_METHODS.projectsCreateNew]: "orchestration", |
| 141 | 141 | [WS_METHODS.shellOpenInEditor]: "workspace", |
| 142 | 142 | [WS_METHODS.filesystemBrowse]: "workspace", |
| 143 | [WS_METHODS.filesystemGetMetadata]: "workspace", | |
| 143 | 144 | [WS_METHODS.agentSessionsScan]: "workspace", |
| 144 | 145 | [WS_METHODS.agentSessionsImport]: "workspace", |
| 145 | 146 | [WS_METHODS.assetsCreateUrl]: "workspace", |
apps/server/src/workspace/WorkspaceFileSystem.test.ts+92| ... | ... | @@ -57,6 +57,95 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( |
| 57 | 57 | }); |
| 58 | 58 | |
| 59 | 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 | 149 | describe("readFile", () => { |
| 61 | 150 | it.effect("reads UTF-8 files relative to the workspace root", () => |
| 62 | 151 | Effect.gen(function* () { |
| ... | ... | @@ -125,6 +214,9 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i |
| 125 | 214 | .pipe(Effect.flip); |
| 126 | 215 | |
| 127 | 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 | 13 | import * as NodeFSP from "node:fs/promises"; |
| 14 | 14 | |
| 15 | 15 | import type { |
| 16 | FilesystemEntryMetadata, | |
| 17 | FilesystemGetMetadataInput, | |
| 18 | FilesystemGetMetadataResult, | |
| 16 | 19 | ProjectReadFileInput, |
| 17 | 20 | ProjectReadFileResult, |
| 18 | 21 | ProjectWriteFileInput, |
| 19 | 22 | ProjectWriteFileResult, |
| 20 | 23 | } from "@t3tools/contracts"; |
| 24 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; | |
| 25 | import * as HostProcess from "@t3tools/shared/HostProcess"; | |
| 21 | 26 | import * as Context from "effect/Context"; |
| 22 | 27 | import * as Effect from "effect/Effect"; |
| 23 | 28 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -27,6 +32,7 @@ import * as Schema from "effect/Schema"; |
| 27 | 32 | |
| 28 | 33 | import * as WorkspaceEntries from "./WorkspaceEntries.ts"; |
| 29 | 34 | import * as WorkspacePaths from "./WorkspacePaths.ts"; |
| 35 | import { fileHeaderMimeType } from "./fileHeaderMimeType.ts"; | |
| 30 | 36 | |
| 31 | 37 | const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; |
| 32 | 38 | |
| ... | ... | @@ -107,6 +113,10 @@ export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; |
| 107 | 113 | export class WorkspaceFileSystem extends Context.Service< |
| 108 | 114 | WorkspaceFileSystem, |
| 109 | 115 | { |
| 116 | readonly getMetadata: ( | |
| 117 | input: FilesystemGetMetadataInput, | |
| 118 | ) => Effect.Effect<FilesystemGetMetadataResult>; | |
| 119 | ||
| 110 | 120 | /** |
| 111 | 121 | * Read a UTF-8 text file relative to the workspace root, or any host file by |
| 112 | 122 | * absolute path. |
| ... | ... | @@ -139,6 +149,51 @@ export const make = Effect.gen(function* () { |
| 139 | 149 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; |
| 140 | 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 | 198 | * Resolves the file a read targets. Workspace-relative paths must stay inside the |
| 144 | 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 | 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 | 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 | 2681 | ), |
| 2682 | 2682 | ), |
| 2683 | 2683 | [WS_METHODS.shellOpenInEditor]: (input) => externalLauncher.launchEditor(input), |
| 2684 | [WS_METHODS.filesystemGetMetadata]: (input) => workspaceFileSystem.getMetadata(input), | |
| 2684 | 2685 | [WS_METHODS.filesystemBrowse]: (input) => |
| 2685 | 2686 | workspaceEntries.browse(input).pipe( |
| 2686 | 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 | 7 | const mint = vi.hoisted(() => vi.fn()); |
| 8 | 8 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); |
| 9 | 9 | vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); |
| 10 | vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => undefined })); | |
| 10 | 11 | vi.mock("../hooks/useSettings", async (importOriginal) => { |
| 11 | 12 | const actual = await importOriginal<typeof import("../hooks/useSettings")>(); |
| 12 | 13 | const settings = actual.getClientSettings(); |
apps/web/src/components/ChatMarkdown.permissions.test.tsx+1| ... | ... | @@ -29,6 +29,7 @@ const state = vi.hoisted(() => ({ |
| 29 | 29 | |
| 30 | 30 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => serverConfig })); |
| 31 | 31 | vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); |
| 32 | vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => undefined })); | |
| 32 | 33 | vi.mock("../hooks/useSettings", () => ({ |
| 33 | 34 | getClientSettings: () => DEFAULT_CLIENT_SETTINGS, |
| 34 | 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 | 17 | import { Button } from "./ui/button"; |
| 18 | 18 | import { setMarkdownTaskChecked } from "./files/filePreviewMode"; |
| 19 | 19 | |
| 20 | vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null })); | |
| 20 | 21 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); |
| 21 | 22 | vi.mock("./chat/MermaidDiagram", () => ({ |
| 22 | 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 | 121 | import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; |
| 122 | 122 | import { MediaActions, type MediaActionSource } from "./media/MediaActions"; |
| 123 | 123 | import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; |
| 124 | import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; | |
| 124 | 125 | import { FileTagChipContent } from "./chat/FileTagChip"; |
| 125 | 126 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; |
| 126 | 127 | import { |
| ... | ... | @@ -1192,6 +1193,7 @@ function UncachedShikiCodeBlock({ |
| 1192 | 1193 | } |
| 1193 | 1194 | |
| 1194 | 1195 | interface MarkdownFileLinkProps { |
| 1196 | environmentId: EnvironmentId | null; | |
| 1195 | 1197 | href: string; |
| 1196 | 1198 | targetPath: string; |
| 1197 | 1199 | iconPath: string; |
| ... | ... | @@ -1929,6 +1931,7 @@ function MarkdownExternalLinkContent({ |
| 1929 | 1931 | } |
| 1930 | 1932 | |
| 1931 | 1933 | const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1934 | environmentId, | |
| 1932 | 1935 | href, |
| 1933 | 1936 | targetPath, |
| 1934 | 1937 | iconPath, |
| ... | ... | @@ -2235,7 +2238,12 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 2235 | 2238 | }} |
| 2236 | 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 | 2247 | </ContextChip> |
| 2240 | 2248 | ) : ( |
| 2241 | 2249 | <ContextChip |
| ... | ... | @@ -2248,7 +2256,12 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 2248 | 2256 | onClick={handleContextMenu} |
| 2249 | 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 | 2265 | </ContextChip> |
| 2253 | 2266 | ) |
| 2254 | 2267 | } |
| ... | ... | @@ -2257,7 +2270,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 2257 | 2270 | {/* The full path: the chip already shows the shortened form, and a link |
| 2258 | 2271 | to the workspace root collapses to a bare label that repeats it. */} |
| 2259 | 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 | 2274 | </div> |
| 2262 | 2275 | </TooltipPopup> |
| 2263 | 2276 | </Tooltip> |
| ... | ... | @@ -2272,6 +2285,7 @@ function areMarkdownFileLinkPropsEqual( |
| 2272 | 2285 | previous.href === next.href && |
| 2273 | 2286 | previous.targetPath === next.targetPath && |
| 2274 | 2287 | previous.iconPath === next.iconPath && |
| 2288 | previous.environmentId === next.environmentId && | |
| 2275 | 2289 | previous.displayPath === next.displayPath && |
| 2276 | 2290 | previous.panelPath === next.panelPath && |
| 2277 | 2291 | previous.line === next.line && |
| ... | ... | @@ -2642,6 +2656,7 @@ function useChatMarkdownState({ |
| 2642 | 2656 | |
| 2643 | 2657 | return ( |
| 2644 | 2658 | <MarkdownFileLink |
| 2659 | environmentId={environmentId} | |
| 2645 | 2660 | href={fileLinkMeta.targetPath} |
| 2646 | 2661 | targetPath={fileLinkMeta.targetPath} |
| 2647 | 2662 | iconPath={fileLinkMeta.filePath} |
| ... | ... | @@ -2687,6 +2702,7 @@ function useChatMarkdownState({ |
| 2687 | 2702 | [ |
| 2688 | 2703 | canUseShellActions, |
| 2689 | 2704 | canOperatePreview, |
| 2705 | environmentId, | |
| 2690 | 2706 | fileLinkParentSuffixByPath, |
| 2691 | 2707 | openFileInPanel, |
| 2692 | 2708 | openInPreferredEditor, |
apps/web/src/components/ChatView.tsx+11-1| ... | ... | @@ -241,6 +241,7 @@ import { isCommandPaletteOpen } from "../commandPaletteBus"; |
| 241 | 241 | import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; |
| 242 | 242 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 243 | 243 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 244 | import { FileMetadataThreadProvider } from "../hooks/FileMetadataThreadProvider"; | |
| 244 | 245 | import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize"; |
| 245 | 246 | import { |
| 246 | 247 | RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY, |
| ... | ... | @@ -11277,7 +11278,7 @@ export default function ChatView(props: ChatViewProps) { |
| 11277 | 11278 | addFolders: (folders) => composerRef.current?.addDroppedFolders(folders), |
| 11278 | 11279 | }); |
| 11279 | 11280 | |
| 11280 | return ( | |
| 11281 | const content = ( | |
| 11281 | 11282 | <div |
| 11282 | 11283 | ref={setWorkspaceLayoutElement} |
| 11283 | 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 | 12118 | )} |
| 12118 | 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 | 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 | 91 | import { resolveDiffThemeName } from "~/lib/diffRendering"; |
| 92 | 92 | import { cn, isMacPlatform } from "~/lib/utils"; |
| 93 | 93 | import { basenameOfPath } from "~/pierre-icons"; |
| 94 | import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; | |
| 94 | 95 | import { FileTagChipContent } from "./chat/FileTagChip"; |
| 95 | 96 | import { SkillChipIcon } from "./chat/SkillInlineText"; |
| 96 | 97 | import { AssistantCitationChip } from "./chat/AssistantCitationChip"; |
| ... | ... | @@ -273,6 +274,8 @@ function ComposerMentionNodeView({ node }: NodeViewProps) { |
| 273 | 274 | > |
| 274 | 275 | <FileTagChipContent |
| 275 | 276 | path={path} |
| 277 | environmentId={actions.environmentId} | |
| 278 | cwd={actions.cwd} | |
| 276 | 279 | label={basenameOfPath(path)} |
| 277 | 280 | theme={resolvedThemeFromDocument()} |
| 278 | 281 | /> |
| ... | ... | @@ -282,7 +285,13 @@ function ComposerMentionNodeView({ node }: NodeViewProps) { |
| 282 | 285 | <NodeViewWrapper as="span" className={CHIP_NODE_SELECTION_CLASS_NAME}> |
| 283 | 286 | <Tooltip> |
| 284 | 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 | 295 | </Tooltip> |
| 287 | 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 | 1917 | const composerContextActions = useMemo( |
| 1918 | 1918 | () => ({ |
| 1919 | 1919 | environmentId, |
| 1920 | cwd: gitCwd ?? undefined, | |
| 1920 | 1921 | expandImage: (imageId: string) => { |
| 1921 | 1922 | const preview = buildExpandedImagePreview(composerImages, imageId); |
| 1922 | 1923 | if (preview) onExpandImage(preview); |
| ... | ... | @@ -1947,7 +1948,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1947 | 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 | 1961 | const composerContextRecords = useMemo( |
| 1953 | 1962 | () => |
apps/web/src/components/chat/FileTagChip.tsx+8-5| ... | ... | @@ -1,18 +1,21 @@ |
| 1 | import { inferEntryKindFromPath } from "../../pierre-icons"; | |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | 2 | import { ContextChipLabel } from "../ContextChip"; |
| 3 | import { PierreEntryIcon } from "./PierreEntryIcon"; | |
| 3 | import { WorkspaceEntryIcon } from "./WorkspaceEntryIcon"; | |
| 4 | 4 | |
| 5 | 5 | /** Icon and label for a file mention; render inside `<ContextChip kind="mention">`. */ |
| 6 | 6 | export function FileTagChipContent(props: { |
| 7 | 7 | path: string; |
| 8 | 8 | label: string; |
| 9 | 9 | theme: "light" | "dark"; |
| 10 | environmentId?: EnvironmentId | null | undefined; | |
| 11 | cwd?: string | undefined; | |
| 10 | 12 | }) { |
| 11 | 13 | return ( |
| 12 | 14 | <> |
| 13 | <PierreEntryIcon | |
| 14 | pathValue={props.path} | |
| 15 | kind={inferEntryKindFromPath(props.path)} | |
| 15 | <WorkspaceEntryIcon | |
| 16 | path={props.path} | |
| 17 | environmentId={props.environmentId} | |
| 18 | cwd={props.cwd} | |
| 16 | 19 | theme={props.theme} |
| 17 | 20 | /> |
| 18 | 21 | <ContextChipLabel>{props.label}</ContextChipLabel> |
apps/web/src/components/chat/MessagesTimeline.tsx+12-5| ... | ... | @@ -187,7 +187,7 @@ import { |
| 187 | 187 | } from "./timelineScrollAnchoring"; |
| 188 | 188 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 189 | 189 | import { PierreEntryIcon } from "./PierreEntryIcon"; |
| 190 | import { inferEntryKindFromPath } from "../../pierre-icons"; | |
| 190 | import { WorkspaceEntryIcon, WorkspaceEntryTooltip } from "./WorkspaceEntryIcon"; | |
| 191 | 191 | import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; |
| 192 | 192 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; |
| 193 | 193 | import { |
| ... | ... | @@ -4264,16 +4264,23 @@ function UserMessageMentionChip(props: { |
| 4264 | 4264 | useRightPanelStore.getState().openFile(ctx.threadRef, props.record.path); |
| 4265 | 4265 | }} |
| 4266 | 4266 | > |
| 4267 | <PierreEntryIcon | |
| 4268 | pathValue={props.record.path} | |
| 4269 | kind={inferEntryKindFromPath(props.record.path)} | |
| 4267 | <WorkspaceEntryIcon | |
| 4268 | path={props.record.path} | |
| 4269 | environmentId={ctx.activeThreadEnvironmentId} | |
| 4270 | cwd={ctx.workspaceRoot} | |
| 4270 | 4271 | theme={ctx.resolvedTheme} |
| 4271 | 4272 | /> |
| 4272 | 4273 | <ContextChipLabel>{props.record.label}</ContextChipLabel> |
| 4273 | 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 | 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 | 62 | export const PierreEntryIcon = memo(function PierreEntryIcon(props: { |
| 63 | 63 | pathValue: string; |
| 64 | 64 | kind: "file" | "directory"; |
| 65 | mimeType?: string | undefined; | |
| 65 | 66 | theme: "light" | "dark"; |
| 66 | 67 | className?: string; |
| 67 | 68 | }) { |
| 68 | 69 | useInsertionEffect(ensurePierreIconSprite, []); |
| 69 | 70 | const icon = useMemo( |
| 70 | () => resolvePierreIconForEntry(props.pathValue, props.kind), | |
| 71 | [props.kind, props.pathValue], | |
| 71 | () => resolvePierreIconForEntry(props.pathValue, props.kind, props.mimeType), | |
| 72 | [props.kind, props.pathValue, props.mimeType], | |
| 72 | 73 | ); |
| 73 | 74 | |
| 74 | 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 | 68 | /** What a chip can do beyond showing itself; the composer supplies the handlers. */ |
| 69 | 69 | export interface ComposerContextActions { |
| 70 | 70 | environmentId: EnvironmentId | null; |
| 71 | cwd?: string | undefined; | |
| 71 | 72 | expandImage: (imageId: string) => void; |
| 72 | 73 | expandVideo: (fileId: string) => void; |
| 73 | 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 | 8 | } from "./pierre-icons"; |
| 9 | 9 | |
| 10 | 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 | 27 | it("uses Pierre exact filename and complete-set extension mappings", () => { |
| 12 | 28 | assert.equal(resolvePierreIconForEntry("Dockerfile", "file")?.token, "docker"); |
| 13 | 29 | assert.equal(resolvePierreIconForEntry("src/Button.tsx", "file")?.token, "react"); |
apps/web/src/pierre-icons.ts+9-7| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | type FileTreeIcons, |
| 5 | 5 | } from "@pierre/trees"; |
| 6 | 6 | import { VIDEO_FILE_EXTENSIONS } from "@t3tools/shared/video"; |
| 7 | import { fileIconPathForMimeType } from "@t3tools/shared/filePreview"; | |
| 7 | 8 | |
| 8 | 9 | export interface PierreIconResolution { |
| 9 | 10 | name: string; |
| ... | ... | @@ -149,12 +150,6 @@ export function basenameOfPath(pathValue: string): string { |
| 149 | 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 | 153 | /** Languages whose files are recognised by name rather than by extension. */ |
| 159 | 154 | const LANGUAGE_FILE_NAMES: Record<string, string> = { |
| 160 | 155 | dockerfile: "Dockerfile", |
| ... | ... | @@ -171,9 +166,16 @@ export function syntheticFileNameForLanguageId(languageId: string): string { |
| 171 | 166 | export function resolvePierreIconForEntry( |
| 172 | 167 | pathValue: string, |
| 173 | 168 | kind: "file" | "directory", |
| 169 | mimeType?: string, | |
| 174 | 170 | ): PierreIconResolution | null { |
| 175 | 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 | 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 | 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 | 12 | import * as Crypto from "effect/Crypto"; |
| 4 | 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as Deferred from "effect/Deferred"; | |
| 5 | 15 | import * as Fiber from "effect/Fiber"; |
| 6 | 16 | import * as Layer from "effect/Layer"; |
| 7 | 17 | import * as Option from "effect/Option"; |
| 8 | 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 | 23 | import * as EnvironmentRegistry from "../connection/registry.ts"; |
| 12 | 24 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| ... | ... | @@ -15,6 +27,12 @@ import type { EnvironmentProject } from "./models.ts"; |
| 15 | 27 | import { createProjectEnvironmentAtoms } from "./projectCommands.ts"; |
| 16 | 28 | |
| 17 | 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 | 36 | const PROJECT_ID = ProjectId.make("scratch"); |
| 19 | 37 | const PROJECT = { |
| 20 | 38 | id: PROJECT_ID, |
| ... | ... | @@ -23,21 +41,51 @@ const PROJECT = { |
| 23 | 41 | } as EnvironmentProject; |
| 24 | 42 | |
| 25 | 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 | 47 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ |
| 27 | 48 | target: { environmentId: ENVIRONMENT_ID }, |
| 28 | 49 | session: yield* SubscriptionRef.make( |
| 29 | 50 | Option.some({ |
| 30 | 51 | client: { |
| 31 | 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 | 75 | } as unknown as RpcSession), |
| 34 | 76 | ), |
| 77 | state: yield* SubscriptionRef.make({ | |
| 78 | ...AVAILABLE_CONNECTION_STATE, | |
| 79 | phase: "connected" as const, | |
| 80 | }), | |
| 35 | 81 | } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]); |
| 36 | 82 | const runtime = Atom.runtime( |
| 37 | 83 | Layer.mergeAll( |
| 38 | 84 | Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, { |
| 39 | 85 | run: (_environmentId, effect) => |
| 40 | 86 | Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 87 | followStream: (_environmentId, stream) => | |
| 88 | Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 41 | 89 | } as EnvironmentRegistry.EnvironmentRegistry["Service"]), |
| 42 | 90 | Layer.succeed( |
| 43 | 91 | Crypto.Crypto, |
| ... | ... | @@ -57,10 +105,130 @@ const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { |
| 57 | 105 | }); |
| 58 | 106 | const registry = AtomRegistry.make(); |
| 59 | 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 | 122 | const openScratch = Effect.promise(() => |
| 61 | 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 | 234 | describe("openScratch", () => { |
packages/client-runtime/src/state/projectCommands.ts+61-1| ... | ... | @@ -13,7 +13,12 @@ import * as Schema from "effect/Schema"; |
| 13 | 13 | import * as Stream from "effect/Stream"; |
| 14 | 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 | 22 | import type { EnvironmentProject } from "./models.ts"; |
| 18 | 23 | import { |
| 19 | 24 | createAtomCommandScheduler, |
| ... | ... | @@ -30,6 +35,8 @@ import { |
| 30 | 35 | updateProject, |
| 31 | 36 | } from "../operations/commands.ts"; |
| 32 | 37 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 38 | import { createFileMetadataAtoms } from "./fileMetadata.ts"; | |
| 39 | import { resolveWorkspaceFilePath } from "@t3tools/shared/path"; | |
| 33 | 40 | |
| 34 | 41 | export type { |
| 35 | 42 | CreateProjectInput, |
| ... | ... | @@ -52,6 +59,17 @@ function optimisticProjectFileKey(target: OptimisticProjectFileTarget): string { |
| 52 | 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 | 73 | /** The Scratch project was created, but its event never reached this client. */ |
| 56 | 74 | export class ScratchProjectNotLoadedError extends Schema.TaggedError<ScratchProjectNotLoadedError>()( |
| 57 | 75 | "ScratchProjectNotLoadedError", |
| ... | ... | @@ -69,6 +87,7 @@ export function createProjectEnvironmentAtoms<R, E>( |
| 69 | 87 | readonly projectAtom: (ref: ScopedProjectRef) => Atom.Atom<EnvironmentProject | null>; |
| 70 | 88 | }, |
| 71 | 89 | ) { |
| 90 | const fileMetadata = createFileMetadataAtoms(runtime); | |
| 72 | 91 | const projectScheduler = createAtomCommandScheduler(); |
| 73 | 92 | const fileScheduler = createAtomCommandScheduler(); |
| 74 | 93 | const optimisticFileFamily = Atom.family((key: string) => |
| ... | ... | @@ -82,22 +101,52 @@ export function createProjectEnvironmentAtoms<R, E>( |
| 82 | 101 | JSON.stringify([environmentId, input.projectId]), |
| 83 | 102 | }; |
| 84 | 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 | 119 | searchEntries: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 86 | 120 | label: "environment-data:projects:search-entries", |
| 87 | 121 | tag: WS_METHODS.projectsSearchEntries, |
| 88 | 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 | 129 | listEntries: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 91 | 130 | label: "environment-data:projects:list-entries", |
| 92 | 131 | tag: WS_METHODS.projectsListEntries, |
| 93 | 132 | staleTimeMs: 30_000, |
| 94 | 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 | 140 | readFile: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 97 | 141 | label: "environment-data:projects:read-file", |
| 98 | 142 | tag: WS_METHODS.projectsReadFile, |
| 99 | 143 | staleTimeMs: 30_000, |
| 100 | 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 | 151 | optimisticFile: (target: OptimisticProjectFileTarget) => |
| 103 | 152 | optimisticFileFamily(optimisticProjectFileKey(target)), |
| ... | ... | @@ -163,6 +212,17 @@ export function createProjectEnvironmentAtoms<R, E>( |
| 163 | 212 | key: ({ environmentId, input }) => |
| 164 | 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 | 769 | readonly input: NoInfer<Input>; |
| 770 | 770 | }, |
| 771 | 771 | registry: AtomRegistry.AtomRegistry, |
| 772 | result: EnvironmentRpcSuccess<TTag>, | |
| 772 | 773 | ) => Effect.Effect<void, never, R>; |
| 773 | 774 | readonly onSettled?: ( |
| 774 | 775 | target: { |
| ... | ... | @@ -797,7 +798,7 @@ export function createEnvironmentRpcCommand< |
| 797 | 798 | authorize: (id, method, payload) => |
| 798 | 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 | 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 | 1 | import * as Schema from "effect/Schema"; |
| 2 | 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 | 16 | describe("FilesystemBrowseError", () => { |
| 7 | 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 | 1 | import * as Schema from "effect/Schema"; |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; | |
| 2 | import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; | |
| 3 | 3 | |
| 4 | 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 | 31 | export const FilesystemBrowseInput = Schema.Struct({ |
| 7 | 32 | partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)), |
| 8 | 33 | cwd: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))), |
packages/contracts/src/rpc.ts+10| ... | ... | @@ -77,6 +77,8 @@ import { |
| 77 | 77 | HostPowerSnapshot, |
| 78 | 78 | } from "./background.ts"; |
| 79 | 79 | import { |
| 80 | FilesystemGetMetadataInput, | |
| 81 | FilesystemGetMetadataResult, | |
| 80 | 82 | FilesystemBrowseInput, |
| 81 | 83 | FilesystemBrowseResult, |
| 82 | 84 | FilesystemBrowseError, |
| ... | ... | @@ -382,6 +384,7 @@ export const WS_METHODS = { |
| 382 | 384 | |
| 383 | 385 | // Filesystem methods |
| 384 | 386 | filesystemBrowse: "filesystem.browse", |
| 387 | filesystemGetMetadata: "filesystem.getMetadata", | |
| 385 | 388 | agentSessionsScan: "agentSessions.scan", |
| 386 | 389 | agentSessionsImport: "agentSessions.import", |
| 387 | 390 | assetsCreateUrl: "assets.createUrl", |
| ... | ... | @@ -1236,6 +1239,12 @@ const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { |
| 1236 | 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 | 1248 | const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { |
| 1240 | 1249 | payload: FilesystemBrowseInput, |
| 1241 | 1250 | success: FilesystemBrowseResult, |
| ... | ... | @@ -1947,6 +1956,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 1947 | 1956 | WsProjectsMutateRpc, |
| 1948 | 1957 | WsShellOpenInEditorRpc, |
| 1949 | 1958 | WsFilesystemBrowseRpc, |
| 1959 | WsFilesystemGetMetadataRpc, | |
| 1950 | 1960 | WsAgentSessionsScanRpc, |
| 1951 | 1961 | WsAgentSessionsImportRpc, |
| 1952 | 1962 | WsAssetsCreateUrlRpc, |
packages/shared/src/filePreview.ts+21| ... | ... | @@ -1,5 +1,26 @@ |
| 1 | 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 | 24 | export type FilePreviewKind = |
| 4 | 25 | | "image" |
| 5 | 26 | | "video" |
packages/shared/src/path.test.ts+13| ... | ... | @@ -9,9 +9,22 @@ import { |
| 9 | 9 | newProjectFolderName, |
| 10 | 10 | normalizeProjectPathForComparison, |
| 11 | 11 | normalizeProjectPathForDispatch, |
| 12 | resolveWorkspaceFilePath, | |
| 12 | 13 | } from "./path.ts"; |
| 13 | 14 | |
| 14 | 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 | 28 | it("detects windows drive paths", () => { |
| 16 | 29 | expect(isWindowsDrivePath("C:\\repo")).toBe(true); |
| 17 | 30 | expect(isWindowsDrivePath("D:/repo")).toBe(true); |
packages/shared/src/path.ts+10| ... | ... | @@ -111,3 +111,13 @@ export function workspaceRelativeFilePath( |
| 111 | 111 | export function isAbsolutePath(value: string): boolean { |
| 112 | 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 | } |