| author | |
| committer | |
| log | 3bc314d088f34759f6ecac21eda3767529f5466c |
| tree | 281f968be9515b4be480f4d4f50c77bb1b5e0a91 |
| parent | 96a2bb7293207bd038d5eb5754b664d9b1c58e5c |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover) |
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-sol23 files changed, 1189 insertions(+), 96 deletions(-)
apps/server/src/auth/RpcAuthorization.test.ts+3| ... | ... | @@ -32,6 +32,9 @@ import { |
| 32 | 32 | import * as RpcAuthorization from "./RpcAuthorization.ts"; |
| 33 | 33 | |
| 34 | 34 | describe("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 | 38 | it("declares exactly one scope for every RPC in the server group", () => { |
| 36 | 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 | 149 | [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope, |
| 150 | 150 | [WS_METHODS.projectsListEntries]: AuthFilesystemReadScope, |
| 151 | 151 | [WS_METHODS.projectsReadFile]: AuthFilesystemReadScope, |
| 152 | [WS_METHODS.projectsWatch]: AuthFilesystemReadScope, | |
| 152 | 153 | [WS_METHODS.projectsSearchContents]: AuthFilesystemReadScope, |
| 153 | 154 | [WS_METHODS.projectsSearchEntries]: AuthFilesystemReadScope, |
| 154 | 155 | [WS_METHODS.projectsWriteFile]: AuthFilesystemWriteScope, |
apps/server/src/observability/RpcInstrumentation.ts+1| ... | ... | @@ -136,6 +136,7 @@ const RPC_AGGREGATES = { |
| 136 | 136 | [WS_METHODS.projectCloneRetry]: "source-control", |
| 137 | 137 | [WS_METHODS.subscribeProjectClones]: "source-control", |
| 138 | 138 | [WS_METHODS.projectsListEntries]: "workspace", |
| 139 | [WS_METHODS.projectsWatch]: "workspace", | |
| 139 | 140 | [WS_METHODS.projectsReadFile]: "workspace", |
| 140 | 141 | [WS_METHODS.projectsSearchContents]: "workspace", |
| 141 | 142 | [WS_METHODS.projectsSearchEntries]: "workspace", |
apps/server/src/workspace/WorkspaceFileSystem.test.ts+168| ... | ... | @@ -7,6 +7,7 @@ import * as Effect from "effect/Effect"; |
| 7 | 7 | import * as FileSystem from "effect/FileSystem"; |
| 8 | 8 | import * as Layer from "effect/Layer"; |
| 9 | 9 | import * as Path from "effect/Path"; |
| 10 | import * as Stream from "effect/Stream"; | |
| 10 | 11 | |
| 11 | 12 | import * as ServerConfig from "../config.ts"; |
| 12 | 13 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| ... | ... | @@ -57,6 +58,173 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( |
| 57 | 58 | }); |
| 58 | 59 | |
| 59 | 60 | it.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 | 228 | describe("getMetadata", () => { |
| 61 | 229 | it.effect("expands home-relative paths on the server", () => |
| 62 | 230 | Effect.gen(function* () { |
apps/server/src/workspace/WorkspaceFileSystem.ts+155-1| ... | ... | @@ -17,6 +17,7 @@ import type { |
| 17 | 17 | FilesystemGetMetadataInput, |
| 18 | 18 | FilesystemGetMetadataResult, |
| 19 | 19 | ProjectReadFileInput, |
| 20 | ProjectWatchInput, | |
| 20 | 21 | ProjectReadFileResult, |
| 21 | 22 | ProjectWriteFileInput, |
| 22 | 23 | ProjectWriteFileResult, |
| ... | ... | @@ -24,11 +25,15 @@ import type { |
| 24 | 25 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| 25 | 26 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 26 | 27 | import * as Context from "effect/Context"; |
| 28 | import * as Clock from "effect/Clock"; | |
| 29 | import * as Cause from "effect/Cause"; | |
| 27 | 30 | import * as Effect from "effect/Effect"; |
| 28 | 31 | import * as FileSystem from "effect/FileSystem"; |
| 29 | 32 | import * as Layer from "effect/Layer"; |
| 30 | 33 | import * as Path from "effect/Path"; |
| 31 | 34 | import * as Schema from "effect/Schema"; |
| 35 | import * as Stream from "effect/Stream"; | |
| 36 | import * as Queue from "effect/Queue"; | |
| 32 | 37 | |
| 33 | 38 | import * as WorkspaceEntries from "./WorkspaceEntries.ts"; |
| 34 | 39 | import * as WorkspacePaths from "./WorkspacePaths.ts"; |
| ... | ... | @@ -52,6 +57,7 @@ export class WorkspaceFileSystemOperationError extends Schema.TaggedError<Worksp |
| 52 | 57 | "close", |
| 53 | 58 | "make-directory", |
| 54 | 59 | "write-file", |
| 60 | "watch", | |
| 55 | 61 | ]), |
| 56 | 62 | cause: Schema.Defect(), |
| 57 | 63 | }, |
| ... | ... | @@ -113,6 +119,13 @@ export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; |
| 113 | 119 | export class WorkspaceFileSystem extends Context.Service< |
| 114 | 120 | WorkspaceFileSystem, |
| 115 | 121 | { |
| 122 | readonly watch: ( | |
| 123 | input: ProjectWatchInput, | |
| 124 | ) => Stream.Stream< | |
| 125 | number, | |
| 126 | WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError | |
| 127 | >; | |
| 128 | ||
| 116 | 129 | readonly getMetadata: ( |
| 117 | 130 | input: FilesystemGetMetadataInput, |
| 118 | 131 | ) => Effect.Effect<FilesystemGetMetadataResult>; |
| ... | ... | @@ -395,7 +408,148 @@ export const make = Effect.gen(function* () { |
| 395 | 408 | return { relativePath: target.relativePath }; |
| 396 | 409 | }); |
| 397 | 410 | |
| 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 | }); |
| 400 | 554 | |
| 401 | 555 | export const layer = Layer.effect(WorkspaceFileSystem, make); |
apps/server/src/ws.ts+11| ... | ... | @@ -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 | 2699 | [WS_METHODS.projectsWriteFile]: (input) => |
| 2689 | 2700 | workspaceFileSystem.writeFile(input).pipe( |
| 2690 | 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 | 15 | import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; |
| 16 | 16 | import { GitHubIcon } from "./Icons"; |
| 17 | 17 | import { Button } from "./ui/button"; |
| 18 | import { setMarkdownTaskChecked } from "./files/filePreviewMode"; | |
| 19 | 18 | |
| 20 | 19 | vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null })); |
| 21 | 20 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); |
| ... | ... | @@ -778,19 +777,12 @@ describe("ChatMarkdown streaming", () => { |
| 778 | 777 | } |
| 779 | 778 | }); |
| 780 | 779 | |
| 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 | 781 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 783 | 782 | let renderer: ReactTestRenderer | undefined; |
| 784 | let editedText: string | undefined; | |
| 783 | const taskChange = vi.fn(); | |
| 785 | 784 | 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} /> | |
| 794 | 786 | ); |
| 795 | 787 | |
| 796 | 788 | try { |
| ... | ... | @@ -819,7 +811,11 @@ describe("ChatMarkdown streaming", () => { |
| 819 | 811 | }); |
| 820 | 812 | |
| 821 | 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 | 819 | expect(mounted.root.findAllByType("input")[1]!.props.checked).toBe(true); |
| 824 | 820 | } finally { |
| 825 | 821 | await act(async () => renderer?.unmount()); |
apps/web/src/components/files/FileMarkdownPreview.test.tsx created+237| ... | ... | @@ -0,0 +1,237 @@ |
| 1 | // @vitest-environment jsdom | |
| 2 | import { EditorView } from "@codemirror/view"; | |
| 3 | import { act } from "react"; | |
| 4 | import { createRoot, type Root } from "react-dom/client"; | |
| 5 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 7 | ||
| 8 | const externalLink = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); | |
| 9 | const fileLink = vi.hoisted(() => vi.fn()); | |
| 10 | const media = vi.hoisted(() => ({ source: vi.fn(), showMenu: vi.fn() })); | |
| 11 | vi.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 | })); | |
| 24 | vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => externalLink })); | |
| 25 | vi.mock("~/rightPanelStore", () => ({ | |
| 26 | useRightPanelStore: { getState: () => ({ openFile: fileLink }) }, | |
| 27 | })); | |
| 28 | vi.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 | })); | |
| 34 | vi.mock("~/components/composerCodeBlockHighlight", () => ({ composerCodeBlockHighlight: [] })); | |
| 35 | ||
| 36 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; | |
| 37 | import { MediaPlaybackProvider } from "../media/MediaPlaybackHost"; | |
| 38 | ||
| 39 | let root: Root; | |
| 40 | let container: HTMLDivElement; | |
| 41 | const change = vi.fn(); | |
| 42 | let playing: WeakSet<HTMLMediaElement>; | |
| 43 | const threadRef = { | |
| 44 | environmentId: EnvironmentId.make("environment"), | |
| 45 | threadId: ThreadId.make("thread"), | |
| 46 | }; | |
| 47 | ||
| 48 | beforeEach(() => { | |
| 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 | }); | |
| 84 | afterEach(async () => { | |
| 85 | await act(() => root.unmount()); | |
| 86 | container.remove(); | |
| 87 | vi.restoreAllMocks(); | |
| 88 | vi.unstubAllGlobals(); | |
| 89 | }); | |
| 90 | async 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 | ||
| 110 | describe("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(""); | |
| 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 = ""; | |
| 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("", 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 = ""; | |
| 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 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 1 | import { createMarkdownEditor, livePreview, type MarkdownEditor } from "@clo/markdown-editor"; | |
| 2 | import { syntaxTree } from "@codemirror/language"; | |
| 3 | import { Compartment, EditorState, Prec, Transaction } from "@codemirror/state"; | |
| 4 | import { EditorView } from "@codemirror/view"; | |
| 5 | import type { AssetResource, ScopedThreadRef } from "@t3tools/contracts"; | |
| 6 | import { | |
| 7 | classifyMarkdownImageSource, | |
| 8 | markdownImageSourceFragment, | |
| 9 | } from "@t3tools/client-runtime/markdown-images"; | |
| 10 | import { workspaceRelativeFilePath } from "@t3tools/shared/path"; | |
| 11 | import { resolveMarkdownFileLinkTarget } from "@t3tools/shared/markdownLinks"; | |
| 12 | import { resolvePathLinkTarget, splitFilePathPosition } from "@t3tools/shared/fileLinks"; | |
| 13 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 2 | 14 | |
| 3 | import ChatMarkdown from "~/components/ChatMarkdown"; | |
| 4 | import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; | |
| 15 | import { useOpenLink } from "~/browser/useOpenLink"; | |
| 16 | import { useRightPanelStore } from "~/rightPanelStore"; | |
| 17 | import { toastManager } from "~/components/ui/toast"; | |
| 18 | import { useAssetUrls } from "~/assets/assetUrls"; | |
| 19 | import { composerCodeBlockHighlight } from "~/components/composerCodeBlockHighlight"; | |
| 20 | import { workspaceAssetResource } from "./filePreviewMode"; | |
| 21 | import { useMarkdownVideos } from "./FileMarkdownVideos"; | |
| 5 | 22 | |
| 6 | 23 | export function FileMarkdownPreview(props: { |
| 7 | 24 | readonly cwd: string; |
| 8 | 25 | readonly relativePath: string; |
| 9 | 26 | readonly text: string; |
| 10 | 27 | 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; | |
| 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 | 40 | const lastSeparator = Math.max( |
| 16 | 41 | props.relativePath.lastIndexOf("/"), |
| 17 | 42 | props.relativePath.lastIndexOf("\\"), |
| ... | ... | @@ -20,15 +45,223 @@ export function FileMarkdownPreview(props: { |
| 20 | 45 | lastSeparator >= 0 |
| 21 | 46 | ? resolvePathLinkTarget(props.relativePath.slice(0, lastSeparator), props.cwd) |
| 22 | 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]); | |
| 23 | 260 | |
| 24 | 261 | 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 | </> | |
| 33 | 266 | ); |
| 34 | 267 | } |
apps/web/src/components/files/FileMarkdownVideos.tsx created+96| ... | ... | @@ -0,0 +1,96 @@ |
| 1 | import type { VideoPreviewInput, VideoPreviewRenderer } from "@clo/markdown-editor"; | |
| 2 | import type { EditorView } from "@codemirror/view"; | |
| 3 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; | |
| 4 | import { | |
| 5 | mediaFileReference, | |
| 6 | mediaReferenceFileName, | |
| 7 | mediaUrlReference, | |
| 8 | } from "@t3tools/client-runtime/media-reference"; | |
| 9 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 10 | import { useCallback, useLayoutEffect, useRef, useState } from "react"; | |
| 11 | import { createPortal } from "react-dom"; | |
| 12 | ||
| 13 | import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; | |
| 14 | import { workspaceAssetResource } from "./filePreviewMode"; | |
| 15 | ||
| 16 | export 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 | 12 | filePreviewReadErrorMessage, |
| 13 | 13 | isMarkdownPreviewFile, |
| 14 | 14 | resolveFilePreviewPath, |
| 15 | setMarkdownTaskChecked, | |
| 16 | 15 | shouldShowFileExplorer, |
| 17 | 16 | workspaceAssetResource, |
| 18 | 17 | } from "./filePreviewMode"; |
| ... | ... | @@ -156,21 +155,6 @@ describe("shouldShowFileExplorer", () => { |
| 156 | 155 | }); |
| 157 | 156 | }); |
| 158 | 157 | |
| 159 | describe("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 | ||
| 174 | 158 | describe("resolveFilePreviewPath", () => { |
| 175 | 159 | it.each([ |
| 176 | 160 | ["/repo/project", null], |
apps/web/src/components/files/FilePreviewPanel.tsx+39-30| ... | ... | @@ -1,3 +1,6 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { AsyncResult, Atom } from "effect/reactivity"; | |
| 3 | import { projectEnvironment } from "~/state/projects"; | |
| 1 | 4 | import { Spinner } from "~/components/ui/spinner"; |
| 2 | 5 | import { |
| 3 | 6 | AuthPreviewOperateScope, |
| ... | ... | @@ -104,13 +107,11 @@ import { |
| 104 | 107 | filePreviewReadErrorMessage, |
| 105 | 108 | isMarkdownPreviewFile, |
| 106 | 109 | resolveFilePreviewPath, |
| 107 | setMarkdownTaskChecked, | |
| 108 | 110 | shouldShowFileExplorer, |
| 109 | 111 | workspaceAssetResource, |
| 110 | 112 | } from "./filePreviewMode"; |
| 111 | 113 | import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; |
| 112 | 114 | import { |
| 113 | getOptimisticProjectFileQueryData, | |
| 114 | 115 | getProjectFileContents, |
| 115 | 116 | setProjectFileQueryData, |
| 116 | 117 | useProjectFileQuery, |
| ... | ... | @@ -134,6 +135,8 @@ interface FilePreviewPanelProps { |
| 134 | 135 | workspaceMutationId: string | null; |
| 135 | 136 | } |
| 136 | 137 | |
| 138 | const EMPTY_FILE_WATCH = Atom.make(AsyncResult.initial<number>()); | |
| 139 | ||
| 137 | 140 | const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; |
| 138 | 141 | const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown"; |
| 139 | 142 | const RENDER_BROWSER_FILE_STORAGE_KEY = "t3code.renderBrowserFile"; |
| ... | ... | @@ -314,6 +317,7 @@ function WorkspaceVideoPreview(props: { |
| 314 | 317 | revision={props.workspaceMutationId} |
| 315 | 318 | preload="metadata" |
| 316 | 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 | 321 | onRetry={refreshAssetUrl} |
| 318 | 322 | actionsSource={{ |
| 319 | 323 | kind: "video", |
| ... | ... | @@ -965,6 +969,8 @@ function RenderedMarkdownSurface({ |
| 965 | 969 | contents, |
| 966 | 970 | threadRef, |
| 967 | 971 | readOnly, |
| 972 | draft, | |
| 973 | revision, | |
| 968 | 974 | onPendingChange, |
| 969 | 975 | }: Omit< |
| 970 | 976 | EditableFileSurfaceProps, |
| ... | ... | @@ -977,6 +983,8 @@ function RenderedMarkdownSurface({ |
| 977 | 983 | > & { |
| 978 | 984 | threadRef: ScopedThreadRef; |
| 979 | 985 | readOnly: boolean; |
| 986 | draft: boolean; | |
| 987 | revision: string | null; | |
| 980 | 988 | }) { |
| 981 | 989 | const saveCoordinator = useFileSaveCoordinator({ |
| 982 | 990 | environmentId, |
| ... | ... | @@ -986,27 +994,20 @@ function RenderedMarkdownSurface({ |
| 986 | 994 | }); |
| 987 | 995 | |
| 988 | 996 | 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 | /> | |
| 1010 | 1011 | ); |
| 1011 | 1012 | } |
| 1012 | 1013 | |
| ... | ... | @@ -1040,7 +1041,7 @@ export default function FilePreviewPanel({ |
| 1040 | 1041 | onOpenFile, |
| 1041 | 1042 | onPendingChange, |
| 1042 | 1043 | selectedFilePending, |
| 1043 | workspaceMutationId, | |
| 1044 | workspaceMutationId: orchestrationMutationId, | |
| 1044 | 1045 | }: FilePreviewPanelProps) { |
| 1045 | 1046 | const relativePath = |
| 1046 | 1047 | attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath; |
| ... | ... | @@ -1082,6 +1083,14 @@ export default function FilePreviewPanel({ |
| 1082 | 1083 | relativePath, |
| 1083 | 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 | 1094 | const attemptedPath = file.readError?.resolvedPath ?? file.readError?.operationPath; |
| 1086 | 1095 | // A chat link cannot tell a folder from a file, so a folder arrives here as |
| 1087 | 1096 | // a file surface and the read fails. Keep the breadcrumbs, drop the preview |
| ... | ... | @@ -1174,7 +1183,7 @@ export default function FilePreviewPanel({ |
| 1174 | 1183 | // it notices when the path becomes a file. |
| 1175 | 1184 | (isDirectory || (!isMedia && !isPdf)) && |
| 1176 | 1185 | !selectedFilePending, |
| 1177 | mutationId: workspaceMutationId, | |
| 1186 | mutationId: orchestrationMutationId, | |
| 1178 | 1187 | refresh: file.refresh, |
| 1179 | 1188 | resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`, |
| 1180 | 1189 | }); |
| ... | ... | @@ -1432,9 +1441,7 @@ export default function FilePreviewPanel({ |
| 1432 | 1441 | </div> |
| 1433 | 1442 | ) : relativePath && file.data ? ( |
| 1434 | 1443 | 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. | |
| 1438 | 1445 | <RenderedMarkdownSurface |
| 1439 | 1446 | key={relativePath} |
| 1440 | 1447 | environmentId={environmentId} |
| ... | ... | @@ -1442,7 +1449,9 @@ export default function FilePreviewPanel({ |
| 1442 | 1449 | relativePath={relativePath} |
| 1443 | 1450 | threadRef={threadRef} |
| 1444 | 1451 | contents={file.data.contents} |
| 1445 | readOnly={isHostFile || !canWriteFiles} | |
| 1452 | readOnly={isHostFile || !canWriteFiles || file.data.truncated} | |
| 1453 | draft={draft} | |
| 1454 | revision={workspaceMutationId} | |
| 1446 | 1455 | onPendingChange={onPendingChange} |
| 1447 | 1456 | /> |
| 1448 | 1457 | ) : tableDelimiter && renderTable ? ( |
| ... | ... | @@ -1495,7 +1504,7 @@ export default function FilePreviewPanel({ |
| 1495 | 1504 | selectedPath={relativePath} |
| 1496 | 1505 | selectedPathRevealId={revealRequestId} |
| 1497 | 1506 | onOpenFile={onOpenFile} |
| 1498 | workspaceMutationId={workspaceMutationId} | |
| 1507 | workspaceMutationId={orchestrationMutationId} | |
| 1499 | 1508 | {...(previewPath && !isMedia && !isPdf |
| 1500 | 1509 | ? { onRefreshSelectedFile: file.refresh } |
| 1501 | 1510 | : {})} |
apps/web/src/components/files/filePreviewMode.ts-17| ... | ... | @@ -41,23 +41,6 @@ export function shouldShowFileExplorer(input: { |
| 41 | 41 | return input.explorerOpen || input.relativePath === null; |
| 42 | 42 | } |
| 43 | 43 | |
| 44 | export 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 | 45 | * The asset for a file the panel shows. A draft has no thread on the server |
| 63 | 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 | 265 | const result = useAtomValue(atom); |
| 266 | 266 | const refreshAtom = useAtomRefresh(atom); |
| 267 | 267 | const refresh = useCallback(() => refreshAtom(), [refreshAtom]); |
| 268 | const data = Option.getOrNull(AsyncResult.value(result)); | |
| 269 | 268 | const optimisticResult = useAtomValue( |
| 270 | 269 | optimisticFileAtom(environmentId, cwd, relativePath ?? EMPTY_PROJECT_FILE_PATH), |
| 271 | 270 | ); |
| 272 | 271 | const optimisticFile = relativePath === null ? null : optimisticResult; |
| 273 | 272 | const cause = failureCause(result); |
| 274 | 273 | const readError = isProjectReadFileError(cause) ? cause : null; |
| 274 | const data = readError === null ? Option.getOrNull(AsyncResult.value(result)) : null; | |
| 275 | 275 | |
| 276 | 276 | return { |
| 277 | 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 | |
| 2 | import type { AsyncResult as AsyncResultType } from "effect/reactivity"; | |
| 3 | import type { ProjectEntry } from "@t3tools/contracts"; | |
| 4 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 5 | import { act, useLayoutEffect } from "react"; | |
| 6 | import { createRoot, type Root } from "react-dom/client"; | |
| 7 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 8 | ||
| 9 | const 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 | })); | |
| 15 | vi.mock("~/rpc/atomRegistry", async () => { | |
| 16 | const { AtomRegistry } = await import("effect/reactivity"); | |
| 17 | return { appAtomRegistry: AtomRegistry.make() }; | |
| 18 | }); | |
| 19 | vi.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 | ||
| 50 | import { useDirectoryEntries } from "./useDirectoryEntries"; | |
| 51 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | |
| 52 | import { projectEnvironment } from "~/state/projects"; | |
| 53 | ||
| 54 | let root: Root; | |
| 55 | let container: HTMLDivElement; | |
| 56 | let folders: ReturnType<typeof useDirectoryEntries>; | |
| 57 | function 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 | } | |
| 64 | beforeEach(() => { | |
| 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 | }); | |
| 73 | afterEach(async () => { | |
| 74 | await act(() => root.unmount()); | |
| 75 | appAtomRegistry.reset(); | |
| 76 | container.remove(); | |
| 77 | vi.unstubAllGlobals(); | |
| 78 | }); | |
| 79 | ||
| 80 | it("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 | ||
| 101 | it("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 | 1 | import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; |
| 2 | 2 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; |
| 3 | 3 | import * as Cause from "effect/Cause"; |
| 4 | import { AsyncResult } from "effect/reactivity"; | |
| 4 | 5 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 5 | 6 | |
| 6 | 7 | import { appAtomRegistry } from "~/rpc/atomRegistry"; |
| ... | ... | @@ -17,17 +18,37 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { |
| 17 | 18 | const loaded = useRef(new Set<string>()); |
| 18 | 19 | const requested = useRef(new Set<string>()); |
| 19 | 20 | const active = useRef(true); |
| 21 | const watches = useRef(new Map<string, () => void>()); | |
| 20 | 22 | const running = useRef(0); |
| 21 | 23 | const waiting = useRef<Array<() => void>>([]); |
| 22 | 24 | |
| 23 | 25 | const load = useCallback( |
| 24 | 26 | function loadDirectory(directoryPath: string, refresh = false): Promise<void> { |
| 27 | if (!active.current) return Promise.resolve(); | |
| 25 | 28 | const existing = requests.current.get(directoryPath); |
| 26 | 29 | if (existing) |
| 27 | 30 | return refresh ? existing.then(() => loadDirectory(directoryPath, true)) : existing; |
| 28 | 31 | if (!refresh && loaded.current.has(directoryPath)) return Promise.resolve(); |
| 29 | 32 | loaded.current.add(directoryPath); |
| 30 | 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 | 52 | const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } }); |
| 32 | 53 | setPending((count) => count + 1); |
| 33 | 54 | if (!refresh) setLoadingDirectories((previous) => new Set(previous).add(directoryPath)); |
| ... | ... | @@ -98,6 +119,9 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { |
| 98 | 119 | void load(""); |
| 99 | 120 | return () => { |
| 100 | 121 | active.current = false; |
| 122 | for (const stop of watches.current.values()) stop(); | |
| 123 | watches.current.clear(); | |
| 124 | loaded.current.clear(); | |
| 101 | 125 | }; |
| 102 | 126 | }, [load]); |
| 103 | 127 |
apps/web/src/components/files/useFileSaveCoordinator.test.tsx+2-4| ... | ... | @@ -21,7 +21,6 @@ vi.mock("./projectFilesQueryState", () => ({ |
| 21 | 21 | getUnsavedProjectFileQueryData: getUnsavedFile, |
| 22 | 22 | })); |
| 23 | 23 | |
| 24 | import { setMarkdownTaskChecked } from "./filePreviewMode"; | |
| 25 | 24 | import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; |
| 26 | 25 | |
| 27 | 26 | const environmentId = EnvironmentId.make("save-lifecycle-audit"); |
| ... | ... | @@ -93,10 +92,9 @@ describe("file-save React lifecycle", () => { |
| 93 | 92 | expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", false); |
| 94 | 93 | }); |
| 95 | 94 | |
| 96 | it("persists rendered Markdown task changes after StrictMode setup replay", async () => { | |
| 95 | it("persists Markdown edits after StrictMode setup replay", async () => { | |
| 97 | 96 | mount({ ...defaultProps, relativePath: "README.md" }); |
| 98 | const nextContents = setMarkdownTaskChecked("- [ ] task\n", 2, true); | |
| 99 | changeHandler()(nextContents); | |
| 97 | changeHandler()("- [x] task\n"); | |
| 100 | 98 | await vi.advanceTimersByTimeAsync(500); |
| 101 | 99 | expect(writeFile).toHaveBeenCalledExactlyOnceWith({ |
| 102 | 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 | 321 | Previews use the original file, even outside the workspace. Moving or deleting it |
| 322 | 322 | can break the preview, so save a copy if you need to keep it. |
| 323 | 323 | |
| 324 | ## Workspace files | |
| 325 | ||
| 326 | On web and desktop, Markdown files open in an editable preview. Select text to reveal | |
| 327 | its Markdown syntax; changes save automatically. Use the source toggle to edit raw | |
| 328 | Markdown. Control-click or Command-click a link to open it, or place the caret in the | |
| 329 | link and press Control+Enter or Command+Enter. Open files and folders update when | |
| 330 | files change on the environment's machine, including over a remote connection. | |
| 331 | ||
| 324 | 332 | ## Files outside the workspace |
| 325 | 333 | |
| 326 | 334 | Follow 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 | 69 | | typeof WS_METHODS.subscribeVcsStatus |
| 70 | 70 | | typeof WS_METHODS.subscribeWorktreeSetup |
| 71 | 71 | | typeof WS_METHODS.subscribeProjectClones |
| 72 | | typeof WS_METHODS.projectsWatch | |
| 72 | 73 | | typeof WS_METHODS.terminalAttach |
| 73 | 74 | | typeof WS_METHODS.terminalObserve; |
| 74 | 75 |
packages/client-runtime/src/state/projectCommands.test.ts+45-1| ... | ... | @@ -43,6 +43,7 @@ const PROJECT = { |
| 43 | 43 | const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { |
| 44 | 44 | const metadataRequests: string[][] = []; |
| 45 | 45 | const file = { byteLength: 5 }; |
| 46 | const fileChanges = yield* SubscriptionRef.make(0); | |
| 46 | 47 | const readResponse = { wait: Effect.void as Effect.Effect<void> }; |
| 47 | 48 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ |
| 48 | 49 | target: { environmentId: ENVIRONMENT_ID }, |
| ... | ... | @@ -50,6 +51,7 @@ const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { |
| 50 | 51 | Option.some({ |
| 51 | 52 | client: { |
| 52 | 53 | [WS_METHODS.projectsEnsureScratch]: () => Effect.succeed({ projectId: PROJECT_ID }), |
| 54 | [WS_METHODS.projectsWatch]: () => SubscriptionRef.changes(fileChanges), | |
| 53 | 55 | [WS_METHODS.projectsReadFile]: () => |
| 54 | 56 | Effect.gen(function* () { |
| 55 | 57 | const byteLength = file.byteLength; |
| ... | ... | @@ -122,7 +124,49 @@ const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { |
| 122 | 124 | const openScratch = Effect.promise(() => |
| 123 | 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 | ||
| 139 | describe("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 | }); |
| 127 | 171 | |
| 128 | 172 | describe("writeFile metadata", () => { |
packages/client-runtime/src/state/projectCommands.ts+12| ... | ... | @@ -25,6 +25,7 @@ import { |
| 25 | 25 | createEnvironmentCommand, |
| 26 | 26 | createEnvironmentRpcCommand, |
| 27 | 27 | createEnvironmentRpcQueryAtomFamily, |
| 28 | createEnvironmentRpcSubscriptionAtomFamily, | |
| 28 | 29 | } from "./runtime.ts"; |
| 29 | 30 | import { |
| 30 | 31 | type CreateProjectInput, |
| ... | ... | @@ -100,6 +101,11 @@ export function createProjectEnvironmentAtoms<R, E>( |
| 100 | 101 | key: ({ environmentId, input }: { environmentId: string; input: { projectId: string } }) => |
| 101 | 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 | 109 | return { |
| 104 | 110 | fileMetadata: fileMetadata.metadata, |
| 105 | 111 | fileMetadataThread: fileMetadata.retainThread, |
| ... | ... | @@ -126,6 +132,7 @@ export function createProjectEnvironmentAtoms<R, E>( |
| 126 | 132 | (result, isCurrent) => fileMetadata.rememberEntries(input.cwd, result.entries, isCurrent), |
| 127 | 133 | ), |
| 128 | 134 | }), |
| 135 | watch, | |
| 129 | 136 | listEntries: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 130 | 137 | label: "environment-data:projects:list-entries", |
| 131 | 138 | tag: WS_METHODS.projectsListEntries, |
| ... | ... | @@ -140,6 +147,11 @@ export function createProjectEnvironmentAtoms<R, E>( |
| 140 | 147 | readFile: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 141 | 148 | label: "environment-data:projects:read-file", |
| 142 | 149 | tag: WS_METHODS.projectsReadFile, |
| 150 | refreshTrigger: ({ environmentId, input }) => | |
| 151 | watch({ | |
| 152 | environmentId, | |
| 153 | input: { ...input, directory: false }, | |
| 154 | }), | |
| 143 | 155 | staleTimeMs: 30_000, |
| 144 | 156 | idleTtlMs: 5 * 60_000, |
| 145 | 157 | execute: (input) => |
packages/contracts/src/project.ts+7| ... | ... | @@ -441,6 +441,12 @@ export const ProjectReadFileInput = Schema.Struct({ |
| 441 | 441 | }); |
| 442 | 442 | export type ProjectReadFileInput = typeof ProjectReadFileInput.Type; |
| 443 | 443 | |
| 444 | export const ProjectWatchInput = Schema.Struct({ | |
| 445 | ...ProjectReadFileInput.fields, | |
| 446 | directory: Schema.Boolean, | |
| 447 | }); | |
| 448 | export type ProjectWatchInput = typeof ProjectWatchInput.Type; | |
| 449 | ||
| 444 | 450 | export const ProjectReadFileResult = Schema.Struct({ |
| 445 | 451 | relativePath: TrimmedNonEmptyString, |
| 446 | 452 | contents: Schema.String, |
| ... | ... | @@ -467,6 +473,7 @@ export const ProjectFileOperation = Schema.Literals([ |
| 467 | 473 | "close", |
| 468 | 474 | "make-directory", |
| 469 | 475 | "write-file", |
| 476 | "watch", | |
| 470 | 477 | ]); |
| 471 | 478 | export type ProjectFileOperation = typeof ProjectFileOperation.Type; |
| 472 | 479 |
packages/contracts/src/rpc.ts+10| ... | ... | @@ -225,6 +225,7 @@ import { |
| 225 | 225 | ProjectListEntriesResult, |
| 226 | 226 | ProjectReadFileError, |
| 227 | 227 | ProjectReadFileInput, |
| 228 | ProjectWatchInput, | |
| 228 | 229 | ProjectReadFileResult, |
| 229 | 230 | ProjectSearchContentsError, |
| 230 | 231 | ProjectSearchContentsInput, |
| ... | ... | @@ -376,6 +377,7 @@ export const WS_METHODS = { |
| 376 | 377 | projectsRemove: "projects.remove", |
| 377 | 378 | projectsListEntries: "projects.listEntries", |
| 378 | 379 | projectsReadFile: "projects.readFile", |
| 380 | projectsWatch: "projects.watch", | |
| 379 | 381 | projectsSearchContents: "projects.searchContents", |
| 380 | 382 | projectsSearchEntries: "projects.searchEntries", |
| 381 | 383 | projectsWriteFile: "projects.writeFile", |
| ... | ... | @@ -1233,6 +1235,13 @@ const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { |
| 1233 | 1235 | error: Schema.Union([ProjectReadFileError, EnvironmentAuthorizationError]), |
| 1234 | 1236 | }); |
| 1235 | 1237 | |
| 1238 | const WsProjectsWatchRpc = Rpc.make(WS_METHODS.projectsWatch, { | |
| 1239 | payload: ProjectWatchInput, | |
| 1240 | success: Schema.Number, | |
| 1241 | error: Schema.Union([ProjectReadFileError, EnvironmentAuthorizationError]), | |
| 1242 | stream: true, | |
| 1243 | }); | |
| 1244 | ||
| 1236 | 1245 | const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { |
| 1237 | 1246 | payload: ProjectWriteFileInput, |
| 1238 | 1247 | success: ProjectWriteFileResult, |
| ... | ... | @@ -1994,6 +2003,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 1994 | 2003 | WsSubscribeProjectClonesRpc, |
| 1995 | 2004 | WsProjectsListEntriesRpc, |
| 1996 | 2005 | WsProjectsReadFileRpc, |
| 2006 | WsProjectsWatchRpc, | |
| 1997 | 2007 | WsProjectsSearchContentsRpc, |
| 1998 | 2008 | WsProjectsSearchEntriesRpc, |
| 1999 | 2009 | WsProjectsEnsureScratchRpc, |