authorclover caruso <git@paperclover.net> 2026-10-14 14:12:29-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
log3bc314d088f34759f6ecac21eda3767529f5466c
tree281f968be9515b4be480f4d4f50c77bb1b5e0a91
parent96a2bb7293207bd038d5eb5754b664d9b1c58e5c
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

feat: edit Markdown files and watch open workspace contents

Clover wants open files and folders to reflect edits from agents and external tools without reopening them. Stream authorized filesystem invalidations into file queries and loaded directory trees, including atomic replacement, deletion, recreation, symlink changes, and reconnects. Preserve pending drafts, stop subscriptions on unmount, and show read errors instead of stale canonical text. Use @clo/markdown-editor's document preset for rendered Markdown files, retaining source view, autosave, selection, signed relative media, and file and external link navigation. Keep host files, truncated previews, and files without write permission read-only. Render embedded videos through the shared T3 player so playback controls, file actions, and floating playback match transcript videos while retaining signed-source refresh and editor measurements. Assisted-by: gpt-6.1-sol

23 files changed, 1189 insertions(+), 96 deletions(-)

apps/server/src/auth/RpcAuthorization.test.ts+3
...@@ -32,6 +32,9 @@ import {...@@ -32,6 +32,9 @@ import {
32import * as RpcAuthorization from "./RpcAuthorization.ts";32import * as RpcAuthorization from "./RpcAuthorization.ts";
3333
34describe("RPC authorization scopes", () => {34describe("RPC authorization scopes", () => {
35 it("requires filesystem read permission to watch file and folder changes", () => {
36 expect(requiredScopeForRpcMethod(WS_METHODS.projectsWatch)).toBe(AuthFilesystemReadScope);
37 });
35 it("declares exactly one scope for every RPC in the server group", () => {38 it("declares exactly one scope for every RPC in the server group", () => {
36 expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys()));39 expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys()));
37 });40 });
apps/server/src/auth/RpcAuthorization.ts+1
...@@ -149,6 +149,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -149,6 +149,7 @@ export const RPC_REQUIRED_SCOPES = {
149 [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope,149 [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope,
150 [WS_METHODS.projectsListEntries]: AuthFilesystemReadScope,150 [WS_METHODS.projectsListEntries]: AuthFilesystemReadScope,
151 [WS_METHODS.projectsReadFile]: AuthFilesystemReadScope,151 [WS_METHODS.projectsReadFile]: AuthFilesystemReadScope,
152 [WS_METHODS.projectsWatch]: AuthFilesystemReadScope,
152 [WS_METHODS.projectsSearchContents]: AuthFilesystemReadScope,153 [WS_METHODS.projectsSearchContents]: AuthFilesystemReadScope,
153 [WS_METHODS.projectsSearchEntries]: AuthFilesystemReadScope,154 [WS_METHODS.projectsSearchEntries]: AuthFilesystemReadScope,
154 [WS_METHODS.projectsWriteFile]: AuthFilesystemWriteScope,155 [WS_METHODS.projectsWriteFile]: AuthFilesystemWriteScope,
apps/server/src/observability/RpcInstrumentation.ts+1
...@@ -136,6 +136,7 @@ const RPC_AGGREGATES = {...@@ -136,6 +136,7 @@ const RPC_AGGREGATES = {
136 [WS_METHODS.projectCloneRetry]: "source-control",136 [WS_METHODS.projectCloneRetry]: "source-control",
137 [WS_METHODS.subscribeProjectClones]: "source-control",137 [WS_METHODS.subscribeProjectClones]: "source-control",
138 [WS_METHODS.projectsListEntries]: "workspace",138 [WS_METHODS.projectsListEntries]: "workspace",
139 [WS_METHODS.projectsWatch]: "workspace",
139 [WS_METHODS.projectsReadFile]: "workspace",140 [WS_METHODS.projectsReadFile]: "workspace",
140 [WS_METHODS.projectsSearchContents]: "workspace",141 [WS_METHODS.projectsSearchContents]: "workspace",
141 [WS_METHODS.projectsSearchEntries]: "workspace",142 [WS_METHODS.projectsSearchEntries]: "workspace",
apps/server/src/workspace/WorkspaceFileSystem.test.ts+168
...@@ -7,6 +7,7 @@ import * as Effect from "effect/Effect";...@@ -7,6 +7,7 @@ import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
9import * as Path from "effect/Path";9import * as Path from "effect/Path";
10import * as Stream from "effect/Stream";
1011
11import * as ServerConfig from "../config.ts";12import * as ServerConfig from "../config.ts";
12import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";13import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
...@@ -57,6 +58,173 @@ const writeTextFile = Effect.fn("writeTextFile")(function* (...@@ -57,6 +58,173 @@ const writeTextFile = Effect.fn("writeTextFile")(function* (
57});58});
5859
59it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => {60it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => {
61 describe("watch", () => {
62 it.effect("observes file edits, atomic replacements, deletion, and recreation", () =>
63 Effect.gen(function* () {
64 const service = yield* WorkspaceFileSystem.WorkspaceFileSystem;
65 const fs = yield* FileSystem.FileSystem;
66 const path = yield* Path.Path;
67 const cwd = yield* makeTempDir;
68 yield* writeTextFile(cwd, "note.md", "first");
69 let step = 0;
70 const observed: Array<string | null> = [];
71 yield* service.watch({ cwd, relativePath: "note.md", directory: false }).pipe(
72 Stream.take(5),
73 Stream.runForEach(() =>
74 Effect.gen(function* () {
75 observed.push(
76 yield* fs
77 .readFileString(path.join(cwd, "note.md"))
78 .pipe(Effect.catch(() => Effect.succeed(null))),
79 );
80 switch (step++) {
81 case 0:
82 yield* writeTextFile(cwd, "note.md", "edited");
83 break;
84 case 1:
85 yield* writeTextFile(cwd, "replacement", "replaced");
86 yield* fs.rename(path.join(cwd, "replacement"), path.join(cwd, "note.md"));
87 break;
88 case 2:
89 yield* fs.remove(path.join(cwd, "note.md"));
90 break;
91 case 3:
92 yield* writeTextFile(cwd, "note.md", "recreated");
93 break;
94 }
95 }),
96 ),
97 );
98 expect(observed).toEqual(["first", "edited", "replaced", null, "recreated"]);
99 }),
100 );
101
102 it.effect("reattaches after an open directory is deleted and recreated", () =>
103 Effect.gen(function* () {
104 const service = yield* WorkspaceFileSystem.WorkspaceFileSystem;
105 const fs = yield* FileSystem.FileSystem;
106 const path = yield* Path.Path;
107 const cwd = yield* makeTempDir;
108 const directory = path.join(cwd, "open");
109 yield* fs.makeDirectory(directory);
110 let step = 0;
111 const observed: Array<readonly string[] | null> = [];
112 yield* service.watch({ cwd, relativePath: "open", directory: true }).pipe(
113 Stream.take(5),
114 Stream.runForEach(() =>
115 Effect.gen(function* () {
116 observed.push(
117 yield* fs.readDirectory(directory).pipe(Effect.catch(() => Effect.succeed(null))),
118 );
119 switch (step++) {
120 case 0:
121 yield* writeTextFile(directory, "one", "one");
122 break;
123 case 1:
124 yield* fs.remove(directory, { recursive: true });
125 break;
126 case 2:
127 yield* fs.makeDirectory(directory);
128 break;
129 case 3:
130 yield* writeTextFile(directory, "two", "two");
131 break;
132 }
133 }),
134 ),
135 );
136 expect(observed).toEqual([[], ["one"], null, [], ["two"]]);
137 }),
138 );
139 });
140
141 describe("watch targets", () => {
142 it.effect("observes initially missing targets and recreation of missing parents", () =>
143 Effect.gen(function* () {
144 const service = yield* WorkspaceFileSystem.WorkspaceFileSystem;
145 const fs = yield* FileSystem.FileSystem;
146 const path = yield* Path.Path;
147 const cwd = yield* makeTempDir;
148 let step = 0;
149 const observed: Array<string | null> = [];
150 yield* service.watch({ cwd, relativePath: "nested/deep/note.md", directory: false }).pipe(
151 Stream.take(4),
152 Stream.runForEach(() =>
153 Effect.gen(function* () {
154 observed.push(
155 yield* fs
156 .readFileString(path.join(cwd, "nested/deep/note.md"))
157 .pipe(Effect.orElseSucceed(() => null)),
158 );
159 switch (step++) {
160 case 0:
161 yield* writeTextFile(cwd, "nested/deep/note.md", "created");
162 break;
163 case 1:
164 yield* fs.remove(path.join(cwd, "nested"), { recursive: true });
165 break;
166 case 2:
167 yield* writeTextFile(cwd, "nested/deep/note.md", "recreated");
168 break;
169 }
170 }),
171 ),
172 );
173 expect(observed).toEqual([null, "created", null, "recreated"]);
174 }),
175 );
176
177 it.effect.skipIf(!symlinksSupported)("follows a file symlink when its target changes", () =>
178 Effect.gen(function* () {
179 const service = yield* WorkspaceFileSystem.WorkspaceFileSystem;
180 const fs = yield* FileSystem.FileSystem;
181 const path = yield* Path.Path;
182 const cwd = yield* makeTempDir;
183 yield* writeTextFile(cwd, "first/note.md", "first");
184 yield* writeTextFile(cwd, "second/note.md", "second");
185 const link = path.join(cwd, "link.md");
186 yield* fs.symlink(path.join(cwd, "first/note.md"), link);
187 let step = 0;
188 const observed: string[] = [];
189 yield* service.watch({ cwd, relativePath: "link.md", directory: false }).pipe(
190 Stream.take(3),
191 Stream.runForEach(() =>
192 Effect.gen(function* () {
193 observed.push(yield* fs.readFileString(link));
194 if (step++ === 0) {
195 yield* fs.remove(link);
196 yield* fs.symlink(path.join(cwd, "second/note.md"), link);
197 } else {
198 yield* writeTextFile(cwd, "second/note.md", "edited");
199 }
200 }),
201 ),
202 );
203 expect(observed).toEqual(["first", "second", "edited"]);
204 }),
205 );
206
207 it.effect("watches the workspace folder itself", () =>
208 Effect.gen(function* () {
209 const service = yield* WorkspaceFileSystem.WorkspaceFileSystem;
210 const fs = yield* FileSystem.FileSystem;
211 const cwd = yield* makeTempDir;
212 let step = 0;
213 const entries: Array<readonly string[]> = [];
214 yield* service.watch({ cwd, relativePath: ".", directory: true }).pipe(
215 Stream.take(2),
216 Stream.runForEach(() =>
217 Effect.gen(function* () {
218 entries.push(yield* fs.readDirectory(cwd));
219 if (step++ === 0) yield* writeTextFile(cwd, "new.md", "created");
220 }),
221 ),
222 );
223 expect(entries).toEqual([[], ["new.md"]]);
224 }),
225 );
226 });
227
60 describe("getMetadata", () => {228 describe("getMetadata", () => {
61 it.effect("expands home-relative paths on the server", () =>229 it.effect("expands home-relative paths on the server", () =>
62 Effect.gen(function* () {230 Effect.gen(function* () {
apps/server/src/workspace/WorkspaceFileSystem.ts+155-1
...@@ -17,6 +17,7 @@ import type {...@@ -17,6 +17,7 @@ import type {
17 FilesystemGetMetadataInput,17 FilesystemGetMetadataInput,
18 FilesystemGetMetadataResult,18 FilesystemGetMetadataResult,
19 ProjectReadFileInput,19 ProjectReadFileInput,
20 ProjectWatchInput,
20 ProjectReadFileResult,21 ProjectReadFileResult,
21 ProjectWriteFileInput,22 ProjectWriteFileInput,
22 ProjectWriteFileResult,23 ProjectWriteFileResult,
...@@ -24,11 +25,15 @@ import type {...@@ -24,11 +25,15 @@ import type {
24import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";25import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
25import * as HostProcess from "@t3tools/shared/HostProcess";26import * as HostProcess from "@t3tools/shared/HostProcess";
26import * as Context from "effect/Context";27import * as Context from "effect/Context";
28import * as Clock from "effect/Clock";
29import * as Cause from "effect/Cause";
27import * as Effect from "effect/Effect";30import * as Effect from "effect/Effect";
28import * as FileSystem from "effect/FileSystem";31import * as FileSystem from "effect/FileSystem";
29import * as Layer from "effect/Layer";32import * as Layer from "effect/Layer";
30import * as Path from "effect/Path";33import * as Path from "effect/Path";
31import * as Schema from "effect/Schema";34import * as Schema from "effect/Schema";
35import * as Stream from "effect/Stream";
36import * as Queue from "effect/Queue";
3237
33import * as WorkspaceEntries from "./WorkspaceEntries.ts";38import * as WorkspaceEntries from "./WorkspaceEntries.ts";
34import * as WorkspacePaths from "./WorkspacePaths.ts";39import * as WorkspacePaths from "./WorkspacePaths.ts";
...@@ -52,6 +57,7 @@ export class WorkspaceFileSystemOperationError extends Schema.TaggedError<Worksp...@@ -52,6 +57,7 @@ export class WorkspaceFileSystemOperationError extends Schema.TaggedError<Worksp
52 "close",57 "close",
53 "make-directory",58 "make-directory",
54 "write-file",59 "write-file",
60 "watch",
55 ]),61 ]),
56 cause: Schema.Defect(),62 cause: Schema.Defect(),
57 },63 },
...@@ -113,6 +119,13 @@ export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type;...@@ -113,6 +119,13 @@ export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type;
113export class WorkspaceFileSystem extends Context.Service<119export class WorkspaceFileSystem extends Context.Service<
114 WorkspaceFileSystem,120 WorkspaceFileSystem,
115 {121 {
122 readonly watch: (
123 input: ProjectWatchInput,
124 ) => Stream.Stream<
125 number,
126 WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError
127 >;
128
116 readonly getMetadata: (129 readonly getMetadata: (
117 input: FilesystemGetMetadataInput,130 input: FilesystemGetMetadataInput,
118 ) => Effect.Effect<FilesystemGetMetadataResult>;131 ) => Effect.Effect<FilesystemGetMetadataResult>;
...@@ -395,7 +408,148 @@ export const make = Effect.gen(function* () {...@@ -395,7 +408,148 @@ export const make = Effect.gen(function* () {
395 return { relativePath: target.relativePath };408 return { relativePath: target.relativePath };
396 });409 });
397410
398 return WorkspaceFileSystem.of({ getMetadata, readFile, writeFile });411 const watch: WorkspaceFileSystem["Service"]["watch"] = (input) =>
412 Stream.unwrap(
413 Effect.gen(function* () {
414 const requestedPath =
415 input.relativePath === "."
416 ? path.resolve(input.cwd)
417 : path.isAbsolute(input.relativePath)
418 ? input.relativePath
419 : (yield* workspacePaths.resolveRelativePathWithinRoot({
420 workspaceRoot: input.cwd,
421 relativePath: input.relativePath,
422 })).absolutePath;
423 const realRoot = path.isAbsolute(input.relativePath)
424 ? null
425 : (yield* resolveReadTarget({ cwd: input.cwd, relativePath: path.resolve(input.cwd) }))
426 .realTargetPath;
427 yield* resolveReadTarget({
428 ...input,
429 relativePath: input.relativePath === "." ? requestedPath : input.relativePath,
430 }).pipe(
431 Effect.catchTags({
432 WorkspaceFileSystemOperationError: (error) =>
433 error.operation === "realpath-target" &&
434 typeof error.cause === "object" &&
435 error.cause !== null &&
436 "code" in error.cause &&
437 error.cause.code === "ENOENT"
438 ? Effect.void
439 : Effect.fail(error),
440 }),
441 );
442 const startedAt = yield* Clock.currentTimeMillis;
443 return Stream.callback<number, WorkspaceFileSystemOperationError>(
444 (mailbox) => {
445 const watchError = (cause: unknown) =>
446 new WorkspaceFileSystemOperationError({
447 workspaceRoot: input.cwd,
448 relativePath: input.relativePath,
449 resolvedPath: requestedPath,
450 operationPath: requestedPath,
451 operation: "watch",
452 cause,
453 });
454 return Effect.acquireRelease(
455 Effect.try({
456 try: () => {
457 let revision = startedAt;
458 let generation = 0;
459 let watchers: NodeFS.FSWatcher[] = [];
460 const close = () => {
461 generation++;
462 for (const watcher of watchers) watcher.close();
463 watchers = [];
464 };
465 const attach = () => {
466 const currentGeneration = ++generation;
467 const directories = new Map<string, Set<string> | null>();
468 const addParents = (target: string, root: string) => {
469 for (let child = target; child !== root;) {
470 const parent = path.dirname(child);
471 if (parent === child) break;
472 const names = directories.get(parent);
473 if (names !== null)
474 directories.set(parent, (names ?? new Set()).add(path.basename(child)));
475 child = parent;
476 }
477 };
478 addParents(requestedPath, path.dirname(path.resolve(input.cwd)));
479 try {
480 const realTarget = NodeFS.realpathSync(requestedPath);
481 const relative =
482 realRoot === null ? null : path.relative(realRoot, realTarget);
483 const insideRoot =
484 relative === null ||
485 !(
486 relative === ".." ||
487 relative.startsWith(`..${path.sep}`) ||
488 path.isAbsolute(relative)
489 );
490 if (insideRoot) {
491 addParents(
492 realTarget,
493 realRoot === null ? path.parse(realTarget).root : path.dirname(realRoot),
494 );
495 if (input.directory && NodeFS.statSync(requestedPath).isDirectory())
496 directories.set(requestedPath, null);
497 }
498 } catch {
499 // Ancestor watches remain attached while the target is absent.
500 }
501 const next: NodeFS.FSWatcher[] = [];
502 try {
503 for (const [directory, names] of directories) {
504 if (!NodeFS.existsSync(directory)) continue;
505 const watcher = NodeFS.watch(directory, (_event, filename) => {
506 if (generation !== currentGeneration) return;
507 if (
508 filename !== null &&
509 names !== null &&
510 !names.has(filename.toString())
511 )
512 return;
513 try {
514 attach();
515 Queue.offerUnsafe(mailbox, ++revision);
516 } catch (cause) {
517 Queue.failCauseUnsafe(mailbox, Cause.fail(watchError(cause)));
518 }
519 });
520 watcher.on("error", (cause) => {
521 if (generation === currentGeneration)
522 Queue.failCauseUnsafe(mailbox, Cause.fail(watchError(cause)));
523 });
524 next.push(watcher);
525 }
526 } catch (cause) {
527 for (const watcher of next) watcher.close();
528 throw cause;
529 }
530 for (const watcher of watchers) watcher.close();
531 watchers = next;
532 };
533 try {
534 attach();
535 Queue.offerUnsafe(mailbox, revision);
536 return close;
537 } catch (cause) {
538 close();
539 throw cause;
540 }
541 },
542 catch: watchError,
543 }),
544 (close) => Effect.sync(close),
545 );
546 },
547 { bufferSize: 1, strategy: "sliding" },
548 ).pipe(Stream.debounce("100 millis"));
549 }),
550 );
551
552 return WorkspaceFileSystem.of({ getMetadata, readFile, writeFile, watch });
399});553});
400554
401export const layer = Layer.effect(WorkspaceFileSystem, make);555export const layer = Layer.effect(WorkspaceFileSystem, make);
apps/server/src/ws.ts+11
...@@ -2685,6 +2685,17 @@ const layerWsRpc = (...@@ -2685,6 +2685,17 @@ const layerWsRpc = (
2685 }),2685 }),
2686 ),2686 ),
2687 ),2687 ),
2688 [WS_METHODS.projectsWatch]: (input) =>
2689 workspaceFileSystem.watch(input).pipe(
2690 Stream.mapError(
2691 (cause) =>
2692 new ProjectReadFileError({
2693 ...input,
2694 ...projectFileFailureContext(cause),
2695 cause,
2696 }),
2697 ),
2698 ),
2688 [WS_METHODS.projectsWriteFile]: (input) =>2699 [WS_METHODS.projectsWriteFile]: (input) =>
2689 workspaceFileSystem.writeFile(input).pipe(2700 workspaceFileSystem.writeFile(input).pipe(
2690 Effect.mapError(2701 Effect.mapError(
apps/web/src/components/ChatMarkdown.test.tsx+8-12
...@@ -15,7 +15,6 @@ import { describe, expect, it, vi } from "vite-plus/test";...@@ -15,7 +15,6 @@ import { describe, expect, it, vi } from "vite-plus/test";
15import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";15import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
16import { GitHubIcon } from "./Icons";16import { GitHubIcon } from "./Icons";
17import { Button } from "./ui/button";17import { Button } from "./ui/button";
18import { setMarkdownTaskChecked } from "./files/filePreviewMode";
1918
20vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null }));19vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null }));
21vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));20vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
...@@ -778,19 +777,12 @@ describe("ChatMarkdown streaming", () => {...@@ -778,19 +777,12 @@ describe("ChatMarkdown streaming", () => {
778 }777 }
779 });778 });
780779
781 it("edits the current task text and marker after reusing a renderer", async () => {780 it("reports the current task marker after reusing a renderer", async () => {
782 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);781 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
783 let renderer: ReactTestRenderer | undefined;782 let renderer: ReactTestRenderer | undefined;
784 let editedText: string | undefined;783 const taskChange = vi.fn();
785 const message = (text: string) => (784 const message = (text: string) => (
786 <ChatMarkdown785 <ChatMarkdown cwd="/tmp/project" text={text} onTaskListChange={taskChange} />
787 cwd="/tmp/project"
788 text={text}
789 onTaskListChange={({ markerOffset, checked }) => {
790 editedText = setMarkdownTaskChecked(text, markerOffset, checked);
791 renderer!.update(message(editedText));
792 }}
793 />
794 );786 );
795787
796 try {788 try {
...@@ -819,7 +811,11 @@ describe("ChatMarkdown streaming", () => {...@@ -819,7 +811,11 @@ describe("ChatMarkdown streaming", () => {
819 });811 });
820812
821 expect(input).toBe(originalInput);813 expect(input).toBe(originalInput);
822 expect(editedText).toBe("- [ ] A longer first task\n- [x] Second");814 expect(taskChange).toHaveBeenCalledExactlyOnceWith({
815 markerOffset: "- [ ] A longer first task\n- ".length,
816 checked: true,
817 });
818 await act(async () => mounted.update(message("- [ ] A longer first task\n- [x] Second")));
823 expect(mounted.root.findAllByType("input")[1]!.props.checked).toBe(true);819 expect(mounted.root.findAllByType("input")[1]!.props.checked).toBe(true);
824 } finally {820 } finally {
825 await act(async () => renderer?.unmount());821 await act(async () => renderer?.unmount());
apps/web/src/components/files/FileMarkdownPreview.test.tsx created+237
...@@ -0,0 +1,237 @@
1// @vitest-environment jsdom
2import { EditorView } from "@codemirror/view";
3import { act } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { EnvironmentId, ThreadId } from "@t3tools/contracts";
6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
7
8const externalLink = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
9const fileLink = vi.hoisted(() => vi.fn());
10const media = vi.hoisted(() => ({ source: vi.fn(), showMenu: vi.fn() }));
11vi.mock("../media/MediaActions", () => ({
12 MediaActions: ({
13 source,
14 children,
15 }: {
16 source: unknown;
17 children: import("react").ReactNode;
18 }) => {
19 media.source(source);
20 return children;
21 },
22 useMediaActionMenu: () => ({ showMenu: media.showMenu }),
23}));
24vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => externalLink }));
25vi.mock("~/rightPanelStore", () => ({
26 useRightPanelStore: { getState: () => ({ openFile: fileLink }) },
27}));
28vi.mock("~/assets/assetUrls", () => ({
29 useAssetUrls: (_environmentId: unknown, resources: Array<{ path: string }>) =>
30 resources.map(
31 (resource) => `https://host.example/asset?path=${encodeURIComponent(resource.path)}`,
32 ),
33}));
34vi.mock("~/components/composerCodeBlockHighlight", () => ({ composerCodeBlockHighlight: [] }));
35
36import { FileMarkdownPreview } from "./FileMarkdownPreview";
37import { MediaPlaybackProvider } from "../media/MediaPlaybackHost";
38
39let root: Root;
40let container: HTMLDivElement;
41const change = vi.fn();
42let playing: WeakSet<HTMLMediaElement>;
43const threadRef = {
44 environmentId: EnvironmentId.make("environment"),
45 threadId: ThreadId.make("thread"),
46};
47
48beforeEach(() => {
49 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
50 playing = new WeakSet();
51 vi.spyOn(HTMLMediaElement.prototype, "paused", "get").mockImplementation(function (
52 this: HTMLMediaElement,
53 ) {
54 return !playing.has(this);
55 });
56 vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (
57 this: HTMLMediaElement,
58 ) {
59 playing.add(this);
60 this.dispatchEvent(new Event("play"));
61 return Promise.resolve();
62 });
63 vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(function (
64 this: HTMLMediaElement,
65 ) {
66 playing.delete(this);
67 this.dispatchEvent(new Event("pause"));
68 });
69 vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
70 Object.defineProperty(Range.prototype, "getClientRects", { configurable: true, value: () => [] });
71 Object.defineProperty(Range.prototype, "getBoundingClientRect", {
72 configurable: true,
73 value: () => new DOMRect(),
74 });
75 container = document.createElement("div");
76 document.body.append(container);
77 root = createRoot(container);
78 change.mockClear();
79 externalLink.mockClear();
80 fileLink.mockClear();
81 media.source.mockClear();
82 media.showMenu.mockClear();
83});
84afterEach(async () => {
85 await act(() => root.unmount());
86 container.remove();
87 vi.restoreAllMocks();
88 vi.unstubAllGlobals();
89});
90async function render(text: string, readOnly = false, revision: string | null = null) {
91 await act(async () =>
92 root.render(
93 <MediaPlaybackProvider>
94 <FileMarkdownPreview
95 cwd="/workspace"
96 relativePath="docs/note.md"
97 text={text}
98 threadRef={threadRef}
99 draft={false}
100 readOnly={readOnly}
101 revision={revision}
102 onChange={change}
103 />
104 </MediaPlaybackProvider>,
105 ),
106 );
107 return EditorView.findFromDOM(container.querySelector(".cm-editor")!)!;
108}
109
110describe("Markdown document file viewer", () => {
111 it("edits the source without remounting and adopts external updates without saving them back", async () => {
112 const view = await render("# Original\n\n- [ ] Task");
113 expect(container.querySelector(".cm-lp-h1")).not.toBeNull();
114 await act(() =>
115 view.dispatch({ changes: { from: 2, to: 10, insert: "Edited" }, userEvent: "input" }),
116 );
117 expect(change).toHaveBeenLastCalledWith("# Edited\n\n- [ ] Task");
118 change.mockClear();
119 await act(() => view.dispatch({ selection: { anchor: 4, head: 7 } }));
120 expect(await render("# External\n\nFresh contents")).toBe(view);
121 expect(view.state.doc.toString()).toBe("# External\n\nFresh contents");
122 expect(change).not.toHaveBeenCalled();
123 expect(view.state.selection.main.anchor).toBe(4);
124 expect(view.state.selection.main.head).toBe(7);
125 });
126
127 it("opens file-relative and external links from the keyboard", async () => {
128 const view = await render("[Note](./other.md) [Site](https://example.com)");
129 await act(() => {
130 view.dispatch({ selection: { anchor: 3 } });
131 view.contentDOM.dispatchEvent(
132 new KeyboardEvent("keydown", {
133 key: "Enter",
134 metaKey: true,
135 bubbles: true,
136 cancelable: true,
137 }),
138 );
139 });
140 expect(fileLink).toHaveBeenCalledWith(threadRef, "docs/./other.md", undefined);
141 await act(() => {
142 view.dispatch({ selection: { anchor: 21 } });
143 view.contentDOM.dispatchEvent(
144 new KeyboardEvent("keydown", {
145 key: "Enter",
146 metaKey: true,
147 bubbles: true,
148 cancelable: true,
149 }),
150 );
151 });
152 expect(externalLink).toHaveBeenCalledWith("https://example.com");
153 });
154
155 it("keeps read-only files and task widgets uneditable", async () => {
156 const view = await render("- [ ] Task", true);
157 expect(view.state.readOnly).toBe(true);
158 expect(view.contentDOM.getAttribute("aria-readonly")).toBe("true");
159 expect(view.contentDOM.getAttribute("contenteditable")).toBe("false");
160 const box = container.querySelector<HTMLInputElement>('input[type="checkbox"]')!;
161 expect(box.disabled).toBe(true);
162 await act(() => box.click());
163 expect(view.state.doc.toString()).toBe("- [ ] Task");
164 expect(change).not.toHaveBeenCalled();
165 });
166
167 it("resolves relative images against the Markdown file's directory through host assets", async () => {
168 await render("![Figure](figure.png)");
169 expect(container.querySelector<HTMLImageElement>(".cm-lp-image img")?.src).toBe(
170 "https://host.example/asset?path=%2Fworkspace%2Fdocs%2Ffigure.png",
171 );
172 });
173
174 it("uses the shared custom player and source-aware actions for document videos", async () => {
175 const source = "![Clip](clip.mp4)";
176 const view = await render(source, true);
177 const video = container.querySelector("video")!;
178 expect(video).not.toBeNull();
179 expect(video.controls).toBe(false);
180 expect(video.src).toBe("https://host.example/asset?path=%2Fworkspace%2Fdocs%2Fclip.mp4");
181 expect(container.querySelector('button[aria-label="Play video"]')).not.toBeNull();
182 const menu = container.querySelector<HTMLButtonElement>('button[aria-label="Video actions"]')!;
183 await act(() => menu.click());
184 expect(media.showMenu).toHaveBeenCalledOnce();
185 expect(media.source).toHaveBeenLastCalledWith(
186 expect.objectContaining({
187 kind: "video",
188 name: "clip.mp4",
189 reference: {
190 kind: "file",
191 path: "/workspace/docs/clip.mp4",
192 relativePath: "docs/clip.mp4",
193 },
194 asset: {
195 environmentId: threadRef.environmentId,
196 resource: {
197 _tag: "media-file",
198 threadId: threadRef.threadId,
199 path: "/workspace/docs/clip.mp4",
200 },
201 },
202 }),
203 );
204 expect(view.state.doc.toString()).toBe(source);
205 expect(change).not.toHaveBeenCalled();
206 });
207
208 it("keeps the playing document video when navigating to another chat", async () => {
209 await render("![Clip](clip.mp4)", true);
210 const video = container.querySelector("video")!;
211 video.currentTime = 12;
212 await act(() => video.play());
213 await act(() =>
214 root.render(
215 <MediaPlaybackProvider>
216 <main>Another chat</main>
217 </MediaPlaybackProvider>,
218 ),
219 );
220 expect(container.querySelector("[data-media-floating-player] video")).toBe(video);
221 expect(video.currentTime).toBe(12);
222 expect(video.paused).toBe(false);
223 });
224
225 it("preserves the document player across signed source revisions", async () => {
226 const text = "![Clip](clip.mp4)";
227 await render(text, true, "first");
228 const video = container.querySelector("video")!;
229 video.currentTime = 12;
230 await act(() => video.play());
231 await render(text, true, "second");
232 expect(container.querySelector("video")).toBe(video);
233 expect(video.src).toContain("workspace-revision=first");
234 expect(video.currentTime).toBe(12);
235 expect(video.paused).toBe(false);
236 });
237});
apps/web/src/components/files/FileMarkdownPreview.tsx+247-14
...@@ -1,17 +1,42 @@...@@ -1,17 +1,42 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";1import { createMarkdownEditor, livePreview, type MarkdownEditor } from "@clo/markdown-editor";
2import { syntaxTree } from "@codemirror/language";
3import { Compartment, EditorState, Prec, Transaction } from "@codemirror/state";
4import { EditorView } from "@codemirror/view";
5import type { AssetResource, ScopedThreadRef } from "@t3tools/contracts";
6import {
7 classifyMarkdownImageSource,
8 markdownImageSourceFragment,
9} from "@t3tools/client-runtime/markdown-images";
10import { workspaceRelativeFilePath } from "@t3tools/shared/path";
11import { resolveMarkdownFileLinkTarget } from "@t3tools/shared/markdownLinks";
12import { resolvePathLinkTarget, splitFilePathPosition } from "@t3tools/shared/fileLinks";
13import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
214
3import ChatMarkdown from "~/components/ChatMarkdown";15import { useOpenLink } from "~/browser/useOpenLink";
4import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";16import { useRightPanelStore } from "~/rightPanelStore";
17import { toastManager } from "~/components/ui/toast";
18import { useAssetUrls } from "~/assets/assetUrls";
19import { composerCodeBlockHighlight } from "~/components/composerCodeBlockHighlight";
20import { workspaceAssetResource } from "./filePreviewMode";
21import { useMarkdownVideos } from "./FileMarkdownVideos";
522
6export function FileMarkdownPreview(props: {23export function FileMarkdownPreview(props: {
7 readonly cwd: string;24 readonly cwd: string;
8 readonly relativePath: string;25 readonly relativePath: string;
9 readonly text: string;26 readonly text: string;
10 readonly threadRef: ScopedThreadRef;27 readonly threadRef: ScopedThreadRef;
11 readonly onTaskListChange?:28 readonly draft: boolean;
12 | ((input: { readonly markerOffset: number; readonly checked: boolean }) => void)29 readonly readOnly: boolean;
13 | undefined;30 readonly revision: string | null;
31 readonly onChange: (text: string) => void;
14}) {32}) {
33 const openLink = useOpenLink(props.threadRef);
34 const host = useRef<HTMLDivElement>(null);
35 const editor = useRef<MarkdownEditor>(null);
36 const latest = useRef(props);
37 const [sources, setSources] = useState<string[]>([]);
38 const [presentation] = useState(() => new Compartment());
39 const [editing] = useState(() => new Compartment());
15 const lastSeparator = Math.max(40 const lastSeparator = Math.max(
16 props.relativePath.lastIndexOf("/"),41 props.relativePath.lastIndexOf("/"),
17 props.relativePath.lastIndexOf("\\"),42 props.relativePath.lastIndexOf("\\"),
...@@ -20,15 +45,223 @@ export function FileMarkdownPreview(props: {...@@ -20,15 +45,223 @@ export function FileMarkdownPreview(props: {
20 lastSeparator >= 045 lastSeparator >= 0
21 ? resolvePathLinkTarget(props.relativePath.slice(0, lastSeparator), props.cwd)46 ? resolvePathLinkTarget(props.relativePath.slice(0, lastSeparator), props.cwd)
22 : props.cwd;47 : props.cwd;
48 const { renderVideo, portals } = useMarkdownVideos({
49 cwd: props.cwd,
50 mediaBaseDir: imageBaseDir,
51 threadRef: props.threadRef,
52 draft: props.draft,
53 revision: props.revision,
54 });
55 const resources = useMemo(
56 () =>
57 sources.map((source): AssetResource =>
58 workspaceAssetResource({
59 kind: "media-file",
60 threadRef: props.threadRef,
61 draft: props.draft,
62 workspaceRoot: props.cwd,
63 absolutePath: source,
64 }),
65 ),
66 [props.cwd, props.draft, props.threadRef, sources],
67 );
68 const urls = useAssetUrls(props.threadRef.environmentId, resources);
69 const resolveUrl = useCallback(
70 (url: string) => {
71 const source = classifyMarkdownImageSource(url, imageBaseDir);
72 if (source._tag === "Direct") return source.uri;
73 if (source._tag === "Blocked") return "about:blank";
74 const index = sources.indexOf(source.path);
75 if (index === -1) {
76 queueMicrotask(() =>
77 setSources((current) =>
78 current.includes(source.path) ? current : [...current, source.path],
79 ),
80 );
81 return "about:blank";
82 }
83 const resolved = urls[index];
84 if (!resolved) return "about:blank";
85 const revision =
86 props.revision === null
87 ? ""
88 : `${resolved.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.revision)}`;
89 return `${resolved}${revision}${markdownImageSourceFragment(url)}`;
90 },
91 [imageBaseDir, props.revision, sources, urls],
92 );
93
94 const activateLink = useCallback(
95 (view: EditorView, position: number) => {
96 let node = syntaxTree(view.state).resolveInner(position, 1);
97 while (node.name !== "Link" && node.parent) node = node.parent;
98 const destination = node.name === "Link" ? node.getChild("URL") : null;
99 if (!destination) return false;
100 const href = view.state.sliceDoc(destination.from, destination.to).replace(/^<|>$/g, "");
101 const filePath = resolveMarkdownFileLinkTarget(href, props.cwd, imageBaseDir);
102 if (filePath !== null) {
103 const target = splitFilePathPosition(filePath);
104 useRightPanelStore
105 .getState()
106 .openFile(
107 props.threadRef,
108 workspaceRelativeFilePath(target.path, props.cwd) ?? target.path,
109 target.line,
110 );
111 return true;
112 }
113 if (!/^(?:https?:|mailto:)/i.test(href)) return false;
114 void openLink(href).catch((cause: unknown) =>
115 toastManager.add({
116 type: "error",
117 title: "Unable to open link",
118 description: cause instanceof Error ? cause.message : "Try opening the link again.",
119 }),
120 );
121 return true;
122 },
123 [imageBaseDir, openLink, props.cwd, props.threadRef],
124 );
125
126 useLayoutEffect(() => {
127 latest.current = props;
128 });
129 // oxlint-disable react/exhaustive-effect-dependencies -- One editor per file; controlled updates below preserve the mounted view.
130 useLayoutEffect(() => {
131 const instance = createMarkdownEditor({
132 preset: "document",
133 mentions: false,
134 preview: false,
135 doc: latest.current.text,
136 onChange: (text) => {
137 if (text !== latest.current.text && !latest.current.readOnly) latest.current.onChange(text);
138 },
139 extensions: [
140 presentation.of([]),
141 editing.of([]),
142 composerCodeBlockHighlight,
143 Prec.highest(
144 EditorView.theme({
145 "&": {
146 height: "100%",
147 fontSize: "inherit",
148 color: "var(--foreground)",
149 "--markdown-text": "var(--foreground)",
150 "--markdown-muted": "var(--muted-foreground)",
151 "--markdown-panel": "var(--muted)",
152 "--markdown-accent": "var(--primary)",
153 },
154 ".cm-scroller": {
155 fontFamily: "var(--font-sans)",
156 scrollbarGutter: "stable both-edges",
157 },
158 ".cm-content": {
159 maxWidth: "56rem",
160 margin: "0 auto",
161 padding: "20px 24px",
162 flex: "1",
163 minWidth: "0",
164 },
165 ".cm-lp-strong": {
166 color:
167 "light-dark(oklch(from var(--message-action) min(l, 0.55) c h), oklch(from var(--message-action) max(l, 0.65) c h))",
168 },
169 ".cm-lp-em": { color: "var(--info-foreground)" },
170 ".cm-lp-strong.cm-lp-em, .cm-lp-strong .cm-lp-em, .cm-lp-em .cm-lp-strong": {
171 color: "var(--error-foreground)",
172 backgroundColor: "color-mix(in srgb, var(--error) 10%, transparent)",
173 },
174 ".cm-lp-code, .cm-lp-codeblock": {
175 fontFamily:
176 'var(--chat-code-font, "Recursive"), "T3 Markdown Symbols", var(--font-mono)',
177 fontVariationSettings: '"CASL" 0.15, "MONO" 1',
178 fontSize: "var(--font-size-code, 13px)",
179 },
180 ".cm-lp-codeblock": { lineHeight: "1.25" },
181 "&.cm-focused": { outline: "none" },
182 ".cm-content:focus-visible": {
183 outline: "2px solid var(--ring)",
184 outlineOffset: "-2px",
185 },
186 }),
187 ),
188 ],
189 });
190 editor.current = instance;
191 host.current?.append(instance.dom);
192 return () => {
193 editor.current = null;
194 instance.destroy();
195 };
196 }, []);
197 // oxlint-enable react/exhaustive-effect-dependencies
198
199 useEffect(() => {
200 editor.current?.view.dispatch({
201 effects: [
202 presentation.reconfigure(
203 livePreview({ blockWidgets: true, html: true, resolveUrl, renderVideo }),
204 ),
205 editing.reconfigure([
206 EditorState.readOnly.of(props.readOnly),
207 EditorView.editable.of(!props.readOnly),
208 EditorView.contentAttributes.of({
209 role: "textbox",
210 "aria-label": props.relativePath,
211 "aria-multiline": "true",
212 "aria-readonly": String(props.readOnly),
213 tabindex: "0",
214 }),
215 Prec.highest(
216 EditorView.domEventHandlers({
217 click(event, view) {
218 if (
219 !(props.readOnly || event.metaKey || event.ctrlKey) ||
220 !(event.target instanceof Node)
221 )
222 return false;
223 return activateLink(view, view.posAtDOM(event.target));
224 },
225 keydown(event, view) {
226 if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
227 return activateLink(view, view.state.selection.main.head);
228 }
229 if (event.key !== "Escape" || !view.hasFocus) return false;
230 view.contentDOM.blur();
231 return false;
232 },
233 }),
234 ),
235 ]),
236 ],
237 });
238 }, [
239 activateLink,
240 editing,
241 presentation,
242 props.readOnly,
243 props.relativePath,
244 resolveUrl,
245 renderVideo,
246 ]);
247
248 useEffect(() => {
249 const view = editor.current?.view;
250 if (!view || view.state.doc.toString() === props.text) return;
251 view.dispatch({
252 changes: { from: 0, to: view.state.doc.length, insert: props.text },
253 selection: {
254 anchor: Math.min(view.state.selection.main.anchor, props.text.length),
255 head: Math.min(view.state.selection.main.head, props.text.length),
256 },
257 annotations: Transaction.addToHistory.of(false),
258 });
259 }, [props.text]);
23260
24 return (261 return (
25 <ChatMarkdown262 <>
26 text={props.text}263 <div ref={host} className="min-h-0 flex-1 overflow-hidden" data-file-markdown-editor />
27 cwd={props.cwd}264 {portals}
28 imageBaseDir={imageBaseDir}265 </>
29 threadRef={props.threadRef}
30 className="mx-auto max-w-4xl px-6 py-5"
31 onTaskListChange={props.onTaskListChange}
32 />
33 );266 );
34}267}
apps/web/src/components/files/FileMarkdownVideos.tsx created+96
...@@ -0,0 +1,96 @@
1import type { VideoPreviewInput, VideoPreviewRenderer } from "@clo/markdown-editor";
2import type { EditorView } from "@codemirror/view";
3import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
4import {
5 mediaFileReference,
6 mediaReferenceFileName,
7 mediaUrlReference,
8} from "@t3tools/client-runtime/media-reference";
9import type { ScopedThreadRef } from "@t3tools/contracts";
10import { useCallback, useLayoutEffect, useRef, useState } from "react";
11import { createPortal } from "react-dom";
12
13import { MediaVideoPlayer } from "../media/MediaVideoPlayer";
14import { workspaceAssetResource } from "./filePreviewMode";
15
16export function useMarkdownVideos(context: {
17 readonly cwd: string;
18 readonly mediaBaseDir: string;
19 readonly threadRef: ScopedThreadRef;
20 readonly draft: boolean;
21 readonly revision: string | null;
22}) {
23 const nextKey = useRef(0);
24 const [previews, setPreviews] = useState<
25 readonly {
26 readonly key: number;
27 readonly dom: HTMLElement;
28 readonly input: VideoPreviewInput;
29 readonly view: EditorView;
30 }[]
31 >([]);
32
33 const renderVideo = useCallback<VideoPreviewRenderer>((input, view) => {
34 const dom = document.createElement("span");
35 dom.className = "cm-lp-video";
36 const key = nextKey.current++;
37 setPreviews((current) => [...current, { key, dom, input, view }]);
38 return {
39 dom,
40 update: (input) =>
41 setPreviews((current) =>
42 current.map((preview) => (preview.key === key ? { ...preview, input } : preview)),
43 ),
44 destroy: () => setPreviews((current) => current.filter((preview) => preview.key !== key)),
45 };
46 }, []);
47
48 useLayoutEffect(() => {
49 for (const { view } of previews) view.requestMeasure();
50 }, [previews]);
51
52 const portals = previews.map(({ key, dom, input }) => {
53 const source = classifyMarkdownImageSource(input.source, context.mediaBaseDir);
54 const reference =
55 source._tag === "WorkspaceFile"
56 ? mediaFileReference(source.path, context.cwd)
57 : mediaUrlReference(input.source);
58 const src = input.url === "about:blank" ? null : input.url;
59 const name = (reference && mediaReferenceFileName(reference)) || input.alt || "Video";
60 const asset =
61 source._tag === "WorkspaceFile"
62 ? {
63 environmentId: context.threadRef.environmentId,
64 resource: workspaceAssetResource({
65 kind: "media-file",
66 threadRef: context.threadRef,
67 draft: context.draft,
68 workspaceRoot: context.cwd,
69 absolutePath: source.path,
70 }),
71 }
72 : undefined;
73 return createPortal(
74 <MediaVideoPlayer
75 src={src}
76 label={input.alt || name}
77 originalUrl={source._tag === "Direct" ? source.uri : undefined}
78 sourceFailed={source._tag === "Blocked"}
79 revision={context.revision}
80 preload="metadata"
81 className="w-full max-w-full"
82 videoClassName="max-h-90 max-w-full"
83 actionsSource={{
84 kind: "video",
85 name,
86 src,
87 ...(reference ? { reference } : {}),
88 ...(asset ? { asset } : {}),
89 }}
90 />,
91 dom,
92 String(key),
93 );
94 });
95 return { renderVideo, portals };
96}
apps/web/src/components/files/FilePreviewPanel.test.ts-16
...@@ -12,7 +12,6 @@ import {...@@ -12,7 +12,6 @@ import {
12 filePreviewReadErrorMessage,12 filePreviewReadErrorMessage,
13 isMarkdownPreviewFile,13 isMarkdownPreviewFile,
14 resolveFilePreviewPath,14 resolveFilePreviewPath,
15 setMarkdownTaskChecked,
16 shouldShowFileExplorer,15 shouldShowFileExplorer,
17 workspaceAssetResource,16 workspaceAssetResource,
18} from "./filePreviewMode";17} from "./filePreviewMode";
...@@ -156,21 +155,6 @@ describe("shouldShowFileExplorer", () => {...@@ -156,21 +155,6 @@ describe("shouldShowFileExplorer", () => {
156 });155 });
157});156});
158157
159describe("setMarkdownTaskChecked", () => {
160 const markdown = "- [ ] First\n- [x] Second\n";
161
162 it("checks and unchecks the task marker at the supplied offset", () => {
163 expect(setMarkdownTaskChecked(markdown, 2, true)).toBe("- [x] First\n- [x] Second\n");
164 expect(setMarkdownTaskChecked(markdown, 14, false)).toBe("- [ ] First\n- [ ] Second\n");
165 expect(setMarkdownTaskChecked("1. [X] Ordered\n", 3, false)).toBe("1. [ ] Ordered\n");
166 });
167
168 it("leaves the document unchanged for a stale or invalid marker offset", () => {
169 expect(setMarkdownTaskChecked(markdown, 0, true)).toBe(markdown);
170 expect(setMarkdownTaskChecked(markdown, 200, true)).toBe(markdown);
171 });
172});
173
174describe("resolveFilePreviewPath", () => {158describe("resolveFilePreviewPath", () => {
175 it.each([159 it.each([
176 ["/repo/project", null],160 ["/repo/project", null],
apps/web/src/components/files/FilePreviewPanel.tsx+39-30
...@@ -1,3 +1,6 @@...@@ -1,3 +1,6 @@
1import { useAtomValue } from "@effect/atom-react";
2import { AsyncResult, Atom } from "effect/reactivity";
3import { projectEnvironment } from "~/state/projects";
1import { Spinner } from "~/components/ui/spinner";4import { Spinner } from "~/components/ui/spinner";
2import {5import {
3 AuthPreviewOperateScope,6 AuthPreviewOperateScope,
...@@ -104,13 +107,11 @@ import {...@@ -104,13 +107,11 @@ import {
104 filePreviewReadErrorMessage,107 filePreviewReadErrorMessage,
105 isMarkdownPreviewFile,108 isMarkdownPreviewFile,
106 resolveFilePreviewPath,109 resolveFilePreviewPath,
107 setMarkdownTaskChecked,
108 shouldShowFileExplorer,110 shouldShowFileExplorer,
109 workspaceAssetResource,111 workspaceAssetResource,
110} from "./filePreviewMode";112} from "./filePreviewMode";
111import { useFileSaveCoordinator } from "./useFileSaveCoordinator";113import { useFileSaveCoordinator } from "./useFileSaveCoordinator";
112import {114import {
113 getOptimisticProjectFileQueryData,
114 getProjectFileContents,115 getProjectFileContents,
115 setProjectFileQueryData,116 setProjectFileQueryData,
116 useProjectFileQuery,117 useProjectFileQuery,
...@@ -134,6 +135,8 @@ interface FilePreviewPanelProps {...@@ -134,6 +135,8 @@ interface FilePreviewPanelProps {
134 workspaceMutationId: string | null;135 workspaceMutationId: string | null;
135}136}
136137
138const EMPTY_FILE_WATCH = Atom.make(AsyncResult.initial<number>());
139
137const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";140const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";
138const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown";141const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown";
139const RENDER_BROWSER_FILE_STORAGE_KEY = "t3code.renderBrowserFile";142const RENDER_BROWSER_FILE_STORAGE_KEY = "t3code.renderBrowserFile";
...@@ -314,6 +317,7 @@ function WorkspaceVideoPreview(props: {...@@ -314,6 +317,7 @@ function WorkspaceVideoPreview(props: {
314 revision={props.workspaceMutationId}317 revision={props.workspaceMutationId}
315 preload="metadata"318 preload="metadata"
316 className="flex h-full min-h-0 w-full max-w-5xl items-center justify-center"319 className="flex h-full min-h-0 w-full max-w-5xl items-center justify-center"
320 videoClassName="h-auto w-auto max-w-full"
317 onRetry={refreshAssetUrl}321 onRetry={refreshAssetUrl}
318 actionsSource={{322 actionsSource={{
319 kind: "video",323 kind: "video",
...@@ -965,6 +969,8 @@ function RenderedMarkdownSurface({...@@ -965,6 +969,8 @@ function RenderedMarkdownSurface({
965 contents,969 contents,
966 threadRef,970 threadRef,
967 readOnly,971 readOnly,
972 draft,
973 revision,
968 onPendingChange,974 onPendingChange,
969}: Omit<975}: Omit<
970 EditableFileSurfaceProps,976 EditableFileSurfaceProps,
...@@ -977,6 +983,8 @@ function RenderedMarkdownSurface({...@@ -977,6 +983,8 @@ function RenderedMarkdownSurface({
977> & {983> & {
978 threadRef: ScopedThreadRef;984 threadRef: ScopedThreadRef;
979 readOnly: boolean;985 readOnly: boolean;
986 draft: boolean;
987 revision: string | null;
980}) {988}) {
981 const saveCoordinator = useFileSaveCoordinator({989 const saveCoordinator = useFileSaveCoordinator({
982 environmentId,990 environmentId,
...@@ -986,27 +994,20 @@ function RenderedMarkdownSurface({...@@ -986,27 +994,20 @@ function RenderedMarkdownSurface({
986 });994 });
987995
988 return (996 return (
989 <ScrollArea className="min-h-0 flex-1">997 <FileMarkdownPreview
990 <FileMarkdownPreview998 text={contents}
991 text={contents}999 cwd={cwd}
992 cwd={cwd}1000 relativePath={relativePath}
993 relativePath={relativePath}1001 threadRef={threadRef}
994 threadRef={threadRef}1002 draft={draft}
995 onTaskListChange={1003 revision={revision}
996 readOnly1004 readOnly={readOnly}
997 ? undefined1005 onChange={(nextContents) => {
998 : ({ markerOffset, checked }) => {1006 if (nextContents === getProjectFileContents(environmentId, cwd, relativePath)) return;
999 const currentContents =1007 setProjectFileQueryData(environmentId, cwd, relativePath, nextContents);
1000 getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ??1008 saveCoordinator.change(nextContents);
1001 contents;1009 }}
1002 const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked);1010 />
1003 if (nextContents === currentContents) return;
1004 setProjectFileQueryData(environmentId, cwd, relativePath, nextContents);
1005 saveCoordinator.change(nextContents);
1006 }
1007 }
1008 />
1009 </ScrollArea>
1010 );1011 );
1011}1012}
10121013
...@@ -1040,7 +1041,7 @@ export default function FilePreviewPanel({...@@ -1040,7 +1041,7 @@ export default function FilePreviewPanel({
1040 onOpenFile,1041 onOpenFile,
1041 onPendingChange,1042 onPendingChange,
1042 selectedFilePending,1043 selectedFilePending,
1043 workspaceMutationId,1044 workspaceMutationId: orchestrationMutationId,
1044}: FilePreviewPanelProps) {1045}: FilePreviewPanelProps) {
1045 const relativePath =1046 const relativePath =
1046 attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath;1047 attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath;
...@@ -1082,6 +1083,14 @@ export default function FilePreviewPanel({...@@ -1082,6 +1083,14 @@ export default function FilePreviewPanel({
1082 relativePath,1083 relativePath,
1083 attachment === undefined && relativePath !== null,1084 attachment === undefined && relativePath !== null,
1084 );1085 );
1086 const watched = useAtomValue(
1087 attachment === undefined && relativePath !== null && canReadFiles
1088 ? projectEnvironment.watch({ environmentId, input: { cwd, relativePath, directory: false } })
1089 : EMPTY_FILE_WATCH,
1090 );
1091 const workspaceMutationId = AsyncResult.isSuccess(watched)
1092 ? `${orchestrationMutationId ?? ""}:${watched.value}`
1093 : orchestrationMutationId;
1085 const attemptedPath = file.readError?.resolvedPath ?? file.readError?.operationPath;1094 const attemptedPath = file.readError?.resolvedPath ?? file.readError?.operationPath;
1086 // A chat link cannot tell a folder from a file, so a folder arrives here as1095 // A chat link cannot tell a folder from a file, so a folder arrives here as
1087 // a file surface and the read fails. Keep the breadcrumbs, drop the preview1096 // a file surface and the read fails. Keep the breadcrumbs, drop the preview
...@@ -1174,7 +1183,7 @@ export default function FilePreviewPanel({...@@ -1174,7 +1183,7 @@ export default function FilePreviewPanel({
1174 // it notices when the path becomes a file.1183 // it notices when the path becomes a file.
1175 (isDirectory || (!isMedia && !isPdf)) &&1184 (isDirectory || (!isMedia && !isPdf)) &&
1176 !selectedFilePending,1185 !selectedFilePending,
1177 mutationId: workspaceMutationId,1186 mutationId: orchestrationMutationId,
1178 refresh: file.refresh,1187 refresh: file.refresh,
1179 resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`,1188 resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`,
1180 });1189 });
...@@ -1432,9 +1441,7 @@ export default function FilePreviewPanel({...@@ -1432,9 +1441,7 @@ export default function FilePreviewPanel({
1432 </div>1441 </div>
1433 ) : relativePath && file.data ? (1442 ) : relativePath && file.data ? (
1434 isMarkdown && renderMarkdown ? (1443 isMarkdown && renderMarkdown ? (
1435 // Markdown reconciles in place across text updates, so a file1444 // Each file owns its editor selection and undo history.
1436 // switch needs a new key or the previous file's disclosure and
1437 // wrap state carries into the next document.
1438 <RenderedMarkdownSurface1445 <RenderedMarkdownSurface
1439 key={relativePath}1446 key={relativePath}
1440 environmentId={environmentId}1447 environmentId={environmentId}
...@@ -1442,7 +1449,9 @@ export default function FilePreviewPanel({...@@ -1442,7 +1449,9 @@ export default function FilePreviewPanel({
1442 relativePath={relativePath}1449 relativePath={relativePath}
1443 threadRef={threadRef}1450 threadRef={threadRef}
1444 contents={file.data.contents}1451 contents={file.data.contents}
1445 readOnly={isHostFile || !canWriteFiles}1452 readOnly={isHostFile || !canWriteFiles || file.data.truncated}
1453 draft={draft}
1454 revision={workspaceMutationId}
1446 onPendingChange={onPendingChange}1455 onPendingChange={onPendingChange}
1447 />1456 />
1448 ) : tableDelimiter && renderTable ? (1457 ) : tableDelimiter && renderTable ? (
...@@ -1495,7 +1504,7 @@ export default function FilePreviewPanel({...@@ -1495,7 +1504,7 @@ export default function FilePreviewPanel({
1495 selectedPath={relativePath}1504 selectedPath={relativePath}
1496 selectedPathRevealId={revealRequestId}1505 selectedPathRevealId={revealRequestId}
1497 onOpenFile={onOpenFile}1506 onOpenFile={onOpenFile}
1498 workspaceMutationId={workspaceMutationId}1507 workspaceMutationId={orchestrationMutationId}
1499 {...(previewPath && !isMedia && !isPdf1508 {...(previewPath && !isMedia && !isPdf
1500 ? { onRefreshSelectedFile: file.refresh }1509 ? { onRefreshSelectedFile: file.refresh }
1501 : {})}1510 : {})}
apps/web/src/components/files/filePreviewMode.ts-17
...@@ -41,23 +41,6 @@ export function shouldShowFileExplorer(input: {...@@ -41,23 +41,6 @@ export function shouldShowFileExplorer(input: {
41 return input.explorerOpen || input.relativePath === null;41 return input.explorerOpen || input.relativePath === null;
42}42}
4343
44export function setMarkdownTaskChecked(
45 markdown: string,
46 markerOffset: number,
47 checked: boolean,
48): string {
49 if (
50 markerOffset < 0 ||
51 markdown[markerOffset] !== "[" ||
52 !/[ xX]/.test(markdown[markerOffset + 1] ?? "") ||
53 markdown[markerOffset + 2] !== "]"
54 ) {
55 return markdown;
56 }
57
58 return `${markdown.slice(0, markerOffset + 1)}${checked ? "x" : " "}${markdown.slice(markerOffset + 2)}`;
59}
60
61/**44/**
62 * The asset for a file the panel shows. A draft has no thread on the server45 * The asset for a file the panel shows. A draft has no thread on the server
63 * yet, so it names its workspace root instead of a thread to resolve one from.46 * yet, so it names its workspace root instead of a thread to resolve one from.
apps/web/src/components/files/projectFilesQueryState.ts+1-1
...@@ -265,13 +265,13 @@ export function useProjectFileQuery(...@@ -265,13 +265,13 @@ export function useProjectFileQuery(
265 const result = useAtomValue(atom);265 const result = useAtomValue(atom);
266 const refreshAtom = useAtomRefresh(atom);266 const refreshAtom = useAtomRefresh(atom);
267 const refresh = useCallback(() => refreshAtom(), [refreshAtom]);267 const refresh = useCallback(() => refreshAtom(), [refreshAtom]);
268 const data = Option.getOrNull(AsyncResult.value(result));
269 const optimisticResult = useAtomValue(268 const optimisticResult = useAtomValue(
270 optimisticFileAtom(environmentId, cwd, relativePath ?? EMPTY_PROJECT_FILE_PATH),269 optimisticFileAtom(environmentId, cwd, relativePath ?? EMPTY_PROJECT_FILE_PATH),
271 );270 );
272 const optimisticFile = relativePath === null ? null : optimisticResult;271 const optimisticFile = relativePath === null ? null : optimisticResult;
273 const cause = failureCause(result);272 const cause = failureCause(result);
274 const readError = isProjectReadFileError(cause) ? cause : null;273 const readError = isProjectReadFileError(cause) ? cause : null;
274 const data = readError === null ? Option.getOrNull(AsyncResult.value(result)) : null;
275275
276 return {276 return {
277 data: canReadFiles ? (optimisticFile?.data ?? data) : null,277 data: canReadFiles ? (optimisticFile?.data ?? data) : null,
apps/web/src/components/files/useDirectoryEntries.test.tsx created+113
...@@ -0,0 +1,113 @@
1// @vitest-environment jsdom
2import type { AsyncResult as AsyncResultType } from "effect/reactivity";
3import type { ProjectEntry } from "@t3tools/contracts";
4import { EnvironmentId } from "@t3tools/contracts";
5import { act, useLayoutEffect } from "react";
6import { createRoot, type Root } from "react-dom/client";
7import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
8
9const disk = vi.hoisted(() => ({
10 entries: new Map<string, ProjectEntry[]>(),
11 watchStarts: [] as string[],
12 reads: [] as string[],
13 change: vi.fn<(path: string) => void>(),
14}));
15vi.mock("~/rpc/atomRegistry", async () => {
16 const { AtomRegistry } = await import("effect/reactivity");
17 return { appAtomRegistry: AtomRegistry.make() };
18});
19vi.mock("~/state/projects", async () => {
20 const { AsyncResult, Atom } = await import("effect/reactivity");
21 const { appAtomRegistry } = await import("~/rpc/atomRegistry");
22 const signals = Atom.family((_path: string) =>
23 Atom.make<AsyncResultType.AsyncResult<number>>(AsyncResult.initial()),
24 );
25 const watches = Atom.family((path: string) =>
26 Atom.make((get) => {
27 disk.watchStarts.push(path);
28 return get(signals(path));
29 }),
30 );
31 const listings = Atom.family((path: string) =>
32 Atom.make(() => {
33 disk.reads.push(path);
34 return AsyncResult.success({ entries: disk.entries.get(path) ?? [], truncated: false });
35 }).pipe(Atom.setIdleTTL("5 minutes")),
36 );
37 let revision = 0;
38 disk.change.mockImplementation((path) =>
39 appAtomRegistry.set(signals(path), AsyncResult.success(++revision)),
40 );
41 return {
42 projectEnvironment: {
43 watch: ({ input }: { input: { relativePath: string } }) => watches(input.relativePath),
44 listEntries: ({ input }: { input: { directoryPath: string } }) =>
45 listings(input.directoryPath),
46 },
47 };
48});
49
50import { useDirectoryEntries } from "./useDirectoryEntries";
51import { appAtomRegistry } from "~/rpc/atomRegistry";
52import { projectEnvironment } from "~/state/projects";
53
54let root: Root;
55let container: HTMLDivElement;
56let folders: ReturnType<typeof useDirectoryEntries>;
57function Probe() {
58 const entries = useDirectoryEntries(EnvironmentId.make("directory-watch"), "/workspace");
59 useLayoutEffect(() => {
60 folders = entries;
61 });
62 return <div>{entries.entries.map((entry) => entry.path).join("\n")}</div>;
63}
64beforeEach(() => {
65 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
66 disk.entries.clear();
67 disk.reads.length = 0;
68 disk.watchStarts.length = 0;
69 container = document.createElement("div");
70 document.body.append(container);
71 root = createRoot(container);
72});
73afterEach(async () => {
74 await act(() => root.unmount());
75 appAtomRegistry.reset();
76 container.remove();
77 vi.unstubAllGlobals();
78});
79
80it("starts fresh folder subscriptions and updates expanded contents after host changes", async () => {
81 disk.entries.set("", [{ path: "nested", kind: "directory" }]);
82 disk.entries.set("nested", [{ path: "nested/old.txt", kind: "file" }]);
83 await act(async () => root.render(<Probe />));
84 await act(async () => folders.load("nested"));
85 expect(container.textContent).toContain("nested/old.txt");
86 expect(disk.watchStarts).toEqual([".", "nested"]);
87
88 disk.entries.set("nested", [{ path: "nested/new.txt", kind: "file" }]);
89 await act(async () => disk.change("nested"));
90 expect(container.textContent).toContain("nested/new.txt");
91 expect(container.textContent).not.toContain("nested/old.txt");
92
93 const reads = disk.reads.length;
94 await act(async () => {
95 disk.change("nested");
96 root.unmount();
97 });
98 expect(disk.reads).toHaveLength(reads);
99});
100
101it("refreshes a cached watch revision after registering its cleanup", async () => {
102 disk.entries.set("", [{ path: "fresh.txt", kind: "file" }]);
103 disk.change(".");
104 appAtomRegistry.get(
105 projectEnvironment.watch({
106 environmentId: EnvironmentId.make("directory-watch"),
107 input: { cwd: "/workspace", relativePath: ".", directory: true },
108 }),
109 );
110 await act(async () => root.render(<Probe />));
111 expect(container.textContent).toBe("fresh.txt");
112 expect(disk.watchStarts).toEqual(["."]);
113});
apps/web/src/components/files/useDirectoryEntries.ts+24
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";1import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
2import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";2import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
3import * as Cause from "effect/Cause";3import * as Cause from "effect/Cause";
4import { AsyncResult } from "effect/reactivity";
4import { useCallback, useEffect, useMemo, useRef, useState } from "react";5import { useCallback, useEffect, useMemo, useRef, useState } from "react";
56
6import { appAtomRegistry } from "~/rpc/atomRegistry";7import { appAtomRegistry } from "~/rpc/atomRegistry";
...@@ -17,17 +18,37 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {...@@ -17,17 +18,37 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
17 const loaded = useRef(new Set<string>());18 const loaded = useRef(new Set<string>());
18 const requested = useRef(new Set<string>());19 const requested = useRef(new Set<string>());
19 const active = useRef(true);20 const active = useRef(true);
21 const watches = useRef(new Map<string, () => void>());
20 const running = useRef(0);22 const running = useRef(0);
21 const waiting = useRef<Array<() => void>>([]);23 const waiting = useRef<Array<() => void>>([]);
2224
23 const load = useCallback(25 const load = useCallback(
24 function loadDirectory(directoryPath: string, refresh = false): Promise<void> {26 function loadDirectory(directoryPath: string, refresh = false): Promise<void> {
27 if (!active.current) return Promise.resolve();
25 const existing = requests.current.get(directoryPath);28 const existing = requests.current.get(directoryPath);
26 if (existing)29 if (existing)
27 return refresh ? existing.then(() => loadDirectory(directoryPath, true)) : existing;30 return refresh ? existing.then(() => loadDirectory(directoryPath, true)) : existing;
28 if (!refresh && loaded.current.has(directoryPath)) return Promise.resolve();31 if (!refresh && loaded.current.has(directoryPath)) return Promise.resolve();
29 loaded.current.add(directoryPath);32 loaded.current.add(directoryPath);
30 requested.current.add(directoryPath);33 requested.current.add(directoryPath);
34 if (!watches.current.has(directoryPath)) {
35 const watch = projectEnvironment.watch({
36 environmentId,
37 input: { cwd, relativePath: directoryPath || ".", directory: true },
38 });
39 watches.current.set(
40 directoryPath,
41 appAtomRegistry.subscribe(
42 watch,
43 (result) => {
44 if (AsyncResult.isSuccess(result)) {
45 queueMicrotask(() => void loadDirectory(directoryPath, true));
46 }
47 },
48 { immediate: true },
49 ),
50 );
51 }
31 const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } });52 const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } });
32 setPending((count) => count + 1);53 setPending((count) => count + 1);
33 if (!refresh) setLoadingDirectories((previous) => new Set(previous).add(directoryPath));54 if (!refresh) setLoadingDirectories((previous) => new Set(previous).add(directoryPath));
...@@ -98,6 +119,9 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {...@@ -98,6 +119,9 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
98 void load("");119 void load("");
99 return () => {120 return () => {
100 active.current = false;121 active.current = false;
122 for (const stop of watches.current.values()) stop();
123 watches.current.clear();
124 loaded.current.clear();
101 };125 };
102 }, [load]);126 }, [load]);
103127
apps/web/src/components/files/useFileSaveCoordinator.test.tsx+2-4
...@@ -21,7 +21,6 @@ vi.mock("./projectFilesQueryState", () => ({...@@ -21,7 +21,6 @@ vi.mock("./projectFilesQueryState", () => ({
21 getUnsavedProjectFileQueryData: getUnsavedFile,21 getUnsavedProjectFileQueryData: getUnsavedFile,
22}));22}));
2323
24import { setMarkdownTaskChecked } from "./filePreviewMode";
25import { useFileSaveCoordinator } from "./useFileSaveCoordinator";24import { useFileSaveCoordinator } from "./useFileSaveCoordinator";
2625
27const environmentId = EnvironmentId.make("save-lifecycle-audit");26const environmentId = EnvironmentId.make("save-lifecycle-audit");
...@@ -93,10 +92,9 @@ describe("file-save React lifecycle", () => {...@@ -93,10 +92,9 @@ describe("file-save React lifecycle", () => {
93 expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", false);92 expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", false);
94 });93 });
9594
96 it("persists rendered Markdown task changes after StrictMode setup replay", async () => {95 it("persists Markdown edits after StrictMode setup replay", async () => {
97 mount({ ...defaultProps, relativePath: "README.md" });96 mount({ ...defaultProps, relativePath: "README.md" });
98 const nextContents = setMarkdownTaskChecked("- [ ] task\n", 2, true);97 changeHandler()("- [x] task\n");
99 changeHandler()(nextContents);
100 await vi.advanceTimersByTimeAsync(500);98 await vi.advanceTimersByTimeAsync(500);
101 expect(writeFile).toHaveBeenCalledExactlyOnceWith({99 expect(writeFile).toHaveBeenCalledExactlyOnceWith({
102 environmentId,100 environmentId,
docs/user/composer.md+8
...@@ -321,6 +321,14 @@ File links refer to the environment's machine, including when you connect remote...@@ -321,6 +321,14 @@ File links refer to the environment's machine, including when you connect remote
321Previews use the original file, even outside the workspace. Moving or deleting it321Previews use the original file, even outside the workspace. Moving or deleting it
322can break the preview, so save a copy if you need to keep it.322can break the preview, so save a copy if you need to keep it.
323323
324## Workspace files
325
326On web and desktop, Markdown files open in an editable preview. Select text to reveal
327its Markdown syntax; changes save automatically. Use the source toggle to edit raw
328Markdown. Control-click or Command-click a link to open it, or place the caret in the
329link and press Control+Enter or Command+Enter. Open files and folders update when
330files change on the environment's machine, including over a remote connection.
331
324## Files outside the workspace332## Files outside the workspace
325333
326Follow an agent's file link to read a report or other file outside the workspace.334Follow an agent's file link to read a report or other file outside the workspace.
packages/client-runtime/src/rpc/client.ts+1
...@@ -69,6 +69,7 @@ export type EnvironmentSubscriptionRpcTag =...@@ -69,6 +69,7 @@ export type EnvironmentSubscriptionRpcTag =
69 | typeof WS_METHODS.subscribeVcsStatus69 | typeof WS_METHODS.subscribeVcsStatus
70 | typeof WS_METHODS.subscribeWorktreeSetup70 | typeof WS_METHODS.subscribeWorktreeSetup
71 | typeof WS_METHODS.subscribeProjectClones71 | typeof WS_METHODS.subscribeProjectClones
72 | typeof WS_METHODS.projectsWatch
72 | typeof WS_METHODS.terminalAttach73 | typeof WS_METHODS.terminalAttach
73 | typeof WS_METHODS.terminalObserve;74 | typeof WS_METHODS.terminalObserve;
7475
packages/client-runtime/src/state/projectCommands.test.ts+45-1
...@@ -43,6 +43,7 @@ const PROJECT = {...@@ -43,6 +43,7 @@ const PROJECT = {
43const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () {43const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () {
44 const metadataRequests: string[][] = [];44 const metadataRequests: string[][] = [];
45 const file = { byteLength: 5 };45 const file = { byteLength: 5 };
46 const fileChanges = yield* SubscriptionRef.make(0);
46 const readResponse = { wait: Effect.void as Effect.Effect<void> };47 const readResponse = { wait: Effect.void as Effect.Effect<void> };
47 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({48 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
48 target: { environmentId: ENVIRONMENT_ID },49 target: { environmentId: ENVIRONMENT_ID },
...@@ -50,6 +51,7 @@ const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () {...@@ -50,6 +51,7 @@ const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () {
50 Option.some({51 Option.some({
51 client: {52 client: {
52 [WS_METHODS.projectsEnsureScratch]: () => Effect.succeed({ projectId: PROJECT_ID }),53 [WS_METHODS.projectsEnsureScratch]: () => Effect.succeed({ projectId: PROJECT_ID }),
54 [WS_METHODS.projectsWatch]: () => SubscriptionRef.changes(fileChanges),
53 [WS_METHODS.projectsReadFile]: () =>55 [WS_METHODS.projectsReadFile]: () =>
54 Effect.gen(function* () {56 Effect.gen(function* () {
55 const byteLength = file.byteLength;57 const byteLength = file.byteLength;
...@@ -122,7 +124,49 @@ const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () {...@@ -122,7 +124,49 @@ const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () {
122 const openScratch = Effect.promise(() =>124 const openScratch = Effect.promise(() =>
123 commands.openScratch.run(registry, { environmentId: ENVIRONMENT_ID, input: {} }),125 commands.openScratch.run(registry, { environmentId: ENVIRONMENT_ID, input: {} }),
124 );126 );
125 return { registry, storedProject, openScratch, commands, metadataRequests, readResponse };127 return {
128 registry,
129 storedProject,
130 openScratch,
131 commands,
132 metadataRequests,
133 readResponse,
134 file,
135 fileChanges,
136 };
137});
138
139describe("watched file reads", () => {
140 it.effect("refreshes a mounted file query when its host reports a disk change", () =>
141 Effect.gen(function* () {
142 const h = yield* makeHarness();
143 const watch = h.commands.watch({
144 environmentId: ENVIRONMENT_ID,
145 input: { cwd: "/repo", relativePath: "foo", directory: false },
146 });
147 const unwatch = h.registry.mount(watch);
148 yield* Effect.addFinalizer(() => Effect.sync(unwatch));
149 yield* AtomRegistry.getResult(h.registry, watch);
150 const query = h.commands.readFile({
151 environmentId: ENVIRONMENT_ID,
152 input: { cwd: "/repo", relativePath: "foo" },
153 });
154 const unmount = h.registry.mount(query);
155 yield* Effect.addFinalizer(() => Effect.sync(unmount));
156 expect(
157 (yield* AtomRegistry.getResult(h.registry, query, { suspendOnWaiting: true })).byteLength,
158 ).toBe(5);
159 const updated = yield* AtomRegistry.toStream(h.registry, query).pipe(
160 Stream.filter((result) => AsyncResult.isSuccess(result) && result.value.byteLength === 12),
161 Stream.runHead,
162 Effect.forkChild,
163 );
164 h.file.byteLength = 12;
165 yield* SubscriptionRef.set(h.fileChanges, 1);
166 const result = yield* Fiber.join(updated);
167 expect(Option.isSome(result)).toBe(true);
168 }).pipe(Effect.scoped),
169 );
126});170});
127171
128describe("writeFile metadata", () => {172describe("writeFile metadata", () => {
packages/client-runtime/src/state/projectCommands.ts+12
...@@ -25,6 +25,7 @@ import {...@@ -25,6 +25,7 @@ import {
25 createEnvironmentCommand,25 createEnvironmentCommand,
26 createEnvironmentRpcCommand,26 createEnvironmentRpcCommand,
27 createEnvironmentRpcQueryAtomFamily,27 createEnvironmentRpcQueryAtomFamily,
28 createEnvironmentRpcSubscriptionAtomFamily,
28} from "./runtime.ts";29} from "./runtime.ts";
29import {30import {
30 type CreateProjectInput,31 type CreateProjectInput,
...@@ -100,6 +101,11 @@ export function createProjectEnvironmentAtoms<R, E>(...@@ -100,6 +101,11 @@ export function createProjectEnvironmentAtoms<R, E>(
100 key: ({ environmentId, input }: { environmentId: string; input: { projectId: string } }) =>101 key: ({ environmentId, input }: { environmentId: string; input: { projectId: string } }) =>
101 JSON.stringify([environmentId, input.projectId]),102 JSON.stringify([environmentId, input.projectId]),
102 };103 };
104 const watch = createEnvironmentRpcSubscriptionAtomFamily(runtime, {
105 label: "environment-data:projects:watch",
106 tag: WS_METHODS.projectsWatch,
107 idleTtlMs: 0,
108 });
103 return {109 return {
104 fileMetadata: fileMetadata.metadata,110 fileMetadata: fileMetadata.metadata,
105 fileMetadataThread: fileMetadata.retainThread,111 fileMetadataThread: fileMetadata.retainThread,
...@@ -126,6 +132,7 @@ export function createProjectEnvironmentAtoms<R, E>(...@@ -126,6 +132,7 @@ export function createProjectEnvironmentAtoms<R, E>(
126 (result, isCurrent) => fileMetadata.rememberEntries(input.cwd, result.entries, isCurrent),132 (result, isCurrent) => fileMetadata.rememberEntries(input.cwd, result.entries, isCurrent),
127 ),133 ),
128 }),134 }),
135 watch,
129 listEntries: createEnvironmentRpcQueryAtomFamily(runtime, {136 listEntries: createEnvironmentRpcQueryAtomFamily(runtime, {
130 label: "environment-data:projects:list-entries",137 label: "environment-data:projects:list-entries",
131 tag: WS_METHODS.projectsListEntries,138 tag: WS_METHODS.projectsListEntries,
...@@ -140,6 +147,11 @@ export function createProjectEnvironmentAtoms<R, E>(...@@ -140,6 +147,11 @@ export function createProjectEnvironmentAtoms<R, E>(
140 readFile: createEnvironmentRpcQueryAtomFamily(runtime, {147 readFile: createEnvironmentRpcQueryAtomFamily(runtime, {
141 label: "environment-data:projects:read-file",148 label: "environment-data:projects:read-file",
142 tag: WS_METHODS.projectsReadFile,149 tag: WS_METHODS.projectsReadFile,
150 refreshTrigger: ({ environmentId, input }) =>
151 watch({
152 environmentId,
153 input: { ...input, directory: false },
154 }),
143 staleTimeMs: 30_000,155 staleTimeMs: 30_000,
144 idleTtlMs: 5 * 60_000,156 idleTtlMs: 5 * 60_000,
145 execute: (input) =>157 execute: (input) =>
packages/contracts/src/project.ts+7
...@@ -441,6 +441,12 @@ export const ProjectReadFileInput = Schema.Struct({...@@ -441,6 +441,12 @@ export const ProjectReadFileInput = Schema.Struct({
441});441});
442export type ProjectReadFileInput = typeof ProjectReadFileInput.Type;442export type ProjectReadFileInput = typeof ProjectReadFileInput.Type;
443443
444export const ProjectWatchInput = Schema.Struct({
445 ...ProjectReadFileInput.fields,
446 directory: Schema.Boolean,
447});
448export type ProjectWatchInput = typeof ProjectWatchInput.Type;
449
444export const ProjectReadFileResult = Schema.Struct({450export const ProjectReadFileResult = Schema.Struct({
445 relativePath: TrimmedNonEmptyString,451 relativePath: TrimmedNonEmptyString,
446 contents: Schema.String,452 contents: Schema.String,
...@@ -467,6 +473,7 @@ export const ProjectFileOperation = Schema.Literals([...@@ -467,6 +473,7 @@ export const ProjectFileOperation = Schema.Literals([
467 "close",473 "close",
468 "make-directory",474 "make-directory",
469 "write-file",475 "write-file",
476 "watch",
470]);477]);
471export type ProjectFileOperation = typeof ProjectFileOperation.Type;478export type ProjectFileOperation = typeof ProjectFileOperation.Type;
472479
packages/contracts/src/rpc.ts+10
...@@ -225,6 +225,7 @@ import {...@@ -225,6 +225,7 @@ import {
225 ProjectListEntriesResult,225 ProjectListEntriesResult,
226 ProjectReadFileError,226 ProjectReadFileError,
227 ProjectReadFileInput,227 ProjectReadFileInput,
228 ProjectWatchInput,
228 ProjectReadFileResult,229 ProjectReadFileResult,
229 ProjectSearchContentsError,230 ProjectSearchContentsError,
230 ProjectSearchContentsInput,231 ProjectSearchContentsInput,
...@@ -376,6 +377,7 @@ export const WS_METHODS = {...@@ -376,6 +377,7 @@ export const WS_METHODS = {
376 projectsRemove: "projects.remove",377 projectsRemove: "projects.remove",
377 projectsListEntries: "projects.listEntries",378 projectsListEntries: "projects.listEntries",
378 projectsReadFile: "projects.readFile",379 projectsReadFile: "projects.readFile",
380 projectsWatch: "projects.watch",
379 projectsSearchContents: "projects.searchContents",381 projectsSearchContents: "projects.searchContents",
380 projectsSearchEntries: "projects.searchEntries",382 projectsSearchEntries: "projects.searchEntries",
381 projectsWriteFile: "projects.writeFile",383 projectsWriteFile: "projects.writeFile",
...@@ -1233,6 +1235,13 @@ const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, {...@@ -1233,6 +1235,13 @@ const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, {
1233 error: Schema.Union([ProjectReadFileError, EnvironmentAuthorizationError]),1235 error: Schema.Union([ProjectReadFileError, EnvironmentAuthorizationError]),
1234});1236});
12351237
1238const WsProjectsWatchRpc = Rpc.make(WS_METHODS.projectsWatch, {
1239 payload: ProjectWatchInput,
1240 success: Schema.Number,
1241 error: Schema.Union([ProjectReadFileError, EnvironmentAuthorizationError]),
1242 stream: true,
1243});
1244
1236const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, {1245const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, {
1237 payload: ProjectWriteFileInput,1246 payload: ProjectWriteFileInput,
1238 success: ProjectWriteFileResult,1247 success: ProjectWriteFileResult,
...@@ -1994,6 +2003,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1994,6 +2003,7 @@ export const WsRpcGroup = RpcGroup.make(
1994 WsSubscribeProjectClonesRpc,2003 WsSubscribeProjectClonesRpc,
1995 WsProjectsListEntriesRpc,2004 WsProjectsListEntriesRpc,
1996 WsProjectsReadFileRpc,2005 WsProjectsReadFileRpc,
2006 WsProjectsWatchRpc,
1997 WsProjectsSearchContentsRpc,2007 WsProjectsSearchContentsRpc,
1998 WsProjectsSearchEntriesRpc,2008 WsProjectsSearchEntriesRpc,
1999 WsProjectsEnsureScratchRpc,2009 WsProjectsEnsureScratchRpc,