authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-10-10 17:32:29-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 17:32:29-05:00
log0442f5bfcca6eca556ee79d41f4addb7a2022188
tree0d344f67f6d6b16c2b48bad2d8a83293403de153
parentf92afe79c921dd7de20204e83d62fed0491b4e54
signature Signed by PGP key B5690EEEBB952194

fix(web): use server metadata for file chip icons (#17923)


34 files changed, 1565 insertions(+), 33 deletions(-)

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