From 0442f5bfcca6eca556ee79d41f4addb7a2022188 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 10 Oct 2026 17:32:29 -0500 Subject: [PATCH] fix(web): use server metadata for file chip icons (#17923) --- apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/observability/RpcInstrumentation.ts | 1 + .../src/workspace/WorkspaceFileSystem.test.ts | 92 ++++ .../src/workspace/WorkspaceFileSystem.ts | 57 ++- .../src/workspace/fileHeaderMimeType.test.ts | 30 ++ .../src/workspace/fileHeaderMimeType.ts | 22 + apps/server/src/ws.ts | 1 + .../components/ChatMarkdown.assets.test.tsx | 1 + .../ChatMarkdown.permissions.test.tsx | 1 + apps/web/src/components/ChatMarkdown.test.tsx | 1 + apps/web/src/components/ChatMarkdown.tsx | 22 +- apps/web/src/components/ChatView.tsx | 12 +- .../components/ComposerPromptEditorTiptap.tsx | 11 +- apps/web/src/components/chat/ChatComposer.tsx | 11 +- apps/web/src/components/chat/FileTagChip.tsx | 13 +- .../src/components/chat/MessagesTimeline.tsx | 17 +- .../src/components/chat/PierreEntryIcon.tsx | 5 +- .../components/chat/WorkspaceEntryIcon.tsx | 41 ++ .../composerContextPresentation.tsx | 1 + .../src/hooks/FileMetadataThreadProvider.tsx | 64 +++ apps/web/src/hooks/useFileMetadata.ts | 34 ++ apps/web/src/pierre-icons.test.ts | 16 + apps/web/src/pierre-icons.ts | 16 +- .../src/state/fileMetadata.test.ts | 458 ++++++++++++++++++ .../client-runtime/src/state/fileMetadata.ts | 338 +++++++++++++ .../src/state/projectCommands.test.ts | 174 ++++++- .../src/state/projectCommands.ts | 62 ++- packages/client-runtime/src/state/runtime.ts | 3 +- packages/contracts/src/filesystem.test.ts | 12 +- packages/contracts/src/filesystem.ts | 27 +- packages/contracts/src/rpc.ts | 10 + packages/shared/src/filePreview.ts | 21 + packages/shared/src/path.test.ts | 13 + packages/shared/src/path.ts | 10 + 34 files changed, 1565 insertions(+), 33 deletions(-) create mode 100644 apps/server/src/workspace/fileHeaderMimeType.test.ts create mode 100644 apps/server/src/workspace/fileHeaderMimeType.ts create mode 100644 apps/web/src/components/chat/WorkspaceEntryIcon.tsx create mode 100644 apps/web/src/hooks/FileMetadataThreadProvider.tsx create mode 100644 apps/web/src/hooks/useFileMetadata.ts create mode 100644 packages/client-runtime/src/state/fileMetadata.test.ts create mode 100644 packages/client-runtime/src/state/fileMetadata.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 9c6243fde2300b8ab9599fe563da3810da17aa07..405c9eee2eea9dd2a62cecb43683ae5bec5c67e5 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -154,6 +154,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsCreateNew]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthFilesystemReadScope, + [WS_METHODS.filesystemGetMetadata]: AuthFilesystemReadScope, [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 26a4b007b8b66163bf41abb8a0918f9cf5969876..4fe5ffa6a5650e98dd6e6b09441fb9efab6d4bc4 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -140,6 +140,7 @@ const RPC_AGGREGATES = { [WS_METHODS.projectsCreateNew]: "orchestration", [WS_METHODS.shellOpenInEditor]: "workspace", [WS_METHODS.filesystemBrowse]: "workspace", + [WS_METHODS.filesystemGetMetadata]: "workspace", [WS_METHODS.agentSessionsScan]: "workspace", [WS_METHODS.agentSessionsImport]: "workspace", [WS_METHODS.assetsCreateUrl]: "workspace", diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 885b0eabb4adacee390f4775949af95a51bf9bb1..3e32e5537692e989aba88965a6ecebc8535f7ba9 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -57,6 +57,95 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( }); it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { + describe("getMetadata", () => { + it.effect("expands home-relative paths on the server", () => + Effect.gen(function* () { + const service = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const home = yield* makeTempDir; + yield* writeTextFile(home, "file", "hello"); + expect( + (yield* service + .getMetadata({ paths: ["~/file"] }) + .pipe(Effect.provideService(HostProcess.HomeDirectory, home))).entries, + ).toEqual([{ kind: "file", byteLength: 5 }]); + }), + ); + + it.effect( + "reports actual kinds for extensionless files, dotfiles, and dotted directories", + () => + Effect.gen(function* () { + const service = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "custom-name", "hello"); + yield* writeTextFile(cwd, ".npmrc", "registry=test"); + yield* fs.makeDirectory(path.join(cwd, "folder.ts")); + const file = path.join(cwd, "custom-name"); + const result = yield* service.getMetadata({ + paths: [ + file, + path.join(cwd, ".npmrc"), + path.join(cwd, "folder.ts"), + file, + path.join(cwd, "missing"), + "relative-path", + ], + }); + expect(result.entries).toEqual([ + { kind: "file", byteLength: 5 }, + { kind: "file", byteLength: 13 }, + { kind: "directory" }, + { kind: "file", byteLength: 5 }, + null, + null, + ]); + }), + ); + + it.effect("detects an extensionless script and image without returning their contents", () => + Effect.gen(function* () { + const service = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const script = "#!/usr/bin/env python3\nprint('hello')\n"; + yield* writeTextFile(cwd, "run", script); + const image = path.join(cwd, "image"); + yield* fs.writeFile( + image, + new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + ); + expect( + (yield* service.getMetadata({ paths: [path.join(cwd, "run"), image] })).entries, + ).toEqual([ + { kind: "file", byteLength: script.length, mimeType: "text/x-python" }, + { kind: "file", byteLength: 8, mimeType: "image/png" }, + ]); + }), + ); + + it.effect.skipIf(!symlinksSupported)("follows file and directory symlinks", () => + Effect.gen(function* () { + const service = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "target", "text"); + yield* fs.makeDirectory(path.join(cwd, "directory")); + const fileLink = path.join(cwd, "file-link"); + const directoryLink = path.join(cwd, "directory-link"); + yield* fs.symlink(path.join(cwd, "target"), fileLink); + yield* fs.symlink(path.join(cwd, "directory"), directoryLink); + expect((yield* service.getMetadata({ paths: [fileLink, directoryLink] })).entries).toEqual([ + { kind: "file", byteLength: 4 }, + { kind: "directory" }, + ]); + }), + ); + }); + describe("readFile", () => { it.effect("reads UTF-8 files relative to the workspace root", () => Effect.gen(function* () { @@ -125,6 +214,9 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i .pipe(Effect.flip); expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError); + expect((yield* workspaceFileSystem.getMetadata({ paths: [fifoPath] })).entries).toEqual([ + { kind: "other" }, + ]); }), ); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 9ef3d4546d1db29db4f22d55fbf78d615b5b3913..335b18c791b06e8f7f25e1994a51d7504f2cb52d 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -13,11 +13,16 @@ import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import type { + FilesystemEntryMetadata, + FilesystemGetMetadataInput, + FilesystemGetMetadataResult, ProjectReadFileInput, ProjectReadFileResult, ProjectWriteFileInput, ProjectWriteFileResult, } from "@t3tools/contracts"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -27,6 +32,7 @@ import * as Schema from "effect/Schema"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; +import { fileHeaderMimeType } from "./fileHeaderMimeType.ts"; const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; @@ -107,6 +113,10 @@ export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; export class WorkspaceFileSystem extends Context.Service< WorkspaceFileSystem, { + readonly getMetadata: ( + input: FilesystemGetMetadataInput, + ) => Effect.Effect; + /** * Read a UTF-8 text file relative to the workspace root, or any host file by * absolute path. @@ -139,6 +149,51 @@ export const make = Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const metadataForPath = Effect.fnUntraced(function* (requestedPath: string) { + const expandedPath = expandHomePath(requestedPath, yield* HostProcess.HomeDirectory); + if (!path.isAbsolute(expandedPath)) return null; + const stat = yield* fileSystem.stat(expandedPath).pipe(Effect.option); + if (stat._tag === "None") return null; + if (stat.value.type === "Directory") return { kind: "directory" } as const; + if (stat.value.type !== "File") return { kind: "other" } as const; + let mimeType: string | undefined; + // Named extensions already choose the icon without a content read. For an + // extensionless regular file, read at most 512 bytes, never the whole file. + if (path.extname(expandedPath) === "" && stat.value.size > 0) { + mimeType = yield* Effect.tryPromise({ + try: async () => { + const handle = await NodeFSP.open( + expandedPath, + NodeFS.constants.O_RDONLY | NodeFS.constants.O_NONBLOCK, + ); + try { + if (!(await handle.stat()).isFile()) return undefined; + const buffer = Buffer.alloc(512); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + return fileHeaderMimeType(buffer.subarray(0, bytesRead)); + } finally { + await handle.close(); + } + }, + catch: () => undefined, + }).pipe(Effect.catch(() => Effect.succeed(undefined))); + } + return { + kind: "file", + byteLength: Number(stat.value.size), + ...(mimeType === undefined ? {} : { mimeType }), + } satisfies FilesystemEntryMetadata; + }); + + const getMetadata: WorkspaceFileSystem["Service"]["getMetadata"] = Effect.fn( + "WorkspaceFileSystem.getMetadata", + )(function* (input) { + const paths = [...new Set(input.paths)]; + const entries = yield* Effect.forEach(paths, metadataForPath, { concurrency: 8 }); + const byPath = new Map(paths.map((value, index) => [value, entries[index] ?? null])); + return { entries: input.paths.map((value) => byPath.get(value) ?? null) }; + }); + /** * Resolves the file a read targets. Workspace-relative paths must stay inside the * 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* () { return { relativePath: target.relativePath }; }); - return WorkspaceFileSystem.of({ readFile, writeFile }); + return WorkspaceFileSystem.of({ getMetadata, readFile, writeFile }); }); export const layer = Layer.effect(WorkspaceFileSystem, make); diff --git a/apps/server/src/workspace/fileHeaderMimeType.test.ts b/apps/server/src/workspace/fileHeaderMimeType.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9fe13cfd01d74b2c4b1484c21f30f792738f036b --- /dev/null +++ b/apps/server/src/workspace/fileHeaderMimeType.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vite-plus/test"; +import { fileHeaderMimeType } from "./fileHeaderMimeType.ts"; + +describe("fileHeaderMimeType", () => { + it.each([ + [[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], "image/png"], + [[0xff, 0xd8, 0xff], "image/jpeg"], + [[0x50, 0x4b, 0x03, 0x04], "application/zip"], + [[0x52, 0x49, 0x46, 0x46, 0xc2, 0xa0, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50], "image/webp"], + [[0x89, 0x50, 0x4e], undefined], + ])("classifies binary header %j", (header, expected) => { + expect(fileHeaderMimeType(new Uint8Array(header))).toBe(expected); + }); + + it.each([ + ["%PDF-1.7", "application/pdf"], + ["GIF89a", "image/gif"], + ["SQLite format 3\0", "application/vnd.sqlite3"], + ["RIFF1234WEBP", "image/webp"], + ["#!/usr/bin/env python3\n", "text/x-python"], + ["#!/usr/bin/node\n", "text/javascript"], + ["#!/bin/bash\n", "text/x-shellscript"], + ["#!/usr/bin/env ruby\n", "text/x-ruby"], + ["hello", undefined], + ["", undefined], + ["#!/usr/bin/env custom\n", undefined], + ])("classifies %j", (header, expected) => { + expect(fileHeaderMimeType(new TextEncoder().encode(header))).toBe(expected); + }); +}); diff --git a/apps/server/src/workspace/fileHeaderMimeType.ts b/apps/server/src/workspace/fileHeaderMimeType.ts new file mode 100644 index 0000000000000000000000000000000000000000..07355b0f7efc651d7539e5195cc4b2a0df5cfc97 --- /dev/null +++ b/apps/server/src/workspace/fileHeaderMimeType.ts @@ -0,0 +1,22 @@ +/** Formats whose headers give extensionless files a useful icon. This is a + * presentation hint, never a validation of a file's contents or safety. */ +export function fileHeaderMimeType(bytes: Uint8Array): string | undefined { + const startsWith = (signature: readonly number[], offset = 0) => + signature.every((byte, index) => bytes[offset + index] === byte); + if (startsWith([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"; + if (startsWith([0xff, 0xd8, 0xff])) return "image/jpeg"; + if (startsWith([0x50, 0x4b, 0x03, 0x04])) return "application/zip"; + if (startsWith([0x52, 0x49, 0x46, 0x46]) && startsWith([0x57, 0x45, 0x42, 0x50], 8)) + return "image/webp"; + const header = new TextDecoder().decode(bytes); + if (header.startsWith("GIF87a") || header.startsWith("GIF89a")) return "image/gif"; + if (header.startsWith("%PDF-")) return "application/pdf"; + if (header.startsWith("SQLite format 3\0")) return "application/vnd.sqlite3"; + if (!header.startsWith("#!")) return undefined; + const interpreter = header.split("\n", 1)[0] ?? ""; + if (/\bpython[\d.]*\b/.test(interpreter)) return "text/x-python"; + if (/\b(?:node|nodejs)\b/.test(interpreter)) return "text/javascript"; + if (/\bruby\b/.test(interpreter)) return "text/x-ruby"; + if (/\b(?:sh|bash|zsh|fish)\b/.test(interpreter)) return "text/x-shellscript"; + return undefined; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c1a01b963a508deb760a32649f8018340a4510fd..6deb654772f24cd5b23e580a0130b6b45cebf593 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2681,6 +2681,7 @@ const layerWsRpc = ( ), ), [WS_METHODS.shellOpenInEditor]: (input) => externalLauncher.launchEditor(input), + [WS_METHODS.filesystemGetMetadata]: (input) => workspaceFileSystem.getMetadata(input), [WS_METHODS.filesystemBrowse]: (input) => workspaceEntries.browse(input).pipe( Effect.mapError( diff --git a/apps/web/src/components/ChatMarkdown.assets.test.tsx b/apps/web/src/components/ChatMarkdown.assets.test.tsx index b5e219b88d267c20fd63ee5d69e187196cd80fe5..29c90767979992d7eb3fe5029b1deafde1898951 100644 --- a/apps/web/src/components/ChatMarkdown.assets.test.tsx +++ b/apps/web/src/components/ChatMarkdown.assets.test.tsx @@ -7,6 +7,7 @@ import { expect, it, vi } from "vite-plus/test"; const mint = vi.hoisted(() => vi.fn()); vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => undefined })); vi.mock("../hooks/useSettings", async (importOriginal) => { const actual = await importOriginal(); const settings = actual.getClientSettings(); diff --git a/apps/web/src/components/ChatMarkdown.permissions.test.tsx b/apps/web/src/components/ChatMarkdown.permissions.test.tsx index 750d013e0ceee6f60b2a12e527ff601792ee6fed..25542d54a73d64d9b65687dcbc1d1538c041ed29 100644 --- a/apps/web/src/components/ChatMarkdown.permissions.test.tsx +++ b/apps/web/src/components/ChatMarkdown.permissions.test.tsx @@ -29,6 +29,7 @@ const state = vi.hoisted(() => ({ vi.mock("@effect/atom-react", () => ({ useAtomValue: () => serverConfig })); vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => undefined })); vi.mock("../hooks/useSettings", () => ({ getClientSettings: () => DEFAULT_CLIENT_SETTINGS, useClientSettings: (select?: (value: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index b62431a083832de4c27780980ab5f1b0c1e9e522..d55986a9589e2edf40fd16cd2b7738882c655e03 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -17,6 +17,7 @@ import { GitHubIcon } from "./Icons"; import { Button } from "./ui/button"; import { setMarkdownTaskChecked } from "./files/filePreviewMode"; +vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null })); vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); vi.mock("./chat/MermaidDiagram", () => ({ // Real Mermaid needs layout APIs jsdom lacks; a rendered diagram is an SVG. diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c0225713ec4cbaefd68c6a0b2286098bcf8ce5d3..a7a2b0c4eba2f99b393d2db9665dc4c8a6c8f19c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -121,6 +121,7 @@ import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGa import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; import { MediaActions, type MediaActionSource } from "./media/MediaActions"; import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; +import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; import { FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { @@ -1192,6 +1193,7 @@ function UncachedShikiCodeBlock({ } interface MarkdownFileLinkProps { + environmentId: EnvironmentId | null; href: string; targetPath: string; iconPath: string; @@ -1929,6 +1931,7 @@ function MarkdownExternalLinkContent({ } const MarkdownFileLink = memo(function MarkdownFileLink({ + environmentId, href, targetPath, iconPath, @@ -2235,7 +2238,12 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }} onContextMenu={handleContextMenu} > - + ) : ( - + ) } @@ -2257,7 +2270,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ {/* The full path: the chip already shows the shortened form, and a link to the workspace root collapses to a bare label that repeats it. */}
- {targetPath} +
@@ -2272,6 +2285,7 @@ function areMarkdownFileLinkPropsEqual( previous.href === next.href && previous.targetPath === next.targetPath && previous.iconPath === next.iconPath && + previous.environmentId === next.environmentId && previous.displayPath === next.displayPath && previous.panelPath === next.panelPath && previous.line === next.line && @@ -2642,6 +2656,7 @@ function useChatMarkdownState({ return ( composerRef.current?.addDroppedFolders(folders), }); - return ( + const content = (
); + return ( + + {content} + + ); } /** Keeps the thread's preview tabs synced while no browser panel is mounted. */ diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 5d5c87f53881caeedcbb77daa385c207b4d76fb7..6dc46753a3a15c7a9072311648c7fbc217c0f526 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -91,6 +91,7 @@ import { collectInlineContextIds } from "~/lib/composerContextReferences"; import { resolveDiffThemeName } from "~/lib/diffRendering"; import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; +import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; import { FileTagChipContent } from "./chat/FileTagChip"; import { SkillChipIcon } from "./chat/SkillInlineText"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; @@ -273,6 +274,8 @@ function ComposerMentionNodeView({ node }: NodeViewProps) { > @@ -282,7 +285,13 @@ function ComposerMentionNodeView({ node }: NodeViewProps) { - {path} + + + ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c9be1f46442df34d75f376c76f84af28b0aa64f7..bb10c0aed844fdb0770b369fbb3a2809486c020f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1917,6 +1917,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerContextActions = useMemo( () => ({ environmentId, + cwd: gitCwd ?? undefined, expandImage: (imageId: string) => { const preview = buildExpandedImagePreview(composerImages, imageId); if (preview) onExpandImage(preview); @@ -1947,7 +1948,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) openPrLink(event, url); }, }), - [composerFiles, composerImages, environmentId, onExpandImage, openPrLink, routeThreadRef], + [ + composerFiles, + composerImages, + gitCwd, + environmentId, + onExpandImage, + openPrLink, + routeThreadRef, + ], ); const composerContextRecords = useMemo( () => diff --git a/apps/web/src/components/chat/FileTagChip.tsx b/apps/web/src/components/chat/FileTagChip.tsx index 5dbfde8affe46b57d2f515b7691293fc6a4e9ae2..b6235dde889139524ae22e33c6cb7c729d189c8d 100644 --- a/apps/web/src/components/chat/FileTagChip.tsx +++ b/apps/web/src/components/chat/FileTagChip.tsx @@ -1,18 +1,21 @@ -import { inferEntryKindFromPath } from "../../pierre-icons"; +import type { EnvironmentId } from "@t3tools/contracts"; import { ContextChipLabel } from "../ContextChip"; -import { PierreEntryIcon } from "./PierreEntryIcon"; +import { WorkspaceEntryIcon } from "./WorkspaceEntryIcon"; /** Icon and label for a file mention; render inside ``. */ export function FileTagChipContent(props: { path: string; label: string; theme: "light" | "dark"; + environmentId?: EnvironmentId | null | undefined; + cwd?: string | undefined; }) { return ( <> - {props.label} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 052ebedd9a249745a4a994e7db65edb950905195..165dbdaea146c1d91fe0a6da1cf0efc0e7e43362 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -187,7 +187,7 @@ import { } from "./timelineScrollAnchoring"; import { MessageCopyButton } from "./MessageCopyButton"; import { PierreEntryIcon } from "./PierreEntryIcon"; -import { inferEntryKindFromPath } from "../../pierre-icons"; +import { WorkspaceEntryIcon, WorkspaceEntryTooltip } from "./WorkspaceEntryIcon"; import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { @@ -4264,16 +4264,23 @@ function UserMessageMentionChip(props: { useRightPanelStore.getState().openFile(ctx.threadRef, props.record.path); }} > - {props.record.label} } /> - {props.record.path} + + + ); } diff --git a/apps/web/src/components/chat/PierreEntryIcon.tsx b/apps/web/src/components/chat/PierreEntryIcon.tsx index df41adb7dd53dcf5918a46dc68737b350c305f93..97a0a590c37902f8c24e4ec5baef33c9aaf96f7b 100644 --- a/apps/web/src/components/chat/PierreEntryIcon.tsx +++ b/apps/web/src/components/chat/PierreEntryIcon.tsx @@ -62,13 +62,14 @@ const ICON_COLORS: Record = { export const PierreEntryIcon = memo(function PierreEntryIcon(props: { pathValue: string; kind: "file" | "directory"; + mimeType?: string | undefined; theme: "light" | "dark"; className?: string; }) { useInsertionEffect(ensurePierreIconSprite, []); const icon = useMemo( - () => resolvePierreIconForEntry(props.pathValue, props.kind), - [props.kind, props.pathValue], + () => resolvePierreIconForEntry(props.pathValue, props.kind, props.mimeType), + [props.kind, props.pathValue, props.mimeType], ); if (!icon) { diff --git a/apps/web/src/components/chat/WorkspaceEntryIcon.tsx b/apps/web/src/components/chat/WorkspaceEntryIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..28090dcdce94f47a9f3484d0bcac0f9c213f3ff6 --- /dev/null +++ b/apps/web/src/components/chat/WorkspaceEntryIcon.tsx @@ -0,0 +1,41 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; + +import { useFileMetadata } from "../../hooks/useFileMetadata"; +import { PierreEntryIcon } from "./PierreEntryIcon"; + +/** Path chips share metadata queries across messages and the composer. A file + * icon is neutral while the environment is offline or the path is unknown. */ +export function WorkspaceEntryIcon(props: { + path: string; + environmentId?: EnvironmentId | null | undefined; + cwd?: string | undefined; + theme: "light" | "dark"; +}) { + const metadata = useFileMetadata(props.environmentId, props.path, props.cwd); + return ( + + ); +} + +export function WorkspaceEntryTooltip(props: { + path: string; + label?: string; + environmentId?: EnvironmentId | null | undefined; + cwd?: string | undefined; +}) { + const metadata = useFileMetadata(props.environmentId, props.path, props.cwd); + return ( + <> + {props.label ?? props.path} + {metadata?.byteLength === undefined + ? null + : ` ยท ${formatAttachmentSize(metadata.byteLength)}`} + + ); +} diff --git a/apps/web/src/components/composerContextPresentation.tsx b/apps/web/src/components/composerContextPresentation.tsx index e8e493be73c67b5f52c611412de9879a54c4e7cf..1d620ed05d4ad3cf458322ae42fee8fbd6450599 100644 --- a/apps/web/src/components/composerContextPresentation.tsx +++ b/apps/web/src/components/composerContextPresentation.tsx @@ -68,6 +68,7 @@ export type ComposerDraftContextRecord = /** What a chip can do beyond showing itself; the composer supplies the handlers. */ export interface ComposerContextActions { environmentId: EnvironmentId | null; + cwd?: string | undefined; expandImage: (imageId: string) => void; expandVideo: (fileId: string) => void; openFile: (fileId: string) => void; diff --git a/apps/web/src/hooks/FileMetadataThreadProvider.tsx b/apps/web/src/hooks/FileMetadataThreadProvider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ee186250888402a05117a5900ec66556375d29bf --- /dev/null +++ b/apps/web/src/hooks/FileMetadataThreadProvider.tsx @@ -0,0 +1,64 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { OrchestrationV2ThreadProjection, ScopedThreadRef } from "@t3tools/contracts"; +import { resolveWorkspaceFilePath } from "@t3tools/shared/path"; +import { Atom } from "effect/reactivity"; +import { createContext, useEffect, useRef, type ReactNode } from "react"; + +import { projectEnvironment } from "../state/projects"; +import { useAtomCommand } from "../state/use-atom-command"; + +export const FileMetadataThreadContext = createContext(null); +const EMPTY_RETENTION = Atom.make(undefined); + +/** The chat view owns this lifetime, so virtualized message rows can unmount + * without losing the metadata they already fetched. */ +export function FileMetadataThreadProvider(props: { + threadRef: ScopedThreadRef | null; + cwd: string | undefined; + checkpoints: OrchestrationV2ThreadProjection["checkpoints"] | undefined; + children?: ReactNode; +}) { + const { threadRef, cwd, checkpoints, children } = props; + useAtomValue(threadRef ? projectEnvironment.fileMetadataThread(threadRef) : EMPTY_RETENTION); + const invalidate = useAtomCommand(projectEnvironment.invalidateFileMetadata, { + reportFailure: false, + }); + const previous = useRef<{ + environmentId: ScopedThreadRef["environmentId"]; + threadId: ScopedThreadRef["threadId"]; + cwd: string; + checkpoints: OrchestrationV2ThreadProjection["checkpoints"]; + } | null>(null); + + useEffect(() => { + if (!threadRef || !cwd || !checkpoints) { + previous.current = null; + return; + } + const before = previous.current; + previous.current = { ...threadRef, cwd, checkpoints }; + if ( + !before || + before.environmentId !== threadRef.environmentId || + before.threadId !== threadRef.threadId || + before.cwd !== cwd + ) + return; + const oldById = new Map(before.checkpoints.map((checkpoint) => [checkpoint.id, checkpoint])); + const paths = new Set(); + for (const checkpoint of checkpoints) { + const old = oldById.get(checkpoint.id); + oldById.delete(checkpoint.id); + if (old?.files === checkpoint.files && old?.status === checkpoint.status) continue; + for (const file of checkpoint.files) paths.add(resolveWorkspaceFilePath(file.path, cwd)); + for (const file of old?.files ?? []) paths.add(resolveWorkspaceFilePath(file.path, cwd)); + } + // Rewinds can remove checkpoints as well as add them. + for (const checkpoint of oldById.values()) + for (const file of checkpoint.files) paths.add(resolveWorkspaceFilePath(file.path, cwd)); + if (paths.size > 0) + void invalidate({ environmentId: threadRef.environmentId, input: { paths: [...paths] } }); + }, [threadRef, cwd, checkpoints, invalidate]); + + return {children}; +} diff --git a/apps/web/src/hooks/useFileMetadata.ts b/apps/web/src/hooks/useFileMetadata.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc5940410ce3bba7c81f2303bf4129d1fcf0ac40 --- /dev/null +++ b/apps/web/src/hooks/useFileMetadata.ts @@ -0,0 +1,34 @@ +import { AuthFilesystemReadScope, type EnvironmentId } from "@t3tools/contracts"; +import { resolvePathLinkTarget, splitFilePathPosition } from "@t3tools/shared/fileLinks"; +import { normalizeProjectPathForComparison, isAbsolutePath } from "@t3tools/shared/path"; +import { useContext } from "react"; + +import { useEnvironmentScope } from "../state/session"; +import { projectEnvironment } from "../state/projects"; +import { useEnvironmentQuery } from "../state/query"; +import { FileMetadataThreadContext } from "./FileMetadataThreadProvider"; + +export function useFileMetadata( + environmentId: EnvironmentId | null | undefined, + path: string, + cwd?: string, +) { + const canRead = useEnvironmentScope(environmentId ?? null, AuthFilesystemReadScope); + const threadRef = useContext(FileMetadataThreadContext); + const hostPath = splitFilePathPosition(path).path; + const targetPath = normalizeProjectPathForComparison( + hostPath.startsWith("~/") || !cwd ? hostPath : resolvePathLinkTarget(hostPath, cwd), + ); + const absolute = isAbsolutePath(targetPath) || targetPath.startsWith("~/"); + return useEnvironmentQuery( + environmentId && canRead && absolute && targetPath.length <= 512 + ? projectEnvironment.fileMetadata({ + environmentId, + input: { + path: targetPath, + ...(threadRef?.environmentId === environmentId ? { threadId: threadRef.threadId } : {}), + }, + }) + : null, + ).data; +} diff --git a/apps/web/src/pierre-icons.test.ts b/apps/web/src/pierre-icons.test.ts index c90b130b33eae6ddd4740c0abec4b2674cf39916..9910f361ca877ddf3501c6f69b7155556cc5373f 100644 --- a/apps/web/src/pierre-icons.test.ts +++ b/apps/web/src/pierre-icons.test.ts @@ -8,6 +8,22 @@ import { } from "./pierre-icons"; describe("Pierre file icons", () => { + it("uses detected types for extensionless files while keeping known filename icons", () => { + assert.equal( + resolvePierreIconForEntry("custom-script", "file", "text/x-python")?.token, + "python", + ); + assert.equal(resolvePierreIconForEntry("image", "file", "image/png")?.token, "image"); + assert.equal( + resolvePierreIconForEntry("Dockerfile", "file", "text/x-shellscript")?.token, + "docker", + ); + assert.isNull(resolvePierreIconForEntry("folder.png", "directory", "image/png")); + assert.equal( + resolvePierreIconForEntry("unknown", "file", "application/octet-stream")?.token, + "default", + ); + }); it("uses Pierre exact filename and complete-set extension mappings", () => { assert.equal(resolvePierreIconForEntry("Dockerfile", "file")?.token, "docker"); assert.equal(resolvePierreIconForEntry("src/Button.tsx", "file")?.token, "react"); diff --git a/apps/web/src/pierre-icons.ts b/apps/web/src/pierre-icons.ts index d789bd6dfcd449a9c2ec363b7f294467d3ed40ec..3bd2570a665635333ebe8dd6cdafbdf42c83dad8 100644 --- a/apps/web/src/pierre-icons.ts +++ b/apps/web/src/pierre-icons.ts @@ -4,6 +4,7 @@ import { type FileTreeIcons, } from "@pierre/trees"; import { VIDEO_FILE_EXTENSIONS } from "@t3tools/shared/video"; +import { fileIconPathForMimeType } from "@t3tools/shared/filePreview"; export interface PierreIconResolution { name: string; @@ -149,12 +150,6 @@ export function basenameOfPath(pathValue: string): string { return slashIndex === -1 ? pathValue : pathValue.slice(slashIndex + 1); } -export function inferEntryKindFromPath(pathValue: string): "file" | "directory" { - const base = basenameOfPath(pathValue); - if (base.startsWith(".") && !base.slice(1).includes(".")) return "directory"; - return base.includes(".") ? "file" : "directory"; -} - /** Languages whose files are recognised by name rather than by extension. */ const LANGUAGE_FILE_NAMES: Record = { dockerfile: "Dockerfile", @@ -171,9 +166,16 @@ export function syntheticFileNameForLanguageId(languageId: string): string { export function resolvePierreIconForEntry( pathValue: string, kind: "file" | "directory", + mimeType?: string, ): PierreIconResolution | null { if (kind === "directory") return null; - return completeIconResolver.resolveIcon("file-tree-icon-file", pathValue); + const icon = completeIconResolver.resolveIcon("file-tree-icon-file", pathValue); + return icon?.token === "default" && mimeType + ? completeIconResolver.resolveIcon( + "file-tree-icon-file", + fileIconPathForMimeType(pathValue, mimeType), + ) + : icon; } export function hasSpecificPierreIconForFileName(fileName: string): boolean { diff --git a/packages/client-runtime/src/state/fileMetadata.test.ts b/packages/client-runtime/src/state/fileMetadata.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..43de5d79f4a667edffe187d6001859a422a4a471 --- /dev/null +++ b/packages/client-runtime/src/state/fileMetadata.test.ts @@ -0,0 +1,458 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + EnvironmentId, + ThreadId, + AuthFilesystemReadScope, + EnvironmentAuthorizationError, + type FilesystemEntryMetadata, + type FilesystemGetMetadataInput, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as Schema from "effect/Schema"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; +import { Atom, AtomRegistry } from "effect/reactivity"; + +import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { createFileMetadataAtoms } from "./fileMetadata.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("metadata-environment"); +const OTHER_ENVIRONMENT_ID = EnvironmentId.make("metadata-other-environment"); + +const makeHarness = Effect.fnUntraced(function* () { + const batches: string[][] = []; + const values = new Map(); + const failure = { denied: false }; + const response = { wait: Effect.void as Effect.Effect }; + const makeSession = () => + ({ + client: { + [WS_METHODS.filesystemGetMetadata]: (input: FilesystemGetMetadataInput) => + Effect.gen(function* () { + batches.push([...input.paths]); + if (failure.denied) + return yield* new EnvironmentAuthorizationError({ + requiredScope: AuthFilesystemReadScope, + message: "File access is not granted.", + }); + const entries = input.paths.map((path) => values.get(path) ?? null); + yield* response.wait; + return { entries }; + }), + }, + }) as unknown as RpcSession; + const session = yield* SubscriptionRef.make(Option.some(makeSession())); + const state = yield* SubscriptionRef.make({ + ...AVAILABLE_CONNECTION_STATE, + phase: "connected" as const, + }); + const supervisor = { + target: { environmentId: ENVIRONMENT_ID }, + session, + state, + } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]; + const otherSupervisor = { + ...supervisor, + target: { environmentId: OTHER_ENVIRONMENT_ID }, + session: yield* SubscriptionRef.make(Option.some(makeSession())), + } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]; + const getSupervisor = (environmentId: EnvironmentId) => + environmentId === OTHER_ENVIRONMENT_ID ? otherSupervisor : supervisor; + const environmentRegistry = { + run: (environmentId, effect) => + Effect.provideService( + effect, + EnvironmentSupervisor.EnvironmentSupervisor, + getSupervisor(environmentId), + ), + followStream: (environmentId, stream) => + Stream.provideService( + stream, + EnvironmentSupervisor.EnvironmentSupervisor, + getSupervisor(environmentId), + ), + } as EnvironmentRegistry.EnvironmentRegistry["Service"]; + const clock = yield* Clock.Clock; + const runtime = Atom.runtime( + Layer.merge( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Layer.succeed(Clock.Clock, clock), + ), + ); + const atoms = createFileMetadataAtoms(runtime); + const registry = AtomRegistry.make(); + yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + const atom = (path: string, environmentId = ENVIRONMENT_ID, threadId?: ThreadId) => + atoms.metadata({ environmentId, input: { path, ...(threadId ? { threadId } : {}) } }); + const read = (path: string, environmentId = ENVIRONMENT_ID, threadId?: ThreadId) => + AtomRegistry.getResult(registry, atom(path, environmentId, threadId), { + suspendOnWaiting: true, + }).pipe(Effect.tap(() => Effect.yieldNow)); + const seed = (path: string, kind: "file" | "directory") => + atoms + .rememberEntries("/workspace", [{ path, kind }]) + .pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + return { + batches, + values, + registry, + atom, + read, + seed, + session, + makeSession, + atoms, + supervisor, + failure, + response, + }; +}); + +describe("file metadata", () => { + it.effect("does not seed a different path from a literal name with a numeric suffix", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.seed("report:123", "directory"); + h.values.set("/workspace/report", { kind: "file", byteLength: 5 }); + expect(yield* h.read("/workspace/report")).toEqual({ kind: "file", byteLength: 5 }); + expect(yield* h.read("/workspace/report:123")).toEqual({ kind: "directory" }); + yield* h.atoms + .rememberFile("/workspace", { + relativePath: "/workspace/read:12:3", + byteLength: 100, + }) + .pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, h.supervisor)); + expect(yield* h.read("/workspace/read:12:3")).toEqual({ kind: "file", byteLength: 100 }); + expect(yield* h.read("/workspace/read")).toBeNull(); + expect(h.batches).toEqual([["/workspace/report"], ["/workspace/read"]]); + }).pipe(Effect.scoped), + ); + + it.effect("retries only paths invalidated while their response is in flight", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const threadId = ThreadId.make("in-flight-thread"); + h.registry.mount(h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId })); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + h.response.wait = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ); + h.values.set("/workspace/file", { kind: "file", byteLength: 5 }); + h.values.set("/workspace/unchanged", { kind: "directory" }); + const read = yield* Effect.all( + [h.read("/workspace/file", ENVIRONMENT_ID, threadId), h.read("/workspace/unchanged")], + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* h.atoms + .invalidate("/workspace/file") + .pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, h.supervisor)); + h.values.set("/workspace/file", { kind: "file", byteLength: 10 }); + h.response.wait = Effect.void; + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(read)).toEqual([ + { kind: "file", byteLength: 10 }, + { kind: "directory" }, + ]); + expect(h.batches).toEqual([["/workspace/file", "/workspace/unchanged"], ["/workspace/file"]]); + h.registry.refresh(h.atom("/workspace/file", ENVIRONMENT_ID, threadId)); + expect(yield* h.read("/workspace/file", ENVIRONMENT_ID, threadId)).toEqual({ + kind: "file", + byteLength: 10, + }); + expect(h.batches).toHaveLength(2); + }).pipe(Effect.scoped), + ); + + it.effect("does not reuse expired metadata retained by an unrelated thread", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const threadId = ThreadId.make("unrelated-thread"); + h.registry.mount(h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId })); + h.values.set("/workspace/file", { kind: "file", byteLength: 5 }); + yield* h.read("/workspace/file", ENVIRONMENT_ID, threadId); + yield* TestClock.adjust("1 hour"); + h.values.set("/workspace/file", { kind: "directory" }); + expect(yield* h.read("/workspace/file")).toEqual({ kind: "directory" }); + expect(h.batches).toHaveLength(2); + }).pipe(Effect.scoped), + ); + + it.effect("uses a file listing kind without requesting optional metadata", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.seed("run", "file"); + expect(yield* h.read("/workspace/run")).toEqual({ kind: "file" }); + expect(h.batches).toHaveLength(0); + }).pipe(Effect.scoped), + ); + + it.effect("tree refreshes preserve MIME hints retained by an open thread", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const threadId = ThreadId.make("rich-metadata-thread"); + h.registry.mount(h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId })); + const value = { kind: "file" as const, byteLength: 5, mimeType: "text/x-python" }; + h.values.set("/workspace/run", value); + yield* h.read("/workspace/run", ENVIRONMENT_ID, threadId); + yield* TestClock.adjust("1 hour"); + yield* h.seed("run", "file"); + h.registry.refresh(h.atom("/workspace/run", ENVIRONMENT_ID, threadId)); + expect(yield* h.read("/workspace/run", ENVIRONMENT_ID, threadId)).toEqual(value); + expect(h.batches).toHaveLength(1); + }).pipe(Effect.scoped), + ); + + it.effect("an offscreen invalidation waits until that path is read again", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const threadId = ThreadId.make("offscreen-thread"); + h.registry.mount(h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId })); + yield* h.read("/workspace/file", ENVIRONMENT_ID, threadId); + yield* h.atoms + .invalidate("/workspace/file") + .pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, h.supervisor)); + h.atoms.refreshPath(ENVIRONMENT_ID, "/workspace/file", h.registry); + yield* Effect.yieldNow; + expect(h.batches).toHaveLength(1); + h.values.set("/workspace/file", { kind: "directory" }); + expect(yield* h.read("/workspace/file", ENVIRONMENT_ID, threadId)).toEqual({ + kind: "directory", + }); + expect(h.batches).toHaveLength(2); + }).pipe(Effect.scoped), + ); + + it.effect("retains more than 1024 visited paths until the thread closes", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const threadId = ThreadId.make("large-thread"); + const release = h.registry.mount( + h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId }), + ); + const paths = Array.from({ length: 1300 }, (_, i) => `/workspace/visited-${i}`); + yield* Effect.forEach(paths, (path) => h.read(path, ENVIRONMENT_ID, threadId), { + concurrency: "unbounded", + }); + expect(h.batches).toHaveLength(21); + yield* TestClock.adjust("1 hour"); + for (const path of paths) h.registry.refresh(h.atom(path, ENVIRONMENT_ID, threadId)); + yield* Effect.forEach(paths, (path) => h.read(path, ENVIRONMENT_ID, threadId), { + concurrency: "unbounded", + }); + expect(h.batches).toHaveLength(21); + release(); + yield* Effect.yieldNow; + h.registry.refresh(h.atom(paths[0]!, ENVIRONMENT_ID, threadId)); + yield* h.read(paths[0]!, ENVIRONMENT_ID, threadId); + expect(h.batches).toHaveLength(22); + }).pipe(Effect.scoped), + ); + + it.effect("closing one thread does not release another thread's retained paths", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const a = ThreadId.make("thread-a"); + const b = ThreadId.make("thread-b"); + const releaseA = h.registry.mount( + h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId: a }), + ); + h.registry.mount(h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId: b })); + yield* h.read("/workspace/shared", ENVIRONMENT_ID, a); + yield* h.read("/workspace/shared", ENVIRONMENT_ID, b); + expect(h.batches).toHaveLength(1); + releaseA(); + yield* Effect.yieldNow; + yield* TestClock.adjust("1 hour"); + h.registry.refresh(h.atom("/workspace/shared", ENVIRONMENT_ID, b)); + yield* h.read("/workspace/shared", ENVIRONMENT_ID, b); + expect(h.batches).toHaveLength(1); + }).pipe(Effect.scoped), + ); + + it.effect("invalidates a changed retained path without refetching other paths", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const threadId = ThreadId.make("changing-thread"); + h.registry.mount(h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId })); + h.values.set("/workspace/file", { kind: "file", byteLength: 5 }); + const file = h.atom("/workspace/file", ENVIRONMENT_ID, threadId); + h.registry.mount(file); + yield* h.read("/workspace/file", ENVIRONMENT_ID, threadId); + yield* h.read("/workspace/unchanged", ENVIRONMENT_ID, threadId); + const before = h.batches.length; + h.values.set("/workspace/file", { kind: "file", byteLength: 10 }); + yield* h.atoms + .invalidate("/workspace/file") + .pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, h.supervisor)); + h.atoms.refreshPath(ENVIRONMENT_ID, "/workspace/file", h.registry); + expect(yield* h.read("/workspace/file", ENVIRONMENT_ID, threadId)).toEqual({ + kind: "file", + byteLength: 10, + }); + h.registry.refresh(h.atom("/workspace/unchanged", ENVIRONMENT_ID, threadId)); + yield* h.read("/workspace/unchanged", ENVIRONMENT_ID, threadId); + expect(h.batches.slice(before)).toEqual([["/workspace/file"]]); + }).pipe(Effect.scoped), + ); + + it.effect("a reconnect discards retained metadata from the previous session", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const threadId = ThreadId.make("reconnected-thread"); + h.registry.mount(h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId })); + h.values.set("/workspace/file", { kind: "file" }); + yield* h.read("/workspace/file", ENVIRONMENT_ID, threadId); + h.values.set("/workspace/file", { kind: "directory" }); + yield* SubscriptionRef.set(h.session, Option.some(h.makeSession())); + h.registry.refresh(h.atom("/workspace/file", ENVIRONMENT_ID, threadId)); + expect(yield* h.read("/workspace/file", ENVIRONMENT_ID, threadId)).toEqual({ + kind: "directory", + }); + expect(h.batches).toHaveLength(2); + }).pipe(Effect.scoped), + ); + + it.effect("keeps batches and cached paths separate across environments", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.seed("known", "directory"); + expect(yield* h.read("/workspace/known", OTHER_ENVIRONMENT_ID)).toBeNull(); + expect(h.batches).toEqual([["/workspace/known"]]); + yield* Effect.all( + [h.read("/workspace/new"), h.read("/workspace/new", OTHER_ENVIRONMENT_ID)], + { concurrency: "unbounded" }, + ); + expect(h.batches.slice(1)).toEqual([["/workspace/new"], ["/workspace/new"]]); + }).pipe(Effect.scoped), + ); + + it.effect("settles a failed batch and can retry after access is granted", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + h.failure.denied = true; + const failures = yield* Effect.all( + [h.read("/workspace/a").pipe(Effect.flip), h.read("/workspace/b").pipe(Effect.flip)], + { concurrency: "unbounded" }, + ); + expect(failures.every(Schema.is(EnvironmentAuthorizationError))).toBe(true); + h.failure.denied = false; + h.values.set("/workspace/a", { kind: "directory" }); + h.registry.refresh(h.atom("/workspace/a")); + expect(yield* h.read("/workspace/a")).toEqual({ kind: "directory" }); + }).pipe(Effect.scoped), + ); + + it.effect("expires cached metadata and refreshes a changed kind", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + h.values.set("/workspace/path", { kind: "file" }); + yield* h.read("/workspace/path"); + h.values.set("/workspace/path", { kind: "directory" }); + yield* TestClock.adjust("61 seconds"); + h.registry.refresh(h.atom("/workspace/path")); + expect(yield* h.read("/workspace/path")).toEqual({ kind: "directory" }); + expect(h.batches).toHaveLength(2); + }).pipe(Effect.scoped), + ); + + it.effect("reuses file read sizes and normalizes Windows cache keys", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.atoms + .rememberFile("C:\\Workspace", { + relativePath: "file", + byteLength: 100, + }) + .pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, h.supervisor)); + expect(yield* h.read("c:/workspace/file/")).toEqual({ kind: "file", byteLength: 100 }); + expect(h.batches).toHaveLength(0); + }).pipe(Effect.scoped), + ); + + it.effect("batches mounted paths and deduplicates repeated chips", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + h.values.set("/workspace/file", { kind: "file", byteLength: 5 }); + h.values.set("/workspace/folder.ts", { kind: "directory" }); + const results = yield* Effect.all( + [h.read("/workspace/file"), h.read("/workspace/folder.ts"), h.read("/workspace/file")], + { concurrency: "unbounded" }, + ); + expect(h.batches).toEqual([["/workspace/file", "/workspace/folder.ts"]]); + expect(results).toEqual([ + { kind: "file", byteLength: 5 }, + { kind: "directory" }, + { kind: "file", byteLength: 5 }, + ]); + yield* h.read("/workspace/file"); + expect(h.batches).toHaveLength(1); + }).pipe(Effect.scoped), + ); + + it.effect("uses search and tree kinds without another request", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.seed("folder.ts", "directory"); + expect(yield* h.read("/workspace/folder.ts")).toEqual({ kind: "directory" }); + expect(h.batches).toHaveLength(0); + }).pipe(Effect.scoped), + ); + + it.effect("splits large mounts into bounded batches and caches misses", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* Effect.all( + Array.from({ length: 130 }, (_, i) => h.read(`/workspace/${i}`)), + { concurrency: "unbounded" }, + ); + expect(h.batches.flat()).toHaveLength(130); + expect(h.batches.map((batch) => batch.length)).toEqual([64, 64, 2]); + expect(h.batches.every((batch) => batch.length <= 64)).toBe(true); + const count = h.batches.length; + yield* h.read("/workspace/0"); + expect(h.batches).toHaveLength(count); + }).pipe(Effect.scoped), + ); + + it.effect("batches synchronous mounts after the runtime is warm", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.read("/workspace/warmup"); + h.batches.length = 0; + const paths = Array.from({ length: 130 }, (_, i) => `/workspace/mounted-${i}`); + yield* Effect.sync(() => { + for (const path of paths) h.registry.get(h.atom(path)); + }); + yield* Effect.all( + paths.map((path) => h.read(path)), + { concurrency: "unbounded" }, + ); + expect(h.batches.map((batch) => batch.length)).toEqual([64, 64, 2]); + }).pipe(Effect.scoped), + ); + + it.effect("rechecks metadata after the environment session changes", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + h.values.set("/workspace/path", { kind: "file" }); + expect(yield* h.read("/workspace/path")).toEqual({ kind: "file" }); + h.values.set("/workspace/path", { kind: "directory" }); + yield* SubscriptionRef.set(h.session, Option.some(h.makeSession())); + h.registry.refresh(h.atom("/workspace/path")); + expect(yield* h.read("/workspace/path")).toEqual({ kind: "directory" }); + expect(h.batches).toHaveLength(2); + }).pipe(Effect.scoped), + ); +}); diff --git a/packages/client-runtime/src/state/fileMetadata.ts b/packages/client-runtime/src/state/fileMetadata.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0d59db9799068a4897cfeaf179d016ad3d8349e --- /dev/null +++ b/packages/client-runtime/src/state/fileMetadata.ts @@ -0,0 +1,338 @@ +import { + FILESYSTEM_METADATA_BATCH_LIMIT, + type EnvironmentId, + type FilesystemEntryMetadata, + type ProjectEntry, + type ScopedThreadRef, + type ThreadId, + WS_METHODS, +} from "@t3tools/contracts"; +import { normalizeProjectPathForComparison, resolveWorkspaceFilePath } from "@t3tools/shared/path"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Request from "effect/Request"; +import * as RequestResolver from "effect/RequestResolver"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { Atom, AtomRegistry } from "effect/reactivity"; + +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { + request, + type EnvironmentRpcFailure, + EnvironmentRpcUnavailableError, +} from "../rpc/client.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { createEnvironmentQueryAtomFamily } from "./runtime.ts"; + +const METADATA_STALE_TIME_MS = 60_000; +const METADATA_CACHE_LIMIT = 1024; + +interface CachedMetadata { + value: FilesystemEntryMetadata | null; + expires: number; +} + +type RetainedMetadata = Map; + +interface FileMetadataInput { + path: string; + threadId?: ThreadId; +} + +class FileMetadataRequest extends Request.Class< + { + path: string; + session: RpcSession; + supervisor: EnvironmentSupervisor.EnvironmentSupervisor["Service"]; + retained?: RetainedMetadata; + }, + FilesystemEntryMetadata | null, + EnvironmentRpcFailure | EnvironmentRpcUnavailableError +> {} + +/** Shared by file chips, search results, and the file tree in one client. Cache + * entries belong to an RPC session so reconnects and grant changes recheck them. */ +export function createFileMetadataAtoms( + runtime: Atom.AtomRuntime, +) { + const cache = new WeakMap>(); + const invalidationVersions = new WeakMap(); + const inFlight = new WeakMap>>(); + const retainedThreads = new Map< + string, + { readers: number; sessions: WeakMap } + >(); + const threadKey = (ref: ScopedThreadRef) => JSON.stringify([ref.environmentId, ref.threadId]); + const retainThreadFamily = Atom.family((key: string) => + Atom.make((get) => { + let retained = retainedThreads.get(key); + if (!retained) { + retained = { readers: 0, sessions: new WeakMap() }; + retainedThreads.set(key, retained); + } + retained.readers++; + get.addFinalizer(() => { + if (--retained.readers === 0) retainedThreads.delete(key); + }); + }).pipe(Atom.setIdleTTL(0), Atom.withLabel(`file-metadata-thread:${key}`)), + ); + const revisions = Atom.family((key: string) => + Atom.make(0).pipe(Atom.withLabel(`file-metadata-revision:${key}`)), + ); + const revision = (environmentId: EnvironmentId, path: string) => + revisions(JSON.stringify([environmentId, normalizeProjectPathForComparison(path)])); + const knownEntry = (session: RpcSession, path: string, now: number) => { + const key = normalizeProjectPathForComparison(path); + const cached = cache.get(session)?.get(key); + return cached && cached.expires > now ? cached : undefined; + }; + const remember = ( + session: RpcSession, + path: string, + value: FilesystemEntryMetadata | null, + now: number, + partial = false, + ) => { + let entries = cache.get(session); + if (!entries) { + entries = new Map(); + cache.set(session, entries); + } + const key = normalizeProjectPathForComparison(path); + entries.delete(key); + const entry = { value, expires: now + METADATA_STALE_TIME_MS }; + entries.set(key, entry); + for (const thread of retainedThreads.values()) { + const retained = thread.sessions.get(session); + const previous = retained?.get(key); + if (retained && previous) + retained.set(key, { + ...entry, + value: + partial && value && previous.value?.kind === value.kind + ? { ...previous.value, ...value } + : value, + }); + } + if (entries.size > METADATA_CACHE_LIMIT) { + const oldest = entries.keys().next().value; + if (oldest !== undefined) entries.delete(oldest); + } + }; + const resolver = RequestResolver.makeWith({ + batchKey: (entry) => entry.request.session, + delay: Effect.yieldNow, + collectWhile: (entries) => entries.size < FILESYSTEM_METADATA_BATCH_LIMIT, + runAll: Effect.fnUntraced(function* (entries) { + const session = entries[0].request.session; + let pending = [...entries]; + while (pending.length > 0) { + const paths = [...new Set(pending.map((entry) => entry.request.path))]; + let requests = inFlight.get(session); + if (!requests) { + requests = new Map(); + inFlight.set(session, requests); + } + const tokens = new Map(paths.map((path) => [path, { valid: true }])); + for (const [path, token] of tokens) { + let active = requests.get(path); + if (!active) requests.set(path, (active = new Set())); + active.add(token); + } + const activeRequests = requests; + pending = yield* Effect.gen(function* () { + const result = yield* request(WS_METHODS.filesystemGetMetadata, { paths }).pipe( + Effect.provideService( + EnvironmentSupervisor.EnvironmentSupervisor, + entries[0].request.supervisor, + ), + ); + const now = yield* Clock.currentTimeMillis; + const byPath = new Map(paths.map((path, index) => [path, result.entries[index] ?? null])); + const retry: typeof pending = []; + for (const entry of pending) { + // Retry only paths invalidated during this request. Never retain the + // old response or complete a waiting chip with pre-write metadata. + if (!tokens.get(entry.request.path)!.valid) { + retry.push(entry); + continue; + } + const value = byPath.get(entry.request.path) ?? null; + remember(session, entry.request.path, value, now); + entry.request.retained?.set(entry.request.path, { + value, + expires: now + METADATA_STALE_TIME_MS, + }); + entry.completeUnsafe(Exit.succeed(value)); + } + return retry; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + for (const [path, token] of tokens) { + const active = activeRequests.get(path); + active?.delete(token); + if (active?.size === 0) activeRequests.delete(path); + } + }), + ), + ); + } + }), + }); + + const metadataFamily = createEnvironmentQueryAtomFamily(runtime, { + label: "environment-data:filesystem:metadata", + staleTimeMs: METADATA_STALE_TIME_MS, + idleTtlMs: METADATA_STALE_TIME_MS, + execute: Effect.fnUntraced(function* (input: FileMetadataInput) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const session = yield* SubscriptionRef.get(supervisor.session); + if (session._tag === "None") return null; + const thread = input.threadId + ? retainedThreads.get( + threadKey({ environmentId: supervisor.target.environmentId, threadId: input.threadId }), + ) + : undefined; + let retained = thread?.sessions.get(session.value); + if (thread && !retained) { + retained = new Map(); + thread.sessions.set(session.value, retained); + } + const retainedEntry = retained?.get(input.path); + if (retainedEntry) return retainedEntry.value; + const now = yield* Clock.currentTimeMillis; + const cached = knownEntry(session.value, input.path, now); + if (cached) { + retained?.set(input.path, cached); + return cached.value; + } + return yield* Effect.request( + new FileMetadataRequest({ + path: input.path, + session: session.value, + supervisor, + ...(retained ? { retained } : {}), + }), + resolver, + ); + }), + refreshTrigger: ({ + environmentId, + input, + }: { + environmentId: EnvironmentId; + input: FileMetadataInput; + }) => revision(environmentId, input.path), + }); + const metadata = (target: { + environmentId: EnvironmentId; + input: { path: string; threadId?: ThreadId }; + }) => + metadataFamily({ + ...target, + input: { ...target.input, path: normalizeProjectPathForComparison(target.input.path) }, + }); + + const rememberEntries = Effect.fnUntraced(function* ( + cwd: string, + entries: ReadonlyArray, + isCurrent?: (session: RpcSession) => boolean, + ) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const session = yield* SubscriptionRef.get(supervisor.session); + if (session._tag === "None") return; + const now = yield* Clock.currentTimeMillis; + if (isCurrent && !isCurrent(session.value)) return; + for (const entry of entries) { + const path = resolveWorkspaceFilePath(entry.path, cwd); + const previous = knownEntry(session.value, path, now); + if (previous?.value?.kind === entry.kind) continue; + // Listing kinds avoid another request; size and MIME hints are optional. + remember(session.value, path, { kind: entry.kind }, now, true); + } + }); + + const invalidate = Effect.fnUntraced(function* (path: string) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const session = yield* SubscriptionRef.get(supervisor.session); + if (session._tag === "Some") { + invalidationVersions.set(session.value, (invalidationVersions.get(session.value) ?? 0) + 1); + const key = normalizeProjectPathForComparison(path); + for (const token of inFlight.get(session.value)?.get(key) ?? []) token.valid = false; + cache.get(session.value)?.delete(key); + for (const thread of retainedThreads.values()) + thread.sessions.get(session.value)?.delete(normalizeProjectPathForComparison(path)); + } + }); + + const rememberFile = Effect.fnUntraced(function* ( + cwd: string, + file: { relativePath: string; byteLength: number }, + isCurrent?: (session: RpcSession) => boolean, + ) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const session = yield* SubscriptionRef.get(supervisor.session); + if (session._tag === "None") return; + const path = resolveWorkspaceFilePath(file.relativePath, cwd); + const now = yield* Clock.currentTimeMillis; + if (isCurrent && !isCurrent(session.value)) return; + const previous = knownEntry(session.value, path, now)?.value; + remember( + session.value, + path, + { + ...previous, + kind: "file", + byteLength: file.byteLength, + }, + now, + true, + ); + }); + + const seedAfterRead = ( + read: Effect.Effect, + seed: ( + result: A, + isCurrent: (session: RpcSession) => boolean, + ) => Effect.Effect, + ) => + Effect.gen(function* () { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const session = yield* SubscriptionRef.get(supervisor.session); + const version = session._tag === "Some" ? (invalidationVersions.get(session.value) ?? 0) : 0; + const result = yield* read; + // Seeding is optional. Any intervening invalidation or reconnect makes + // the old response unsuitable for the current metadata cache. + yield* seed( + result, + (current) => + session._tag === "Some" && + current === session.value && + (invalidationVersions.get(current) ?? 0) === version, + ); + return result; + }); + + const refreshPath = ( + environmentId: EnvironmentId, + path: string, + registry: AtomRegistry.AtomRegistry, + ) => { + const signal = revision(environmentId, path); + registry.set(signal, registry.get(signal) + 1); + }; + + return { + metadata, + retainThread: (ref: ScopedThreadRef) => retainThreadFamily(threadKey(ref)), + rememberEntries, + rememberFile, + seedAfterRead, + invalidate, + refreshPath, + }; +} diff --git a/packages/client-runtime/src/state/projectCommands.test.ts b/packages/client-runtime/src/state/projectCommands.test.ts index 523a0ca8d8ea6cc1e618b493ee3ae9a9bc4e7fdb..9d7d47ea9d835190a663df51995610dc047106f3 100644 --- a/packages/client-runtime/src/state/projectCommands.test.ts +++ b/packages/client-runtime/src/state/projectCommands.test.ts @@ -1,12 +1,24 @@ import { describe, expect, it } from "@effect/vitest"; -import { EnvironmentId, ProjectId, type ScopedProjectRef, WS_METHODS } from "@t3tools/contracts"; +import { + AuthFilesystemWriteScope, + EnvironmentId, + ProjectId, + ThreadId, + type AuthSessionState, + type ScopedProjectRef, + WS_METHODS, +} from "@t3tools/contracts"; +import { vi } from "vite-plus/test"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { Atom, AtomRegistry } from "effect/reactivity"; +import * as Stream from "effect/Stream"; +import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity"; +import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts"; import * as EnvironmentRegistry from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; @@ -15,6 +27,12 @@ import type { EnvironmentProject } from "./models.ts"; import { createProjectEnvironmentAtoms } from "./projectCommands.ts"; const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +vi.mock("./session.ts", () => ({ + createEnvironmentSessionAtoms: () => ({ sessionStateAtom: sessions }), +})); +const sessions = Atom.family((_id: EnvironmentId) => + Atom.make>(AsyncResult.initial()), +); const PROJECT_ID = ProjectId.make("scratch"); const PROJECT = { id: PROJECT_ID, @@ -23,21 +41,51 @@ const PROJECT = { } as EnvironmentProject; const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { + const metadataRequests: string[][] = []; + const file = { byteLength: 5 }; + const readResponse = { wait: Effect.void as Effect.Effect }; const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: { environmentId: ENVIRONMENT_ID }, session: yield* SubscriptionRef.make( Option.some({ client: { [WS_METHODS.projectsEnsureScratch]: () => Effect.succeed({ projectId: PROJECT_ID }), + [WS_METHODS.projectsReadFile]: () => + Effect.gen(function* () { + const byteLength = file.byteLength; + yield* readResponse.wait; + return { relativePath: "foo", byteLength, contents: "old", truncated: false }; + }), + [WS_METHODS.projectsWriteFile]: () => + Effect.sync(() => { + file.byteLength = 10; + return { relativePath: "foo" }; + }), + [WS_METHODS.filesystemGetMetadata]: (input: { paths: string[] }) => + Effect.sync(() => { + metadataRequests.push(input.paths); + return { + entries: input.paths.map(() => ({ + kind: "file" as const, + byteLength: file.byteLength, + })), + }; + }), }, } as unknown as RpcSession), ), + state: yield* SubscriptionRef.make({ + ...AVAILABLE_CONNECTION_STATE, + phase: "connected" as const, + }), } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]); const runtime = Atom.runtime( Layer.mergeAll( Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, { run: (_environmentId, effect) => Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + followStream: (_environmentId, stream) => + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), } as EnvironmentRegistry.EnvironmentRegistry["Service"]), Layer.succeed( Crypto.Crypto, @@ -57,10 +105,130 @@ const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { }); const registry = AtomRegistry.make(); yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + registry.set( + sessions(ENVIRONMENT_ID), + AsyncResult.success({ + authenticated: true, + auth: { + policy: "remote-reachable" as const, + bootstrapMethods: [], + sessionMethods: [], + sessionCookieName: "test", + }, + scopes: [AuthFilesystemWriteScope], + permissions: [AuthFilesystemWriteScope], + }), + ); const openScratch = Effect.promise(() => commands.openScratch.run(registry, { environmentId: ENVIRONMENT_ID, input: {} }), ); - return { registry, storedProject, openScratch }; + return { registry, storedProject, openScratch, commands, metadataRequests, readResponse }; +}); + +describe("writeFile metadata", () => { + it.effect("reuses a current file read without another metadata request", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* AtomRegistry.getResult( + h.registry, + h.commands.readFile({ + environmentId: ENVIRONMENT_ID, + input: { cwd: "/repo", relativePath: "foo" }, + }), + { suspendOnWaiting: true }, + ); + const metadata = h.commands.fileMetadata({ + environmentId: ENVIRONMENT_ID, + input: { path: "/repo/foo" }, + }); + expect( + yield* AtomRegistry.getResult(h.registry, metadata, { suspendOnWaiting: true }), + ).toEqual({ kind: "file", byteLength: 5 }); + expect(h.metadataRequests).toHaveLength(0); + }).pipe(Effect.scoped), + ); + + it.effect("does not restore old metadata from a read completed after a write", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const threadId = ThreadId.make("stale-read-thread"); + h.registry.mount(h.commands.fileMetadataThread({ environmentId: ENVIRONMENT_ID, threadId })); + const metadata = h.commands.fileMetadata({ + environmentId: ENVIRONMENT_ID, + input: { path: "/repo/foo", threadId }, + }); + h.registry.mount(metadata); + const readMetadata = () => + AtomRegistry.getResult(h.registry, metadata, { suspendOnWaiting: true }); + yield* readMetadata(); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + h.readResponse.wait = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ); + const fileRead = yield* AtomRegistry.getResult( + h.registry, + h.commands.readFile({ + environmentId: ENVIRONMENT_ID, + input: { cwd: "/repo", relativePath: "foo" }, + }), + { suspendOnWaiting: true }, + ).pipe(Effect.forkChild); + yield* Deferred.await(started); + const write = yield* Effect.promise(() => + h.commands.writeFile.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { cwd: "/repo", relativePath: "foo", contents: "new" }, + }), + ); + expect(write._tag).toBe("Success"); + expect(yield* readMetadata()).toEqual({ kind: "file", byteLength: 10 }); + yield* Deferred.succeed(release, undefined); + expect((yield* Fiber.join(fileRead)).byteLength).toBe(5); + h.registry.refresh(metadata); + expect(yield* readMetadata()).toEqual({ kind: "file", byteLength: 10 }); + expect(h.metadataRequests).toHaveLength(2); + }).pipe(Effect.scoped), + ); + + it.effect("refreshes retained canonical and original paths after an aliased write", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const threadId = ThreadId.make("write-metadata-thread"); + h.registry.mount(h.commands.fileMetadataThread({ environmentId: ENVIRONMENT_ID, threadId })); + const atoms = ["/repo/foo", "/repo/./foo"].map((path) => + h.commands.fileMetadata({ environmentId: ENVIRONMENT_ID, input: { path, threadId } }), + ); + for (const atom of atoms) h.registry.mount(atom); + const read = () => + Effect.forEach( + atoms, + (atom) => AtomRegistry.getResult(h.registry, atom, { suspendOnWaiting: true }), + { concurrency: "unbounded" }, + ); + expect(yield* read()).toEqual([ + { kind: "file", byteLength: 5 }, + { kind: "file", byteLength: 5 }, + ]); + const result = yield* Effect.promise(() => + h.commands.writeFile.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { cwd: "/repo", relativePath: "./foo", contents: "new" }, + }), + ); + expect(result).toMatchObject({ _tag: "Success", value: { relativePath: "foo" } }); + expect(yield* read()).toEqual([ + { kind: "file", byteLength: 10 }, + { kind: "file", byteLength: 10 }, + ]); + expect(h.metadataRequests.flat()).toEqual([ + "/repo/foo", + "/repo/./foo", + "/repo/foo", + "/repo/./foo", + ]); + }).pipe(Effect.scoped), + ); }); describe("openScratch", () => { diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index ae77cf4e79e3852de5e7d869a95e8d7496c2b586..46b85f7ff06448f340dc273f5d8853ff86d99710 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -13,7 +13,12 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { Atom, AtomRegistry } from "effect/reactivity"; -import { type EnvironmentRpcInput, request } from "../rpc/client.ts"; +import { + type EnvironmentRpcInput, + type EnvironmentRpcSuccess, + request, + requestGuarded, +} from "../rpc/client.ts"; import type { EnvironmentProject } from "./models.ts"; import { createAtomCommandScheduler, @@ -30,6 +35,8 @@ import { updateProject, } from "../operations/commands.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createFileMetadataAtoms } from "./fileMetadata.ts"; +import { resolveWorkspaceFilePath } from "@t3tools/shared/path"; export type { CreateProjectInput, @@ -52,6 +59,17 @@ function optimisticProjectFileKey(target: OptimisticProjectFileTarget): string { return JSON.stringify([target.environmentId, target.cwd, target.relativePath]); } +function writtenFilePaths( + input: EnvironmentRpcInput, + result: EnvironmentRpcSuccess, +) { + return new Set( + [result.relativePath, input.relativePath].map((path) => + resolveWorkspaceFilePath(path, input.cwd), + ), + ); +} + /** The Scratch project was created, but its event never reached this client. */ export class ScratchProjectNotLoadedError extends Schema.TaggedError()( "ScratchProjectNotLoadedError", @@ -69,6 +87,7 @@ export function createProjectEnvironmentAtoms( readonly projectAtom: (ref: ScopedProjectRef) => Atom.Atom; }, ) { + const fileMetadata = createFileMetadataAtoms(runtime); const projectScheduler = createAtomCommandScheduler(); const fileScheduler = createAtomCommandScheduler(); const optimisticFileFamily = Atom.family((key: string) => @@ -82,22 +101,52 @@ export function createProjectEnvironmentAtoms( JSON.stringify([environmentId, input.projectId]), }; return { + fileMetadata: fileMetadata.metadata, + fileMetadataThread: fileMetadata.retainThread, + invalidateFileMetadata: createEnvironmentCommand(runtime, { + label: "environment-data:filesystem:invalidate-metadata", + execute: (input: { paths: ReadonlyArray }, registry, environmentId) => + Effect.forEach([...new Set(input.paths)], (path) => + fileMetadata + .invalidate(path) + .pipe( + Effect.tap(() => + Effect.sync(() => fileMetadata.refreshPath(environmentId, path, registry)), + ), + ), + ), + }), searchEntries: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:search-entries", tag: WS_METHODS.projectsSearchEntries, staleTimeMs: 15_000, + execute: (input) => + fileMetadata.seedAfterRead( + request(WS_METHODS.projectsSearchEntries, input), + (result, isCurrent) => fileMetadata.rememberEntries(input.cwd, result.entries, isCurrent), + ), }), listEntries: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:list-entries", tag: WS_METHODS.projectsListEntries, staleTimeMs: 30_000, idleTtlMs: 5 * 60_000, + execute: (input) => + fileMetadata.seedAfterRead( + request(WS_METHODS.projectsListEntries, input), + (result, isCurrent) => fileMetadata.rememberEntries(input.cwd, result.entries, isCurrent), + ), }), readFile: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:read-file", tag: WS_METHODS.projectsReadFile, staleTimeMs: 30_000, idleTtlMs: 5 * 60_000, + execute: (input) => + fileMetadata.seedAfterRead( + request(WS_METHODS.projectsReadFile, input), + (result, isCurrent) => fileMetadata.rememberFile(input.cwd, result, isCurrent), + ), }), optimisticFile: (target: OptimisticProjectFileTarget) => optimisticFileFamily(optimisticProjectFileKey(target)), @@ -163,6 +212,17 @@ export function createProjectEnvironmentAtoms( key: ({ environmentId, input }) => JSON.stringify([environmentId, input.cwd, input.relativePath]), }, + execute: (input) => + requestGuarded(WS_METHODS.projectsWriteFile, input).pipe( + Effect.tap((result) => + Effect.forEach(writtenFilePaths(input, result), fileMetadata.invalidate), + ), + ), + onSuccess: ({ environmentId, input }, registry, result) => + Effect.sync(() => { + for (const path of writtenFilePaths(input, result)) + fileMetadata.refreshPath(environmentId, path, registry); + }), }), }; } diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index fb38e84d309bd0d427e9ac68828e5bd3c0d48548..5dee3aa13675a44de5309729886838778f4aa299 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -769,6 +769,7 @@ export function createEnvironmentRpcCommand< readonly input: NoInfer; }, registry: AtomRegistry.AtomRegistry, + result: EnvironmentRpcSuccess, ) => Effect.Effect; readonly onSettled?: ( target: { @@ -797,7 +798,7 @@ export function createEnvironmentRpcCommand< authorize: (id, method, payload) => createCommandPermissions(runtime, method).authorize(registry, id, payload), }), - Effect.tap(() => options.onSuccess?.(target, registry) ?? Effect.void), + Effect.tap((result) => options.onSuccess?.(target, registry, result) ?? Effect.void), Effect.ensuring(options.onSettled?.(target, registry) ?? Effect.void), ); }, diff --git a/packages/contracts/src/filesystem.test.ts b/packages/contracts/src/filesystem.test.ts index 45355b73edc222ad3c913478f340f222608d0c57..6ee1281c23114d7b23d9c4a186f782f1b66dd5d0 100644 --- a/packages/contracts/src/filesystem.test.ts +++ b/packages/contracts/src/filesystem.test.ts @@ -1,7 +1,17 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { FilesystemBrowseError } from "./filesystem.ts"; +import { FilesystemBrowseError, FilesystemGetMetadataInput } from "./filesystem.ts"; + +describe("FilesystemGetMetadataInput", () => { + const decode = Schema.decodeUnknownSync(FilesystemGetMetadataInput); + it("bounds request size and path lengths", () => { + expect(() => decode({ paths: [] })).toThrow(); + expect(() => decode({ paths: Array.from({ length: 65 }, () => "/file") })).toThrow(); + expect(() => decode({ paths: ["/" + "x".repeat(512)] })).toThrow(); + expect(decode({ paths: Array.from({ length: 64 }, () => "/file") }).paths).toHaveLength(64); + }); +}); describe("FilesystemBrowseError", () => { it("derives a stable message from browse context while retaining the cause", () => { diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index 73815fd2c46634507a78801d038e88cdeef21c00..da150fd8b4b5282b5c63dc09f367e7670e7ff9bb 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -1,8 +1,33 @@ import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; const FILESYSTEM_PATH_MAX_LENGTH = 512; +export const FILESYSTEM_METADATA_BATCH_LIMIT = 64; + +export const FilesystemGetMetadataInput = Schema.Struct({ + // Absolute host paths, including ~/ paths. Relative paths need a workspace + // anchor on the client; the server never resolves them against its own cwd. + paths: Schema.Array( + TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)), + ).check(Schema.isMinLength(1), Schema.isMaxLength(FILESYSTEM_METADATA_BATCH_LIMIT)), +}); +export type FilesystemGetMetadataInput = typeof FilesystemGetMetadataInput.Type; + +export const FilesystemEntryMetadata = Schema.Struct({ + kind: Schema.Literals(["file", "directory", "other"]), + byteLength: Schema.optionalKey(NonNegativeInt), + mimeType: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type FilesystemEntryMetadata = typeof FilesystemEntryMetadata.Type; + +// Results follow request order so paths do not have to travel back over the wire. +// A null entry means the path is missing, unreadable, or unsupported. +export const FilesystemGetMetadataResult = Schema.Struct({ + entries: Schema.Array(Schema.NullOr(FilesystemEntryMetadata)), +}); +export type FilesystemGetMetadataResult = typeof FilesystemGetMetadataResult.Type; + export const FilesystemBrowseInput = Schema.Struct({ partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)), cwd: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 289b7041be16e4bbf4f03045ae7c1b83e0953836..05c124a51d56e97e4c5762d1221330e1130cb6a0 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -77,6 +77,8 @@ import { HostPowerSnapshot, } from "./background.ts"; import { + FilesystemGetMetadataInput, + FilesystemGetMetadataResult, FilesystemBrowseInput, FilesystemBrowseResult, FilesystemBrowseError, @@ -382,6 +384,7 @@ export const WS_METHODS = { // Filesystem methods filesystemBrowse: "filesystem.browse", + filesystemGetMetadata: "filesystem.getMetadata", agentSessionsScan: "agentSessions.scan", agentSessionsImport: "agentSessions.import", assetsCreateUrl: "assets.createUrl", @@ -1236,6 +1239,12 @@ const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), }); +const WsFilesystemGetMetadataRpc = Rpc.make(WS_METHODS.filesystemGetMetadata, { + payload: FilesystemGetMetadataInput, + success: FilesystemGetMetadataResult, + error: EnvironmentAuthorizationError, +}); + const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { payload: FilesystemBrowseInput, success: FilesystemBrowseResult, @@ -1947,6 +1956,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsMutateRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, + WsFilesystemGetMetadataRpc, WsAgentSessionsScanRpc, WsAgentSessionsImportRpc, WsAssetsCreateUrlRpc, diff --git a/packages/shared/src/filePreview.ts b/packages/shared/src/filePreview.ts index 04e285eff2245c5ef29d006bb32a14abb0f5f8f1..b35b8a7ecd591304b2c66fac4da17720d74b16c2 100644 --- a/packages/shared/src/filePreview.ts +++ b/packages/shared/src/filePreview.ts @@ -1,5 +1,26 @@ import { videoMimeType } from "./video.ts"; +const ICON_EXTENSION_BY_MIME_TYPE: Readonly> = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "application/pdf": "pdf", + "application/zip": "zip", + "application/vnd.sqlite3": "sqlite", + "text/x-python": "py", + "text/javascript": "js", + "text/x-ruby": "rb", + "text/x-shellscript": "sh", +}; + +/** A detected type fills in an unknown filename's icon; callers keep any + * specific icon already supplied by the filename. */ +export function fileIconPathForMimeType(path: string, mimeType?: string): string { + const extension = ICON_EXTENSION_BY_MIME_TYPE[mimeType ?? ""]; + return extension ? `file.${extension}` : path; +} + export type FilePreviewKind = | "image" | "video" diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts index 705fdbf97ba6484504a5a6ab89124dfa3d65b6c5..9dee78c9d403180140bf538833e05fcbe5d920c2 100644 --- a/packages/shared/src/path.test.ts +++ b/packages/shared/src/path.test.ts @@ -9,9 +9,22 @@ import { newProjectFolderName, normalizeProjectPathForComparison, normalizeProjectPathForDispatch, + resolveWorkspaceFilePath, } from "./path.ts"; describe("path helpers", () => { + it.each([ + ["report:123", "/workspace", "/workspace/report:123"], + ["/workspace/report:12:3", "/other", "/workspace/report:12:3"], + ["src/file", "C:\\Workspace\\", "C:\\Workspace\\src\\file"], + ["C:/Workspace/file", "/other", "C:/Workspace/file"], + ["file", "\\\\host\\share\\", "\\\\host\\share\\file"], + ["file", "/", "/file"], + ["~/report:123", "/workspace", "~/report:123"], + ])("resolves literal path %s within %s", (path, root, expected) => { + expect(resolveWorkspaceFilePath(path, root)).toBe(expected); + }); + it("detects windows drive paths", () => { expect(isWindowsDrivePath("C:\\repo")).toBe(true); expect(isWindowsDrivePath("D:/repo")).toBe(true); diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts index 925e8ac753bdcf39af4da8e808487b38db16a721..86e6d26d6583a316092ece7a02a9bf0ef7123308 100644 --- a/packages/shared/src/path.ts +++ b/packages/shared/src/path.ts @@ -111,3 +111,13 @@ export function workspaceRelativeFilePath( export function isAbsolutePath(value: string): boolean { return value.startsWith("/") || isWindowsAbsolutePath(value); } + +/** Resolve a literal filesystem path without interpreting file-link positions. + * Home-relative paths stay intact for the server to expand. */ +export function resolveWorkspaceFilePath(path: string, workspaceRoot: string): string { + if (isAbsolutePath(path) || path.startsWith("~/") || path.startsWith("~\\")) return path; + const base = workspaceRoot.replace(/[/\\]+$/, ""); + return isWindowsAbsolutePath(workspaceRoot) + ? `${base}\\${path.replaceAll("/", "\\")}` + : `${base}/${path}`; +} -- 2.54.0