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 {
3232import * as RpcAuthorization from "./RpcAuthorization.ts";
3333
3434describe("RPC authorization scopes", () => {
35 it("requires filesystem read permission to watch file and folder changes", () => {
36 expect(requiredScopeForRpcMethod(WS_METHODS.projectsWatch)).toBe(AuthFilesystemReadScope);
37 });
3538 it("declares exactly one scope for every RPC in the server group", () => {
3639 expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys()));
3740 });
apps/server/src/auth/RpcAuthorization.ts+1
......@@ -149,6 +149,7 @@ export const RPC_REQUIRED_SCOPES = {
149149 [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope,
150150 [WS_METHODS.projectsListEntries]: AuthFilesystemReadScope,
151151 [WS_METHODS.projectsReadFile]: AuthFilesystemReadScope,
152 [WS_METHODS.projectsWatch]: AuthFilesystemReadScope,
152153 [WS_METHODS.projectsSearchContents]: AuthFilesystemReadScope,
153154 [WS_METHODS.projectsSearchEntries]: AuthFilesystemReadScope,
154155 [WS_METHODS.projectsWriteFile]: AuthFilesystemWriteScope,
apps/server/src/observability/RpcInstrumentation.ts+1
......@@ -136,6 +136,7 @@ const RPC_AGGREGATES = {
136136 [WS_METHODS.projectCloneRetry]: "source-control",
137137 [WS_METHODS.subscribeProjectClones]: "source-control",
138138 [WS_METHODS.projectsListEntries]: "workspace",
139 [WS_METHODS.projectsWatch]: "workspace",
139140 [WS_METHODS.projectsReadFile]: "workspace",
140141 [WS_METHODS.projectsSearchContents]: "workspace",
141142 [WS_METHODS.projectsSearchEntries]: "workspace",
apps/server/src/workspace/WorkspaceFileSystem.test.ts+168
......@@ -7,6 +7,7 @@ import * as Effect from "effect/Effect";
77import * as FileSystem from "effect/FileSystem";
88import * as Layer from "effect/Layer";
99import * as Path from "effect/Path";
10import * as Stream from "effect/Stream";
1011
1112import * as ServerConfig from "../config.ts";
1213import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
......@@ -57,6 +58,173 @@ const writeTextFile = Effect.fn("writeTextFile")(function* (
5758});
5859
5960it.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
60228 describe("getMetadata", () => {
61229 it.effect("expands home-relative paths on the server", () =>
62230 Effect.gen(function* () {
apps/server/src/workspace/WorkspaceFileSystem.ts+155-1
......@@ -17,6 +17,7 @@ import type {
1717 FilesystemGetMetadataInput,
1818 FilesystemGetMetadataResult,
1919 ProjectReadFileInput,
20 ProjectWatchInput,
2021 ProjectReadFileResult,
2122 ProjectWriteFileInput,
2223 ProjectWriteFileResult,
......@@ -24,11 +25,15 @@ import type {
2425import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
2526import * as HostProcess from "@t3tools/shared/HostProcess";
2627import * as Context from "effect/Context";
28import * as Clock from "effect/Clock";
29import * as Cause from "effect/Cause";
2730import * as Effect from "effect/Effect";
2831import * as FileSystem from "effect/FileSystem";
2932import * as Layer from "effect/Layer";
3033import * as Path from "effect/Path";
3134import * as Schema from "effect/Schema";
35import * as Stream from "effect/Stream";
36import * as Queue from "effect/Queue";
3237
3338import * as WorkspaceEntries from "./WorkspaceEntries.ts";
3439import * as WorkspacePaths from "./WorkspacePaths.ts";
......@@ -52,6 +57,7 @@ export class WorkspaceFileSystemOperationError extends Schema.TaggedError<Worksp
5257 "close",
5358 "make-directory",
5459 "write-file",
60 "watch",
5561 ]),
5662 cause: Schema.Defect(),
5763 },
......@@ -113,6 +119,13 @@ export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type;
113119export class WorkspaceFileSystem extends Context.Service<
114120 WorkspaceFileSystem,
115121 {
122 readonly watch: (
123 input: ProjectWatchInput,
124 ) => Stream.Stream<
125 number,
126 WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError
127 >;
128
116129 readonly getMetadata: (
117130 input: FilesystemGetMetadataInput,
118131 ) => Effect.Effect<FilesystemGetMetadataResult>;
......@@ -395,7 +408,148 @@ export const make = Effect.gen(function* () {
395408 return { relativePath: target.relativePath };
396409 });
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 });
399553});
400554
401555export const layer = Layer.effect(WorkspaceFileSystem, make);
apps/server/src/ws.ts+11
......@@ -2685,6 +2685,17 @@ const layerWsRpc = (
26852685 }),
26862686 ),
26872687 ),
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 ),
26882699 [WS_METHODS.projectsWriteFile]: (input) =>
26892700 workspaceFileSystem.writeFile(input).pipe(
26902701 Effect.mapError(
apps/web/src/components/ChatMarkdown.test.tsx+8-12
......@@ -15,7 +15,6 @@ import { describe, expect, it, vi } from "vite-plus/test";
1515import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
1616import { GitHubIcon } from "./Icons";
1717import { Button } from "./ui/button";
18import { setMarkdownTaskChecked } from "./files/filePreviewMode";
1918
2019vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null }));
2120vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
......@@ -778,19 +777,12 @@ describe("ChatMarkdown streaming", () => {
778777 }
779778 });
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 () => {
782781 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
783782 let renderer: ReactTestRenderer | undefined;
784 let editedText: string | undefined;
783 const taskChange = vi.fn();
785784 const message = (text: string) => (
786 <ChatMarkdown
787 cwd="/tmp/project"
788 text={text}
789 onTaskListChange={({ markerOffset, checked }) => {
790 editedText = setMarkdownTaskChecked(text, markerOffset, checked);
791 renderer!.update(message(editedText));
792 }}
793 />
785 <ChatMarkdown cwd="/tmp/project" text={text} onTaskListChange={taskChange} />
794786 );
795787
796788 try {
......@@ -819,7 +811,11 @@ describe("ChatMarkdown streaming", () => {
819811 });
820812
821813 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")));
823819 expect(mounted.root.findAllByType("input")[1]!.props.checked).toBe(true);
824820 } finally {
825821 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 @@
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";
4import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
15import { useOpenLink } from "~/browser/useOpenLink";
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
623export function FileMarkdownPreview(props: {
724 readonly cwd: string;
825 readonly relativePath: string;
926 readonly text: string;
1027 readonly threadRef: ScopedThreadRef;
11 readonly onTaskListChange?:
12 | ((input: { readonly markerOffset: number; readonly checked: boolean }) => void)
13 | undefined;
28 readonly draft: boolean;
29 readonly readOnly: boolean;
30 readonly revision: string | null;
31 readonly onChange: (text: string) => void;
1432}) {
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());
1540 const lastSeparator = Math.max(
1641 props.relativePath.lastIndexOf("/"),
1742 props.relativePath.lastIndexOf("\\"),
......@@ -20,15 +45,223 @@ export function FileMarkdownPreview(props: {
2045 lastSeparator >= 0
2146 ? resolvePathLinkTarget(props.relativePath.slice(0, lastSeparator), props.cwd)
2247 : 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
24261 return (
25 <ChatMarkdown
26 text={props.text}
27 cwd={props.cwd}
28 imageBaseDir={imageBaseDir}
29 threadRef={props.threadRef}
30 className="mx-auto max-w-4xl px-6 py-5"
31 onTaskListChange={props.onTaskListChange}
32 />
262 <>
263 <div ref={host} className="min-h-0 flex-1 overflow-hidden" data-file-markdown-editor />
264 {portals}
265 </>
33266 );
34267}
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 {
1212 filePreviewReadErrorMessage,
1313 isMarkdownPreviewFile,
1414 resolveFilePreviewPath,
15 setMarkdownTaskChecked,
1615 shouldShowFileExplorer,
1716 workspaceAssetResource,
1817} from "./filePreviewMode";
......@@ -156,21 +155,6 @@ describe("shouldShowFileExplorer", () => {
156155 });
157156});
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
174158describe("resolveFilePreviewPath", () => {
175159 it.each([
176160 ["/repo/project", null],
apps/web/src/components/files/FilePreviewPanel.tsx+39-30
......@@ -1,3 +1,6 @@
1import { useAtomValue } from "@effect/atom-react";
2import { AsyncResult, Atom } from "effect/reactivity";
3import { projectEnvironment } from "~/state/projects";
14import { Spinner } from "~/components/ui/spinner";
25import {
36 AuthPreviewOperateScope,
......@@ -104,13 +107,11 @@ import {
104107 filePreviewReadErrorMessage,
105108 isMarkdownPreviewFile,
106109 resolveFilePreviewPath,
107 setMarkdownTaskChecked,
108110 shouldShowFileExplorer,
109111 workspaceAssetResource,
110112} from "./filePreviewMode";
111113import { useFileSaveCoordinator } from "./useFileSaveCoordinator";
112114import {
113 getOptimisticProjectFileQueryData,
114115 getProjectFileContents,
115116 setProjectFileQueryData,
116117 useProjectFileQuery,
......@@ -134,6 +135,8 @@ interface FilePreviewPanelProps {
134135 workspaceMutationId: string | null;
135136}
136137
138const EMPTY_FILE_WATCH = Atom.make(AsyncResult.initial<number>());
139
137140const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";
138141const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown";
139142const RENDER_BROWSER_FILE_STORAGE_KEY = "t3code.renderBrowserFile";
......@@ -314,6 +317,7 @@ function WorkspaceVideoPreview(props: {
314317 revision={props.workspaceMutationId}
315318 preload="metadata"
316319 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"
317321 onRetry={refreshAssetUrl}
318322 actionsSource={{
319323 kind: "video",
......@@ -965,6 +969,8 @@ function RenderedMarkdownSurface({
965969 contents,
966970 threadRef,
967971 readOnly,
972 draft,
973 revision,
968974 onPendingChange,
969975}: Omit<
970976 EditableFileSurfaceProps,
......@@ -977,6 +983,8 @@ function RenderedMarkdownSurface({
977983> & {
978984 threadRef: ScopedThreadRef;
979985 readOnly: boolean;
986 draft: boolean;
987 revision: string | null;
980988}) {
981989 const saveCoordinator = useFileSaveCoordinator({
982990 environmentId,
......@@ -986,27 +994,20 @@ function RenderedMarkdownSurface({
986994 });
987995
988996 return (
989 <ScrollArea className="min-h-0 flex-1">
990 <FileMarkdownPreview
991 text={contents}
992 cwd={cwd}
993 relativePath={relativePath}
994 threadRef={threadRef}
995 onTaskListChange={
996 readOnly
997 ? undefined
998 : ({ markerOffset, checked }) => {
999 const currentContents =
1000 getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ??
1001 contents;
1002 const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked);
1003 if (nextContents === currentContents) return;
1004 setProjectFileQueryData(environmentId, cwd, relativePath, nextContents);
1005 saveCoordinator.change(nextContents);
1006 }
1007 }
1008 />
1009 </ScrollArea>
997 <FileMarkdownPreview
998 text={contents}
999 cwd={cwd}
1000 relativePath={relativePath}
1001 threadRef={threadRef}
1002 draft={draft}
1003 revision={revision}
1004 readOnly={readOnly}
1005 onChange={(nextContents) => {
1006 if (nextContents === getProjectFileContents(environmentId, cwd, relativePath)) return;
1007 setProjectFileQueryData(environmentId, cwd, relativePath, nextContents);
1008 saveCoordinator.change(nextContents);
1009 }}
1010 />
10101011 );
10111012}
10121013
......@@ -1040,7 +1041,7 @@ export default function FilePreviewPanel({
10401041 onOpenFile,
10411042 onPendingChange,
10421043 selectedFilePending,
1043 workspaceMutationId,
1044 workspaceMutationId: orchestrationMutationId,
10441045}: FilePreviewPanelProps) {
10451046 const relativePath =
10461047 attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath;
......@@ -1082,6 +1083,14 @@ export default function FilePreviewPanel({
10821083 relativePath,
10831084 attachment === undefined && relativePath !== null,
10841085 );
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;
10851094 const attemptedPath = file.readError?.resolvedPath ?? file.readError?.operationPath;
10861095 // A chat link cannot tell a folder from a file, so a folder arrives here as
10871096 // a file surface and the read fails. Keep the breadcrumbs, drop the preview
......@@ -1174,7 +1183,7 @@ export default function FilePreviewPanel({
11741183 // it notices when the path becomes a file.
11751184 (isDirectory || (!isMedia && !isPdf)) &&
11761185 !selectedFilePending,
1177 mutationId: workspaceMutationId,
1186 mutationId: orchestrationMutationId,
11781187 refresh: file.refresh,
11791188 resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`,
11801189 });
......@@ -1432,9 +1441,7 @@ export default function FilePreviewPanel({
14321441 </div>
14331442 ) : relativePath && file.data ? (
14341443 isMarkdown && renderMarkdown ? (
1435 // Markdown reconciles in place across text updates, so a file
1436 // switch needs a new key or the previous file's disclosure and
1437 // wrap state carries into the next document.
1444 // Each file owns its editor selection and undo history.
14381445 <RenderedMarkdownSurface
14391446 key={relativePath}
14401447 environmentId={environmentId}
......@@ -1442,7 +1449,9 @@ export default function FilePreviewPanel({
14421449 relativePath={relativePath}
14431450 threadRef={threadRef}
14441451 contents={file.data.contents}
1445 readOnly={isHostFile || !canWriteFiles}
1452 readOnly={isHostFile || !canWriteFiles || file.data.truncated}
1453 draft={draft}
1454 revision={workspaceMutationId}
14461455 onPendingChange={onPendingChange}
14471456 />
14481457 ) : tableDelimiter && renderTable ? (
......@@ -1495,7 +1504,7 @@ export default function FilePreviewPanel({
14951504 selectedPath={relativePath}
14961505 selectedPathRevealId={revealRequestId}
14971506 onOpenFile={onOpenFile}
1498 workspaceMutationId={workspaceMutationId}
1507 workspaceMutationId={orchestrationMutationId}
14991508 {...(previewPath && !isMedia && !isPdf
15001509 ? { onRefreshSelectedFile: file.refresh }
15011510 : {})}
apps/web/src/components/files/filePreviewMode.ts-17
......@@ -41,23 +41,6 @@ export function shouldShowFileExplorer(input: {
4141 return input.explorerOpen || input.relativePath === null;
4242}
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
6144/**
6245 * The asset for a file the panel shows. A draft has no thread on the server
6346 * 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(
265265 const result = useAtomValue(atom);
266266 const refreshAtom = useAtomRefresh(atom);
267267 const refresh = useCallback(() => refreshAtom(), [refreshAtom]);
268 const data = Option.getOrNull(AsyncResult.value(result));
269268 const optimisticResult = useAtomValue(
270269 optimisticFileAtom(environmentId, cwd, relativePath ?? EMPTY_PROJECT_FILE_PATH),
271270 );
272271 const optimisticFile = relativePath === null ? null : optimisticResult;
273272 const cause = failureCause(result);
274273 const readError = isProjectReadFileError(cause) ? cause : null;
274 const data = readError === null ? Option.getOrNull(AsyncResult.value(result)) : null;
275275
276276 return {
277277 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 @@
11import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
22import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
33import * as Cause from "effect/Cause";
4import { AsyncResult } from "effect/reactivity";
45import { useCallback, useEffect, useMemo, useRef, useState } from "react";
56
67import { appAtomRegistry } from "~/rpc/atomRegistry";
......@@ -17,17 +18,37 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
1718 const loaded = useRef(new Set<string>());
1819 const requested = useRef(new Set<string>());
1920 const active = useRef(true);
21 const watches = useRef(new Map<string, () => void>());
2022 const running = useRef(0);
2123 const waiting = useRef<Array<() => void>>([]);
2224
2325 const load = useCallback(
2426 function loadDirectory(directoryPath: string, refresh = false): Promise<void> {
27 if (!active.current) return Promise.resolve();
2528 const existing = requests.current.get(directoryPath);
2629 if (existing)
2730 return refresh ? existing.then(() => loadDirectory(directoryPath, true)) : existing;
2831 if (!refresh && loaded.current.has(directoryPath)) return Promise.resolve();
2932 loaded.current.add(directoryPath);
3033 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 }
3152 const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } });
3253 setPending((count) => count + 1);
3354 if (!refresh) setLoadingDirectories((previous) => new Set(previous).add(directoryPath));
......@@ -98,6 +119,9 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
98119 void load("");
99120 return () => {
100121 active.current = false;
122 for (const stop of watches.current.values()) stop();
123 watches.current.clear();
124 loaded.current.clear();
101125 };
102126 }, [load]);
103127
apps/web/src/components/files/useFileSaveCoordinator.test.tsx+2-4
......@@ -21,7 +21,6 @@ vi.mock("./projectFilesQueryState", () => ({
2121 getUnsavedProjectFileQueryData: getUnsavedFile,
2222}));
2323
24import { setMarkdownTaskChecked } from "./filePreviewMode";
2524import { useFileSaveCoordinator } from "./useFileSaveCoordinator";
2625
2726const environmentId = EnvironmentId.make("save-lifecycle-audit");
......@@ -93,10 +92,9 @@ describe("file-save React lifecycle", () => {
9392 expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", false);
9493 });
9594
96 it("persists rendered Markdown task changes after StrictMode setup replay", async () => {
95 it("persists Markdown edits after StrictMode setup replay", async () => {
9796 mount({ ...defaultProps, relativePath: "README.md" });
98 const nextContents = setMarkdownTaskChecked("- [ ] task\n", 2, true);
99 changeHandler()(nextContents);
97 changeHandler()("- [x] task\n");
10098 await vi.advanceTimersByTimeAsync(500);
10199 expect(writeFile).toHaveBeenCalledExactlyOnceWith({
102100 environmentId,
docs/user/composer.md+8
......@@ -321,6 +321,14 @@ File links refer to the environment's machine, including when you connect remote
321321Previews use the original file, even outside the workspace. Moving or deleting it
322322can 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
324332## Files outside the workspace
325333
326334Follow 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 =
6969 | typeof WS_METHODS.subscribeVcsStatus
7070 | typeof WS_METHODS.subscribeWorktreeSetup
7171 | typeof WS_METHODS.subscribeProjectClones
72 | typeof WS_METHODS.projectsWatch
7273 | typeof WS_METHODS.terminalAttach
7374 | typeof WS_METHODS.terminalObserve;
7475
packages/client-runtime/src/state/projectCommands.test.ts+45-1
......@@ -43,6 +43,7 @@ const PROJECT = {
4343const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () {
4444 const metadataRequests: string[][] = [];
4545 const file = { byteLength: 5 };
46 const fileChanges = yield* SubscriptionRef.make(0);
4647 const readResponse = { wait: Effect.void as Effect.Effect<void> };
4748 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
4849 target: { environmentId: ENVIRONMENT_ID },
......@@ -50,6 +51,7 @@ const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () {
5051 Option.some({
5152 client: {
5253 [WS_METHODS.projectsEnsureScratch]: () => Effect.succeed({ projectId: PROJECT_ID }),
54 [WS_METHODS.projectsWatch]: () => SubscriptionRef.changes(fileChanges),
5355 [WS_METHODS.projectsReadFile]: () =>
5456 Effect.gen(function* () {
5557 const byteLength = file.byteLength;
......@@ -122,7 +124,49 @@ const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () {
122124 const openScratch = Effect.promise(() =>
123125 commands.openScratch.run(registry, { environmentId: ENVIRONMENT_ID, input: {} }),
124126 );
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 );
126170});
127171
128172describe("writeFile metadata", () => {
packages/client-runtime/src/state/projectCommands.ts+12
......@@ -25,6 +25,7 @@ import {
2525 createEnvironmentCommand,
2626 createEnvironmentRpcCommand,
2727 createEnvironmentRpcQueryAtomFamily,
28 createEnvironmentRpcSubscriptionAtomFamily,
2829} from "./runtime.ts";
2930import {
3031 type CreateProjectInput,
......@@ -100,6 +101,11 @@ export function createProjectEnvironmentAtoms<R, E>(
100101 key: ({ environmentId, input }: { environmentId: string; input: { projectId: string } }) =>
101102 JSON.stringify([environmentId, input.projectId]),
102103 };
104 const watch = createEnvironmentRpcSubscriptionAtomFamily(runtime, {
105 label: "environment-data:projects:watch",
106 tag: WS_METHODS.projectsWatch,
107 idleTtlMs: 0,
108 });
103109 return {
104110 fileMetadata: fileMetadata.metadata,
105111 fileMetadataThread: fileMetadata.retainThread,
......@@ -126,6 +132,7 @@ export function createProjectEnvironmentAtoms<R, E>(
126132 (result, isCurrent) => fileMetadata.rememberEntries(input.cwd, result.entries, isCurrent),
127133 ),
128134 }),
135 watch,
129136 listEntries: createEnvironmentRpcQueryAtomFamily(runtime, {
130137 label: "environment-data:projects:list-entries",
131138 tag: WS_METHODS.projectsListEntries,
......@@ -140,6 +147,11 @@ export function createProjectEnvironmentAtoms<R, E>(
140147 readFile: createEnvironmentRpcQueryAtomFamily(runtime, {
141148 label: "environment-data:projects:read-file",
142149 tag: WS_METHODS.projectsReadFile,
150 refreshTrigger: ({ environmentId, input }) =>
151 watch({
152 environmentId,
153 input: { ...input, directory: false },
154 }),
143155 staleTimeMs: 30_000,
144156 idleTtlMs: 5 * 60_000,
145157 execute: (input) =>
packages/contracts/src/project.ts+7
......@@ -441,6 +441,12 @@ export const ProjectReadFileInput = Schema.Struct({
441441});
442442export 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
444450export const ProjectReadFileResult = Schema.Struct({
445451 relativePath: TrimmedNonEmptyString,
446452 contents: Schema.String,
......@@ -467,6 +473,7 @@ export const ProjectFileOperation = Schema.Literals([
467473 "close",
468474 "make-directory",
469475 "write-file",
476 "watch",
470477]);
471478export type ProjectFileOperation = typeof ProjectFileOperation.Type;
472479
packages/contracts/src/rpc.ts+10
......@@ -225,6 +225,7 @@ import {
225225 ProjectListEntriesResult,
226226 ProjectReadFileError,
227227 ProjectReadFileInput,
228 ProjectWatchInput,
228229 ProjectReadFileResult,
229230 ProjectSearchContentsError,
230231 ProjectSearchContentsInput,
......@@ -376,6 +377,7 @@ export const WS_METHODS = {
376377 projectsRemove: "projects.remove",
377378 projectsListEntries: "projects.listEntries",
378379 projectsReadFile: "projects.readFile",
380 projectsWatch: "projects.watch",
379381 projectsSearchContents: "projects.searchContents",
380382 projectsSearchEntries: "projects.searchEntries",
381383 projectsWriteFile: "projects.writeFile",
......@@ -1233,6 +1235,13 @@ const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, {
12331235 error: Schema.Union([ProjectReadFileError, EnvironmentAuthorizationError]),
12341236});
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
12361245const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, {
12371246 payload: ProjectWriteFileInput,
12381247 success: ProjectWriteFileResult,
......@@ -1994,6 +2003,7 @@ export const WsRpcGroup = RpcGroup.make(
19942003 WsSubscribeProjectClonesRpc,
19952004 WsProjectsListEntriesRpc,
19962005 WsProjectsReadFileRpc,
2006 WsProjectsWatchRpc,
19972007 WsProjectsSearchContentsRpc,
19982008 WsProjectsSearchEntriesRpc,
19992009 WsProjectsEnsureScratchRpc,