diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index ce90ad31fea83f633233d151b8b0b6079ec3cc6b..15638757241ebf228175d937b5efa6ccfa3e63f5 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -32,6 +32,9 @@ import { import * as RpcAuthorization from "./RpcAuthorization.ts"; describe("RPC authorization scopes", () => { + it("requires filesystem read permission to watch file and folder changes", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.projectsWatch)).toBe(AuthFilesystemReadScope); + }); it("declares exactly one scope for every RPC in the server group", () => { expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys())); }); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5b8d483dc1024d0b1b8378d88009fedaed88e8db..d8c1e87c111203a37d39a69f2f78eafb9b1d1733 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -149,6 +149,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope, [WS_METHODS.projectsListEntries]: AuthFilesystemReadScope, [WS_METHODS.projectsReadFile]: AuthFilesystemReadScope, + [WS_METHODS.projectsWatch]: AuthFilesystemReadScope, [WS_METHODS.projectsSearchContents]: AuthFilesystemReadScope, [WS_METHODS.projectsSearchEntries]: AuthFilesystemReadScope, [WS_METHODS.projectsWriteFile]: AuthFilesystemWriteScope, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 167ce8d11fff99b16b4bb9cc81f50f79a627b1fb..34353c888fafaa8cf9dbc4085036edbde6293fc0 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -136,6 +136,7 @@ const RPC_AGGREGATES = { [WS_METHODS.projectCloneRetry]: "source-control", [WS_METHODS.subscribeProjectClones]: "source-control", [WS_METHODS.projectsListEntries]: "workspace", + [WS_METHODS.projectsWatch]: "workspace", [WS_METHODS.projectsReadFile]: "workspace", [WS_METHODS.projectsSearchContents]: "workspace", [WS_METHODS.projectsSearchEntries]: "workspace", diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 3e32e5537692e989aba88965a6ecebc8535f7ba9..bd46a366cccc0e1ec7038c8bea7724ecc4978adf 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -7,6 +7,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; import * as ServerConfig from "../config.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -57,6 +58,173 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( }); it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { + describe("watch", () => { + it.effect("observes file edits, atomic replacements, deletion, and recreation", () => + Effect.gen(function* () { + const service = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "note.md", "first"); + let step = 0; + const observed: Array = []; + yield* service.watch({ cwd, relativePath: "note.md", directory: false }).pipe( + Stream.take(5), + Stream.runForEach(() => + Effect.gen(function* () { + observed.push( + yield* fs + .readFileString(path.join(cwd, "note.md")) + .pipe(Effect.catch(() => Effect.succeed(null))), + ); + switch (step++) { + case 0: + yield* writeTextFile(cwd, "note.md", "edited"); + break; + case 1: + yield* writeTextFile(cwd, "replacement", "replaced"); + yield* fs.rename(path.join(cwd, "replacement"), path.join(cwd, "note.md")); + break; + case 2: + yield* fs.remove(path.join(cwd, "note.md")); + break; + case 3: + yield* writeTextFile(cwd, "note.md", "recreated"); + break; + } + }), + ), + ); + expect(observed).toEqual(["first", "edited", "replaced", null, "recreated"]); + }), + ); + + it.effect("reattaches after an open directory is deleted and recreated", () => + Effect.gen(function* () { + const service = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const directory = path.join(cwd, "open"); + yield* fs.makeDirectory(directory); + let step = 0; + const observed: Array = []; + yield* service.watch({ cwd, relativePath: "open", directory: true }).pipe( + Stream.take(5), + Stream.runForEach(() => + Effect.gen(function* () { + observed.push( + yield* fs.readDirectory(directory).pipe(Effect.catch(() => Effect.succeed(null))), + ); + switch (step++) { + case 0: + yield* writeTextFile(directory, "one", "one"); + break; + case 1: + yield* fs.remove(directory, { recursive: true }); + break; + case 2: + yield* fs.makeDirectory(directory); + break; + case 3: + yield* writeTextFile(directory, "two", "two"); + break; + } + }), + ), + ); + expect(observed).toEqual([[], ["one"], null, [], ["two"]]); + }), + ); + }); + + describe("watch targets", () => { + it.effect("observes initially missing targets and recreation of missing parents", () => + Effect.gen(function* () { + const service = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + let step = 0; + const observed: Array = []; + yield* service.watch({ cwd, relativePath: "nested/deep/note.md", directory: false }).pipe( + Stream.take(4), + Stream.runForEach(() => + Effect.gen(function* () { + observed.push( + yield* fs + .readFileString(path.join(cwd, "nested/deep/note.md")) + .pipe(Effect.orElseSucceed(() => null)), + ); + switch (step++) { + case 0: + yield* writeTextFile(cwd, "nested/deep/note.md", "created"); + break; + case 1: + yield* fs.remove(path.join(cwd, "nested"), { recursive: true }); + break; + case 2: + yield* writeTextFile(cwd, "nested/deep/note.md", "recreated"); + break; + } + }), + ), + ); + expect(observed).toEqual([null, "created", null, "recreated"]); + }), + ); + + it.effect.skipIf(!symlinksSupported)("follows a file symlink when its target changes", () => + Effect.gen(function* () { + const service = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "first/note.md", "first"); + yield* writeTextFile(cwd, "second/note.md", "second"); + const link = path.join(cwd, "link.md"); + yield* fs.symlink(path.join(cwd, "first/note.md"), link); + let step = 0; + const observed: string[] = []; + yield* service.watch({ cwd, relativePath: "link.md", directory: false }).pipe( + Stream.take(3), + Stream.runForEach(() => + Effect.gen(function* () { + observed.push(yield* fs.readFileString(link)); + if (step++ === 0) { + yield* fs.remove(link); + yield* fs.symlink(path.join(cwd, "second/note.md"), link); + } else { + yield* writeTextFile(cwd, "second/note.md", "edited"); + } + }), + ), + ); + expect(observed).toEqual(["first", "second", "edited"]); + }), + ); + + it.effect("watches the workspace folder itself", () => + Effect.gen(function* () { + const service = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fs = yield* FileSystem.FileSystem; + const cwd = yield* makeTempDir; + let step = 0; + const entries: Array = []; + yield* service.watch({ cwd, relativePath: ".", directory: true }).pipe( + Stream.take(2), + Stream.runForEach(() => + Effect.gen(function* () { + entries.push(yield* fs.readDirectory(cwd)); + if (step++ === 0) yield* writeTextFile(cwd, "new.md", "created"); + }), + ), + ); + expect(entries).toEqual([[], ["new.md"]]); + }), + ); + }); + describe("getMetadata", () => { it.effect("expands home-relative paths on the server", () => Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 335b18c791b06e8f7f25e1994a51d7504f2cb52d..c30f870b28bf24baa5458d75c1cc7c9f97e84094 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -17,6 +17,7 @@ import type { FilesystemGetMetadataInput, FilesystemGetMetadataResult, ProjectReadFileInput, + ProjectWatchInput, ProjectReadFileResult, ProjectWriteFileInput, ProjectWriteFileResult, @@ -24,11 +25,15 @@ import type { import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; +import * as Clock from "effect/Clock"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as Queue from "effect/Queue"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; @@ -52,6 +57,7 @@ export class WorkspaceFileSystemOperationError extends Schema.TaggedError Stream.Stream< + number, + WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError + >; + readonly getMetadata: ( input: FilesystemGetMetadataInput, ) => Effect.Effect; @@ -395,7 +408,148 @@ export const make = Effect.gen(function* () { return { relativePath: target.relativePath }; }); - return WorkspaceFileSystem.of({ getMetadata, readFile, writeFile }); + const watch: WorkspaceFileSystem["Service"]["watch"] = (input) => + Stream.unwrap( + Effect.gen(function* () { + const requestedPath = + input.relativePath === "." + ? path.resolve(input.cwd) + : path.isAbsolute(input.relativePath) + ? input.relativePath + : (yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + })).absolutePath; + const realRoot = path.isAbsolute(input.relativePath) + ? null + : (yield* resolveReadTarget({ cwd: input.cwd, relativePath: path.resolve(input.cwd) })) + .realTargetPath; + yield* resolveReadTarget({ + ...input, + relativePath: input.relativePath === "." ? requestedPath : input.relativePath, + }).pipe( + Effect.catchTags({ + WorkspaceFileSystemOperationError: (error) => + error.operation === "realpath-target" && + typeof error.cause === "object" && + error.cause !== null && + "code" in error.cause && + error.cause.code === "ENOENT" + ? Effect.void + : Effect.fail(error), + }), + ); + const startedAt = yield* Clock.currentTimeMillis; + return Stream.callback( + (mailbox) => { + const watchError = (cause: unknown) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: requestedPath, + operationPath: requestedPath, + operation: "watch", + cause, + }); + return Effect.acquireRelease( + Effect.try({ + try: () => { + let revision = startedAt; + let generation = 0; + let watchers: NodeFS.FSWatcher[] = []; + const close = () => { + generation++; + for (const watcher of watchers) watcher.close(); + watchers = []; + }; + const attach = () => { + const currentGeneration = ++generation; + const directories = new Map | null>(); + const addParents = (target: string, root: string) => { + for (let child = target; child !== root;) { + const parent = path.dirname(child); + if (parent === child) break; + const names = directories.get(parent); + if (names !== null) + directories.set(parent, (names ?? new Set()).add(path.basename(child))); + child = parent; + } + }; + addParents(requestedPath, path.dirname(path.resolve(input.cwd))); + try { + const realTarget = NodeFS.realpathSync(requestedPath); + const relative = + realRoot === null ? null : path.relative(realRoot, realTarget); + const insideRoot = + relative === null || + !( + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ); + if (insideRoot) { + addParents( + realTarget, + realRoot === null ? path.parse(realTarget).root : path.dirname(realRoot), + ); + if (input.directory && NodeFS.statSync(requestedPath).isDirectory()) + directories.set(requestedPath, null); + } + } catch { + // Ancestor watches remain attached while the target is absent. + } + const next: NodeFS.FSWatcher[] = []; + try { + for (const [directory, names] of directories) { + if (!NodeFS.existsSync(directory)) continue; + const watcher = NodeFS.watch(directory, (_event, filename) => { + if (generation !== currentGeneration) return; + if ( + filename !== null && + names !== null && + !names.has(filename.toString()) + ) + return; + try { + attach(); + Queue.offerUnsafe(mailbox, ++revision); + } catch (cause) { + Queue.failCauseUnsafe(mailbox, Cause.fail(watchError(cause))); + } + }); + watcher.on("error", (cause) => { + if (generation === currentGeneration) + Queue.failCauseUnsafe(mailbox, Cause.fail(watchError(cause))); + }); + next.push(watcher); + } + } catch (cause) { + for (const watcher of next) watcher.close(); + throw cause; + } + for (const watcher of watchers) watcher.close(); + watchers = next; + }; + try { + attach(); + Queue.offerUnsafe(mailbox, revision); + return close; + } catch (cause) { + close(); + throw cause; + } + }, + catch: watchError, + }), + (close) => Effect.sync(close), + ); + }, + { bufferSize: 1, strategy: "sliding" }, + ).pipe(Stream.debounce("100 millis")); + }), + ); + + return WorkspaceFileSystem.of({ getMetadata, readFile, writeFile, watch }); }); export const layer = Layer.effect(WorkspaceFileSystem, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 139f3646a5416e1420ea324541a125b56e4d5528..dece5c9706638e7464870f769ccffcdd165dca9f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2685,6 +2685,17 @@ const layerWsRpc = ( }), ), ), + [WS_METHODS.projectsWatch]: (input) => + workspaceFileSystem.watch(input).pipe( + Stream.mapError( + (cause) => + new ProjectReadFileError({ + ...input, + ...projectFileFailureContext(cause), + cause, + }), + ), + ), [WS_METHODS.projectsWriteFile]: (input) => workspaceFileSystem.writeFile(input).pipe( Effect.mapError( diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 55c11d2644dd87f3f67e3ebda822bcb7ef323570..5a98b049948e85947b61558fa281725ca8aae958 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -15,7 +15,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; import { GitHubIcon } from "./Icons"; import { Button } from "./ui/button"; -import { setMarkdownTaskChecked } from "./files/filePreviewMode"; vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null })); vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); @@ -778,19 +777,12 @@ describe("ChatMarkdown streaming", () => { } }); - it("edits the current task text and marker after reusing a renderer", async () => { + it("reports the current task marker after reusing a renderer", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); let renderer: ReactTestRenderer | undefined; - let editedText: string | undefined; + const taskChange = vi.fn(); const message = (text: string) => ( - { - editedText = setMarkdownTaskChecked(text, markerOffset, checked); - renderer!.update(message(editedText)); - }} - /> + ); try { @@ -819,7 +811,11 @@ describe("ChatMarkdown streaming", () => { }); expect(input).toBe(originalInput); - expect(editedText).toBe("- [ ] A longer first task\n- [x] Second"); + expect(taskChange).toHaveBeenCalledExactlyOnceWith({ + markerOffset: "- [ ] A longer first task\n- ".length, + checked: true, + }); + await act(async () => mounted.update(message("- [ ] A longer first task\n- [x] Second"))); expect(mounted.root.findAllByType("input")[1]!.props.checked).toBe(true); } finally { await act(async () => renderer?.unmount()); diff --git a/apps/web/src/components/files/FileMarkdownPreview.test.tsx b/apps/web/src/components/files/FileMarkdownPreview.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..952a27007c239d1a8a3659bd1f42e77c57c73722 --- /dev/null +++ b/apps/web/src/components/files/FileMarkdownPreview.test.tsx @@ -0,0 +1,237 @@ +// @vitest-environment jsdom +import { EditorView } from "@codemirror/view"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const externalLink = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const fileLink = vi.hoisted(() => vi.fn()); +const media = vi.hoisted(() => ({ source: vi.fn(), showMenu: vi.fn() })); +vi.mock("../media/MediaActions", () => ({ + MediaActions: ({ + source, + children, + }: { + source: unknown; + children: import("react").ReactNode; + }) => { + media.source(source); + return children; + }, + useMediaActionMenu: () => ({ showMenu: media.showMenu }), +})); +vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => externalLink })); +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { getState: () => ({ openFile: fileLink }) }, +})); +vi.mock("~/assets/assetUrls", () => ({ + useAssetUrls: (_environmentId: unknown, resources: Array<{ path: string }>) => + resources.map( + (resource) => `https://host.example/asset?path=${encodeURIComponent(resource.path)}`, + ), +})); +vi.mock("~/components/composerCodeBlockHighlight", () => ({ composerCodeBlockHighlight: [] })); + +import { FileMarkdownPreview } from "./FileMarkdownPreview"; +import { MediaPlaybackProvider } from "../media/MediaPlaybackHost"; + +let root: Root; +let container: HTMLDivElement; +const change = vi.fn(); +let playing: WeakSet; +const threadRef = { + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), +}; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + playing = new WeakSet(); + vi.spyOn(HTMLMediaElement.prototype, "paused", "get").mockImplementation(function ( + this: HTMLMediaElement, + ) { + return !playing.has(this); + }); + vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function ( + this: HTMLMediaElement, + ) { + playing.add(this); + this.dispatchEvent(new Event("play")); + return Promise.resolve(); + }); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(function ( + this: HTMLMediaElement, + ) { + playing.delete(this); + this.dispatchEvent(new Event("pause")); + }); + vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); + Object.defineProperty(Range.prototype, "getClientRects", { configurable: true, value: () => [] }); + Object.defineProperty(Range.prototype, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(), + }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + change.mockClear(); + externalLink.mockClear(); + fileLink.mockClear(); + media.source.mockClear(); + media.showMenu.mockClear(); +}); +afterEach(async () => { + await act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +async function render(text: string, readOnly = false, revision: string | null = null) { + await act(async () => + root.render( + + + , + ), + ); + return EditorView.findFromDOM(container.querySelector(".cm-editor")!)!; +} + +describe("Markdown document file viewer", () => { + it("edits the source without remounting and adopts external updates without saving them back", async () => { + const view = await render("# Original\n\n- [ ] Task"); + expect(container.querySelector(".cm-lp-h1")).not.toBeNull(); + await act(() => + view.dispatch({ changes: { from: 2, to: 10, insert: "Edited" }, userEvent: "input" }), + ); + expect(change).toHaveBeenLastCalledWith("# Edited\n\n- [ ] Task"); + change.mockClear(); + await act(() => view.dispatch({ selection: { anchor: 4, head: 7 } })); + expect(await render("# External\n\nFresh contents")).toBe(view); + expect(view.state.doc.toString()).toBe("# External\n\nFresh contents"); + expect(change).not.toHaveBeenCalled(); + expect(view.state.selection.main.anchor).toBe(4); + expect(view.state.selection.main.head).toBe(7); + }); + + it("opens file-relative and external links from the keyboard", async () => { + const view = await render("[Note](./other.md) [Site](https://example.com)"); + await act(() => { + view.dispatch({ selection: { anchor: 3 } }); + view.contentDOM.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + metaKey: true, + bubbles: true, + cancelable: true, + }), + ); + }); + expect(fileLink).toHaveBeenCalledWith(threadRef, "docs/./other.md", undefined); + await act(() => { + view.dispatch({ selection: { anchor: 21 } }); + view.contentDOM.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + metaKey: true, + bubbles: true, + cancelable: true, + }), + ); + }); + expect(externalLink).toHaveBeenCalledWith("https://example.com"); + }); + + it("keeps read-only files and task widgets uneditable", async () => { + const view = await render("- [ ] Task", true); + expect(view.state.readOnly).toBe(true); + expect(view.contentDOM.getAttribute("aria-readonly")).toBe("true"); + expect(view.contentDOM.getAttribute("contenteditable")).toBe("false"); + const box = container.querySelector('input[type="checkbox"]')!; + expect(box.disabled).toBe(true); + await act(() => box.click()); + expect(view.state.doc.toString()).toBe("- [ ] Task"); + expect(change).not.toHaveBeenCalled(); + }); + + it("resolves relative images against the Markdown file's directory through host assets", async () => { + await render("![Figure](figure.png)"); + expect(container.querySelector(".cm-lp-image img")?.src).toBe( + "https://host.example/asset?path=%2Fworkspace%2Fdocs%2Ffigure.png", + ); + }); + + it("uses the shared custom player and source-aware actions for document videos", async () => { + const source = "![Clip](clip.mp4)"; + const view = await render(source, true); + const video = container.querySelector("video")!; + expect(video).not.toBeNull(); + expect(video.controls).toBe(false); + expect(video.src).toBe("https://host.example/asset?path=%2Fworkspace%2Fdocs%2Fclip.mp4"); + expect(container.querySelector('button[aria-label="Play video"]')).not.toBeNull(); + const menu = container.querySelector('button[aria-label="Video actions"]')!; + await act(() => menu.click()); + expect(media.showMenu).toHaveBeenCalledOnce(); + expect(media.source).toHaveBeenLastCalledWith( + expect.objectContaining({ + kind: "video", + name: "clip.mp4", + reference: { + kind: "file", + path: "/workspace/docs/clip.mp4", + relativePath: "docs/clip.mp4", + }, + asset: { + environmentId: threadRef.environmentId, + resource: { + _tag: "media-file", + threadId: threadRef.threadId, + path: "/workspace/docs/clip.mp4", + }, + }, + }), + ); + expect(view.state.doc.toString()).toBe(source); + expect(change).not.toHaveBeenCalled(); + }); + + it("keeps the playing document video when navigating to another chat", async () => { + await render("![Clip](clip.mp4)", true); + const video = container.querySelector("video")!; + video.currentTime = 12; + await act(() => video.play()); + await act(() => + root.render( + +
Another chat
+
, + ), + ); + expect(container.querySelector("[data-media-floating-player] video")).toBe(video); + expect(video.currentTime).toBe(12); + expect(video.paused).toBe(false); + }); + + it("preserves the document player across signed source revisions", async () => { + const text = "![Clip](clip.mp4)"; + await render(text, true, "first"); + const video = container.querySelector("video")!; + video.currentTime = 12; + await act(() => video.play()); + await render(text, true, "second"); + expect(container.querySelector("video")).toBe(video); + expect(video.src).toContain("workspace-revision=first"); + expect(video.currentTime).toBe(12); + expect(video.paused).toBe(false); + }); +}); diff --git a/apps/web/src/components/files/FileMarkdownPreview.tsx b/apps/web/src/components/files/FileMarkdownPreview.tsx index 2d4fd14e4b6ef1c4a9bdd383b0da901e3505c24f..81c03f38a61b0d858fdfacf0a471d0635be7df44 100644 --- a/apps/web/src/components/files/FileMarkdownPreview.tsx +++ b/apps/web/src/components/files/FileMarkdownPreview.tsx @@ -1,17 +1,42 @@ -import type { ScopedThreadRef } from "@t3tools/contracts"; +import { createMarkdownEditor, livePreview, type MarkdownEditor } from "@clo/markdown-editor"; +import { syntaxTree } from "@codemirror/language"; +import { Compartment, EditorState, Prec, Transaction } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { AssetResource, ScopedThreadRef } from "@t3tools/contracts"; +import { + classifyMarkdownImageSource, + markdownImageSourceFragment, +} from "@t3tools/client-runtime/markdown-images"; +import { workspaceRelativeFilePath } from "@t3tools/shared/path"; +import { resolveMarkdownFileLinkTarget } from "@t3tools/shared/markdownLinks"; +import { resolvePathLinkTarget, splitFilePathPosition } from "@t3tools/shared/fileLinks"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import ChatMarkdown from "~/components/ChatMarkdown"; -import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; +import { useOpenLink } from "~/browser/useOpenLink"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { toastManager } from "~/components/ui/toast"; +import { useAssetUrls } from "~/assets/assetUrls"; +import { composerCodeBlockHighlight } from "~/components/composerCodeBlockHighlight"; +import { workspaceAssetResource } from "./filePreviewMode"; +import { useMarkdownVideos } from "./FileMarkdownVideos"; export function FileMarkdownPreview(props: { readonly cwd: string; readonly relativePath: string; readonly text: string; readonly threadRef: ScopedThreadRef; - readonly onTaskListChange?: - | ((input: { readonly markerOffset: number; readonly checked: boolean }) => void) - | undefined; + readonly draft: boolean; + readonly readOnly: boolean; + readonly revision: string | null; + readonly onChange: (text: string) => void; }) { + const openLink = useOpenLink(props.threadRef); + const host = useRef(null); + const editor = useRef(null); + const latest = useRef(props); + const [sources, setSources] = useState([]); + const [presentation] = useState(() => new Compartment()); + const [editing] = useState(() => new Compartment()); const lastSeparator = Math.max( props.relativePath.lastIndexOf("/"), props.relativePath.lastIndexOf("\\"), @@ -20,15 +45,223 @@ export function FileMarkdownPreview(props: { lastSeparator >= 0 ? resolvePathLinkTarget(props.relativePath.slice(0, lastSeparator), props.cwd) : props.cwd; + const { renderVideo, portals } = useMarkdownVideos({ + cwd: props.cwd, + mediaBaseDir: imageBaseDir, + threadRef: props.threadRef, + draft: props.draft, + revision: props.revision, + }); + const resources = useMemo( + () => + sources.map((source): AssetResource => + workspaceAssetResource({ + kind: "media-file", + threadRef: props.threadRef, + draft: props.draft, + workspaceRoot: props.cwd, + absolutePath: source, + }), + ), + [props.cwd, props.draft, props.threadRef, sources], + ); + const urls = useAssetUrls(props.threadRef.environmentId, resources); + const resolveUrl = useCallback( + (url: string) => { + const source = classifyMarkdownImageSource(url, imageBaseDir); + if (source._tag === "Direct") return source.uri; + if (source._tag === "Blocked") return "about:blank"; + const index = sources.indexOf(source.path); + if (index === -1) { + queueMicrotask(() => + setSources((current) => + current.includes(source.path) ? current : [...current, source.path], + ), + ); + return "about:blank"; + } + const resolved = urls[index]; + if (!resolved) return "about:blank"; + const revision = + props.revision === null + ? "" + : `${resolved.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.revision)}`; + return `${resolved}${revision}${markdownImageSourceFragment(url)}`; + }, + [imageBaseDir, props.revision, sources, urls], + ); + + const activateLink = useCallback( + (view: EditorView, position: number) => { + let node = syntaxTree(view.state).resolveInner(position, 1); + while (node.name !== "Link" && node.parent) node = node.parent; + const destination = node.name === "Link" ? node.getChild("URL") : null; + if (!destination) return false; + const href = view.state.sliceDoc(destination.from, destination.to).replace(/^<|>$/g, ""); + const filePath = resolveMarkdownFileLinkTarget(href, props.cwd, imageBaseDir); + if (filePath !== null) { + const target = splitFilePathPosition(filePath); + useRightPanelStore + .getState() + .openFile( + props.threadRef, + workspaceRelativeFilePath(target.path, props.cwd) ?? target.path, + target.line, + ); + return true; + } + if (!/^(?:https?:|mailto:)/i.test(href)) return false; + void openLink(href).catch((cause: unknown) => + toastManager.add({ + type: "error", + title: "Unable to open link", + description: cause instanceof Error ? cause.message : "Try opening the link again.", + }), + ); + return true; + }, + [imageBaseDir, openLink, props.cwd, props.threadRef], + ); + + useLayoutEffect(() => { + latest.current = props; + }); + // oxlint-disable react/exhaustive-effect-dependencies -- One editor per file; controlled updates below preserve the mounted view. + useLayoutEffect(() => { + const instance = createMarkdownEditor({ + preset: "document", + mentions: false, + preview: false, + doc: latest.current.text, + onChange: (text) => { + if (text !== latest.current.text && !latest.current.readOnly) latest.current.onChange(text); + }, + extensions: [ + presentation.of([]), + editing.of([]), + composerCodeBlockHighlight, + Prec.highest( + EditorView.theme({ + "&": { + height: "100%", + fontSize: "inherit", + color: "var(--foreground)", + "--markdown-text": "var(--foreground)", + "--markdown-muted": "var(--muted-foreground)", + "--markdown-panel": "var(--muted)", + "--markdown-accent": "var(--primary)", + }, + ".cm-scroller": { + fontFamily: "var(--font-sans)", + scrollbarGutter: "stable both-edges", + }, + ".cm-content": { + maxWidth: "56rem", + margin: "0 auto", + padding: "20px 24px", + flex: "1", + minWidth: "0", + }, + ".cm-lp-strong": { + color: + "light-dark(oklch(from var(--message-action) min(l, 0.55) c h), oklch(from var(--message-action) max(l, 0.65) c h))", + }, + ".cm-lp-em": { color: "var(--info-foreground)" }, + ".cm-lp-strong.cm-lp-em, .cm-lp-strong .cm-lp-em, .cm-lp-em .cm-lp-strong": { + color: "var(--error-foreground)", + backgroundColor: "color-mix(in srgb, var(--error) 10%, transparent)", + }, + ".cm-lp-code, .cm-lp-codeblock": { + fontFamily: + 'var(--chat-code-font, "Recursive"), "T3 Markdown Symbols", var(--font-mono)', + fontVariationSettings: '"CASL" 0.15, "MONO" 1', + fontSize: "var(--font-size-code, 13px)", + }, + ".cm-lp-codeblock": { lineHeight: "1.25" }, + "&.cm-focused": { outline: "none" }, + ".cm-content:focus-visible": { + outline: "2px solid var(--ring)", + outlineOffset: "-2px", + }, + }), + ), + ], + }); + editor.current = instance; + host.current?.append(instance.dom); + return () => { + editor.current = null; + instance.destroy(); + }; + }, []); + // oxlint-enable react/exhaustive-effect-dependencies + + useEffect(() => { + editor.current?.view.dispatch({ + effects: [ + presentation.reconfigure( + livePreview({ blockWidgets: true, html: true, resolveUrl, renderVideo }), + ), + editing.reconfigure([ + EditorState.readOnly.of(props.readOnly), + EditorView.editable.of(!props.readOnly), + EditorView.contentAttributes.of({ + role: "textbox", + "aria-label": props.relativePath, + "aria-multiline": "true", + "aria-readonly": String(props.readOnly), + tabindex: "0", + }), + Prec.highest( + EditorView.domEventHandlers({ + click(event, view) { + if ( + !(props.readOnly || event.metaKey || event.ctrlKey) || + !(event.target instanceof Node) + ) + return false; + return activateLink(view, view.posAtDOM(event.target)); + }, + keydown(event, view) { + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + return activateLink(view, view.state.selection.main.head); + } + if (event.key !== "Escape" || !view.hasFocus) return false; + view.contentDOM.blur(); + return false; + }, + }), + ), + ]), + ], + }); + }, [ + activateLink, + editing, + presentation, + props.readOnly, + props.relativePath, + resolveUrl, + renderVideo, + ]); + + useEffect(() => { + const view = editor.current?.view; + if (!view || view.state.doc.toString() === props.text) return; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: props.text }, + selection: { + anchor: Math.min(view.state.selection.main.anchor, props.text.length), + head: Math.min(view.state.selection.main.head, props.text.length), + }, + annotations: Transaction.addToHistory.of(false), + }); + }, [props.text]); return ( - + <> +
+ {portals} + ); } diff --git a/apps/web/src/components/files/FileMarkdownVideos.tsx b/apps/web/src/components/files/FileMarkdownVideos.tsx new file mode 100644 index 0000000000000000000000000000000000000000..32d8edbe72914d28f5a402b8ec69060d134baa05 --- /dev/null +++ b/apps/web/src/components/files/FileMarkdownVideos.tsx @@ -0,0 +1,96 @@ +import type { VideoPreviewInput, VideoPreviewRenderer } from "@clo/markdown-editor"; +import type { EditorView } from "@codemirror/view"; +import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; +import { + mediaFileReference, + mediaReferenceFileName, + mediaUrlReference, +} from "@t3tools/client-runtime/media-reference"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useCallback, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; +import { workspaceAssetResource } from "./filePreviewMode"; + +export function useMarkdownVideos(context: { + readonly cwd: string; + readonly mediaBaseDir: string; + readonly threadRef: ScopedThreadRef; + readonly draft: boolean; + readonly revision: string | null; +}) { + const nextKey = useRef(0); + const [previews, setPreviews] = useState< + readonly { + readonly key: number; + readonly dom: HTMLElement; + readonly input: VideoPreviewInput; + readonly view: EditorView; + }[] + >([]); + + const renderVideo = useCallback((input, view) => { + const dom = document.createElement("span"); + dom.className = "cm-lp-video"; + const key = nextKey.current++; + setPreviews((current) => [...current, { key, dom, input, view }]); + return { + dom, + update: (input) => + setPreviews((current) => + current.map((preview) => (preview.key === key ? { ...preview, input } : preview)), + ), + destroy: () => setPreviews((current) => current.filter((preview) => preview.key !== key)), + }; + }, []); + + useLayoutEffect(() => { + for (const { view } of previews) view.requestMeasure(); + }, [previews]); + + const portals = previews.map(({ key, dom, input }) => { + const source = classifyMarkdownImageSource(input.source, context.mediaBaseDir); + const reference = + source._tag === "WorkspaceFile" + ? mediaFileReference(source.path, context.cwd) + : mediaUrlReference(input.source); + const src = input.url === "about:blank" ? null : input.url; + const name = (reference && mediaReferenceFileName(reference)) || input.alt || "Video"; + const asset = + source._tag === "WorkspaceFile" + ? { + environmentId: context.threadRef.environmentId, + resource: workspaceAssetResource({ + kind: "media-file", + threadRef: context.threadRef, + draft: context.draft, + workspaceRoot: context.cwd, + absolutePath: source.path, + }), + } + : undefined; + return createPortal( + , + dom, + String(key), + ); + }); + return { renderVideo, portals }; +} diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts index 45304cb58639e33cc62cfd8016d0f921e992d7a4..dec3e545702d5b00184671b45f454c04547291a7 100644 --- a/apps/web/src/components/files/FilePreviewPanel.test.ts +++ b/apps/web/src/components/files/FilePreviewPanel.test.ts @@ -12,7 +12,6 @@ import { filePreviewReadErrorMessage, isMarkdownPreviewFile, resolveFilePreviewPath, - setMarkdownTaskChecked, shouldShowFileExplorer, workspaceAssetResource, } from "./filePreviewMode"; @@ -156,21 +155,6 @@ describe("shouldShowFileExplorer", () => { }); }); -describe("setMarkdownTaskChecked", () => { - const markdown = "- [ ] First\n- [x] Second\n"; - - it("checks and unchecks the task marker at the supplied offset", () => { - expect(setMarkdownTaskChecked(markdown, 2, true)).toBe("- [x] First\n- [x] Second\n"); - expect(setMarkdownTaskChecked(markdown, 14, false)).toBe("- [ ] First\n- [ ] Second\n"); - expect(setMarkdownTaskChecked("1. [X] Ordered\n", 3, false)).toBe("1. [ ] Ordered\n"); - }); - - it("leaves the document unchanged for a stale or invalid marker offset", () => { - expect(setMarkdownTaskChecked(markdown, 0, true)).toBe(markdown); - expect(setMarkdownTaskChecked(markdown, 200, true)).toBe(markdown); - }); -}); - describe("resolveFilePreviewPath", () => { it.each([ ["/repo/project", null], diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index e598129000d0b31b70275a297cafbb5172651127..2f617d597a97fc2d42d7d4a15c7e3db41e37cecb 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -1,3 +1,6 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { projectEnvironment } from "~/state/projects"; import { Spinner } from "~/components/ui/spinner"; import { AuthPreviewOperateScope, @@ -104,13 +107,11 @@ import { filePreviewReadErrorMessage, isMarkdownPreviewFile, resolveFilePreviewPath, - setMarkdownTaskChecked, shouldShowFileExplorer, workspaceAssetResource, } from "./filePreviewMode"; import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; import { - getOptimisticProjectFileQueryData, getProjectFileContents, setProjectFileQueryData, useProjectFileQuery, @@ -134,6 +135,8 @@ interface FilePreviewPanelProps { workspaceMutationId: string | null; } +const EMPTY_FILE_WATCH = Atom.make(AsyncResult.initial()); + const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown"; const RENDER_BROWSER_FILE_STORAGE_KEY = "t3code.renderBrowserFile"; @@ -314,6 +317,7 @@ function WorkspaceVideoPreview(props: { revision={props.workspaceMutationId} preload="metadata" className="flex h-full min-h-0 w-full max-w-5xl items-center justify-center" + videoClassName="h-auto w-auto max-w-full" onRetry={refreshAssetUrl} actionsSource={{ kind: "video", @@ -965,6 +969,8 @@ function RenderedMarkdownSurface({ contents, threadRef, readOnly, + draft, + revision, onPendingChange, }: Omit< EditableFileSurfaceProps, @@ -977,6 +983,8 @@ function RenderedMarkdownSurface({ > & { threadRef: ScopedThreadRef; readOnly: boolean; + draft: boolean; + revision: string | null; }) { const saveCoordinator = useFileSaveCoordinator({ environmentId, @@ -986,27 +994,20 @@ function RenderedMarkdownSurface({ }); return ( - - { - const currentContents = - getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ?? - contents; - const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked); - if (nextContents === currentContents) return; - setProjectFileQueryData(environmentId, cwd, relativePath, nextContents); - saveCoordinator.change(nextContents); - } - } - /> - + { + if (nextContents === getProjectFileContents(environmentId, cwd, relativePath)) return; + setProjectFileQueryData(environmentId, cwd, relativePath, nextContents); + saveCoordinator.change(nextContents); + }} + /> ); } @@ -1040,7 +1041,7 @@ export default function FilePreviewPanel({ onOpenFile, onPendingChange, selectedFilePending, - workspaceMutationId, + workspaceMutationId: orchestrationMutationId, }: FilePreviewPanelProps) { const relativePath = attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath; @@ -1082,6 +1083,14 @@ export default function FilePreviewPanel({ relativePath, attachment === undefined && relativePath !== null, ); + const watched = useAtomValue( + attachment === undefined && relativePath !== null && canReadFiles + ? projectEnvironment.watch({ environmentId, input: { cwd, relativePath, directory: false } }) + : EMPTY_FILE_WATCH, + ); + const workspaceMutationId = AsyncResult.isSuccess(watched) + ? `${orchestrationMutationId ?? ""}:${watched.value}` + : orchestrationMutationId; const attemptedPath = file.readError?.resolvedPath ?? file.readError?.operationPath; // A chat link cannot tell a folder from a file, so a folder arrives here as // a file surface and the read fails. Keep the breadcrumbs, drop the preview @@ -1174,7 +1183,7 @@ export default function FilePreviewPanel({ // it notices when the path becomes a file. (isDirectory || (!isMedia && !isPdf)) && !selectedFilePending, - mutationId: workspaceMutationId, + mutationId: orchestrationMutationId, refresh: file.refresh, resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`, }); @@ -1432,9 +1441,7 @@ export default function FilePreviewPanel({
) : relativePath && file.data ? ( isMarkdown && renderMarkdown ? ( - // Markdown reconciles in place across text updates, so a file - // switch needs a new key or the previous file's disclosure and - // wrap state carries into the next document. + // Each file owns its editor selection and undo history. ) : tableDelimiter && renderTable ? ( @@ -1495,7 +1504,7 @@ export default function FilePreviewPanel({ selectedPath={relativePath} selectedPathRevealId={revealRequestId} onOpenFile={onOpenFile} - workspaceMutationId={workspaceMutationId} + workspaceMutationId={orchestrationMutationId} {...(previewPath && !isMedia && !isPdf ? { onRefreshSelectedFile: file.refresh } : {})} diff --git a/apps/web/src/components/files/filePreviewMode.ts b/apps/web/src/components/files/filePreviewMode.ts index 3d1ad62b68c4efc37a324296af17d0c5e46a81e0..5b7edff149b674b2eedea6102cab464c434c0415 100644 --- a/apps/web/src/components/files/filePreviewMode.ts +++ b/apps/web/src/components/files/filePreviewMode.ts @@ -41,23 +41,6 @@ export function shouldShowFileExplorer(input: { return input.explorerOpen || input.relativePath === null; } -export function setMarkdownTaskChecked( - markdown: string, - markerOffset: number, - checked: boolean, -): string { - if ( - markerOffset < 0 || - markdown[markerOffset] !== "[" || - !/[ xX]/.test(markdown[markerOffset + 1] ?? "") || - markdown[markerOffset + 2] !== "]" - ) { - return markdown; - } - - return `${markdown.slice(0, markerOffset + 1)}${checked ? "x" : " "}${markdown.slice(markerOffset + 2)}`; -} - /** * The asset for a file the panel shows. A draft has no thread on the server * yet, so it names its workspace root instead of a thread to resolve one from. diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 1b91e7d7f8484b6c89c161df55c9f85db6705181..e780bcd9f435b4417e5b513f92e8f83248092d8a 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -265,13 +265,13 @@ export function useProjectFileQuery( const result = useAtomValue(atom); const refreshAtom = useAtomRefresh(atom); const refresh = useCallback(() => refreshAtom(), [refreshAtom]); - const data = Option.getOrNull(AsyncResult.value(result)); const optimisticResult = useAtomValue( optimisticFileAtom(environmentId, cwd, relativePath ?? EMPTY_PROJECT_FILE_PATH), ); const optimisticFile = relativePath === null ? null : optimisticResult; const cause = failureCause(result); const readError = isProjectReadFileError(cause) ? cause : null; + const data = readError === null ? Option.getOrNull(AsyncResult.value(result)) : null; return { data: canReadFiles ? (optimisticFile?.data ?? data) : null, diff --git a/apps/web/src/components/files/useDirectoryEntries.test.tsx b/apps/web/src/components/files/useDirectoryEntries.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f15a6c8daec0b0d0333dcb05dd3686e7ac04e8f3 --- /dev/null +++ b/apps/web/src/components/files/useDirectoryEntries.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment jsdom +import type { AsyncResult as AsyncResultType } from "effect/reactivity"; +import type { ProjectEntry } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; +import { act, useLayoutEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const disk = vi.hoisted(() => ({ + entries: new Map(), + watchStarts: [] as string[], + reads: [] as string[], + change: vi.fn<(path: string) => void>(), +})); +vi.mock("~/rpc/atomRegistry", async () => { + const { AtomRegistry } = await import("effect/reactivity"); + return { appAtomRegistry: AtomRegistry.make() }; +}); +vi.mock("~/state/projects", async () => { + const { AsyncResult, Atom } = await import("effect/reactivity"); + const { appAtomRegistry } = await import("~/rpc/atomRegistry"); + const signals = Atom.family((_path: string) => + Atom.make>(AsyncResult.initial()), + ); + const watches = Atom.family((path: string) => + Atom.make((get) => { + disk.watchStarts.push(path); + return get(signals(path)); + }), + ); + const listings = Atom.family((path: string) => + Atom.make(() => { + disk.reads.push(path); + return AsyncResult.success({ entries: disk.entries.get(path) ?? [], truncated: false }); + }).pipe(Atom.setIdleTTL("5 minutes")), + ); + let revision = 0; + disk.change.mockImplementation((path) => + appAtomRegistry.set(signals(path), AsyncResult.success(++revision)), + ); + return { + projectEnvironment: { + watch: ({ input }: { input: { relativePath: string } }) => watches(input.relativePath), + listEntries: ({ input }: { input: { directoryPath: string } }) => + listings(input.directoryPath), + }, + }; +}); + +import { useDirectoryEntries } from "./useDirectoryEntries"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { projectEnvironment } from "~/state/projects"; + +let root: Root; +let container: HTMLDivElement; +let folders: ReturnType; +function Probe() { + const entries = useDirectoryEntries(EnvironmentId.make("directory-watch"), "/workspace"); + useLayoutEffect(() => { + folders = entries; + }); + return
{entries.entries.map((entry) => entry.path).join("\n")}
; +} +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + disk.entries.clear(); + disk.reads.length = 0; + disk.watchStarts.length = 0; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(() => root.unmount()); + appAtomRegistry.reset(); + container.remove(); + vi.unstubAllGlobals(); +}); + +it("starts fresh folder subscriptions and updates expanded contents after host changes", async () => { + disk.entries.set("", [{ path: "nested", kind: "directory" }]); + disk.entries.set("nested", [{ path: "nested/old.txt", kind: "file" }]); + await act(async () => root.render()); + await act(async () => folders.load("nested")); + expect(container.textContent).toContain("nested/old.txt"); + expect(disk.watchStarts).toEqual([".", "nested"]); + + disk.entries.set("nested", [{ path: "nested/new.txt", kind: "file" }]); + await act(async () => disk.change("nested")); + expect(container.textContent).toContain("nested/new.txt"); + expect(container.textContent).not.toContain("nested/old.txt"); + + const reads = disk.reads.length; + await act(async () => { + disk.change("nested"); + root.unmount(); + }); + expect(disk.reads).toHaveLength(reads); +}); + +it("refreshes a cached watch revision after registering its cleanup", async () => { + disk.entries.set("", [{ path: "fresh.txt", kind: "file" }]); + disk.change("."); + appAtomRegistry.get( + projectEnvironment.watch({ + environmentId: EnvironmentId.make("directory-watch"), + input: { cwd: "/workspace", relativePath: ".", directory: true }, + }), + ); + await act(async () => root.render()); + expect(container.textContent).toBe("fresh.txt"); + expect(disk.watchStarts).toEqual(["."]); +}); diff --git a/apps/web/src/components/files/useDirectoryEntries.ts b/apps/web/src/components/files/useDirectoryEntries.ts index 55aa163ee62ddb5cb8cb1701650d825d17d83350..dee1f5e12283591c0650b3c7a13c8343d2b01de0 100644 --- a/apps/web/src/components/files/useDirectoryEntries.ts +++ b/apps/web/src/components/files/useDirectoryEntries.ts @@ -1,6 +1,7 @@ import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/reactivity"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { appAtomRegistry } from "~/rpc/atomRegistry"; @@ -17,17 +18,37 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { const loaded = useRef(new Set()); const requested = useRef(new Set()); const active = useRef(true); + const watches = useRef(new Map void>()); const running = useRef(0); const waiting = useRef void>>([]); const load = useCallback( function loadDirectory(directoryPath: string, refresh = false): Promise { + if (!active.current) return Promise.resolve(); const existing = requests.current.get(directoryPath); if (existing) return refresh ? existing.then(() => loadDirectory(directoryPath, true)) : existing; if (!refresh && loaded.current.has(directoryPath)) return Promise.resolve(); loaded.current.add(directoryPath); requested.current.add(directoryPath); + if (!watches.current.has(directoryPath)) { + const watch = projectEnvironment.watch({ + environmentId, + input: { cwd, relativePath: directoryPath || ".", directory: true }, + }); + watches.current.set( + directoryPath, + appAtomRegistry.subscribe( + watch, + (result) => { + if (AsyncResult.isSuccess(result)) { + queueMicrotask(() => void loadDirectory(directoryPath, true)); + } + }, + { immediate: true }, + ), + ); + } const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } }); setPending((count) => count + 1); if (!refresh) setLoadingDirectories((previous) => new Set(previous).add(directoryPath)); @@ -98,6 +119,9 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { void load(""); return () => { active.current = false; + for (const stop of watches.current.values()) stop(); + watches.current.clear(); + loaded.current.clear(); }; }, [load]); diff --git a/apps/web/src/components/files/useFileSaveCoordinator.test.tsx b/apps/web/src/components/files/useFileSaveCoordinator.test.tsx index 733c2c5f52d7577e3fb4ee5ceee61bc7e1bd61b4..5c5792d5af6bca32fb8101c94d282347c6156882 100644 --- a/apps/web/src/components/files/useFileSaveCoordinator.test.tsx +++ b/apps/web/src/components/files/useFileSaveCoordinator.test.tsx @@ -21,7 +21,6 @@ vi.mock("./projectFilesQueryState", () => ({ getUnsavedProjectFileQueryData: getUnsavedFile, })); -import { setMarkdownTaskChecked } from "./filePreviewMode"; import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; const environmentId = EnvironmentId.make("save-lifecycle-audit"); @@ -93,10 +92,9 @@ describe("file-save React lifecycle", () => { expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", false); }); - it("persists rendered Markdown task changes after StrictMode setup replay", async () => { + it("persists Markdown edits after StrictMode setup replay", async () => { mount({ ...defaultProps, relativePath: "README.md" }); - const nextContents = setMarkdownTaskChecked("- [ ] task\n", 2, true); - changeHandler()(nextContents); + changeHandler()("- [x] task\n"); await vi.advanceTimersByTimeAsync(500); expect(writeFile).toHaveBeenCalledExactlyOnceWith({ environmentId, diff --git a/docs/user/composer.md b/docs/user/composer.md index 3492e1612a1e4d45ca5bf5882cda5ff8fa860da6..02fc474919211e4da153aacf2141da7ed1f4e844 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -321,6 +321,14 @@ File links refer to the environment's machine, including when you connect remote Previews use the original file, even outside the workspace. Moving or deleting it can break the preview, so save a copy if you need to keep it. +## Workspace files + +On web and desktop, Markdown files open in an editable preview. Select text to reveal +its Markdown syntax; changes save automatically. Use the source toggle to edit raw +Markdown. Control-click or Command-click a link to open it, or place the caret in the +link and press Control+Enter or Command+Enter. Open files and folders update when +files change on the environment's machine, including over a remote connection. + ## Files outside the workspace Follow an agent's file link to read a report or other file outside the workspace. diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index c11a49897b88006d4d4177502572d365bb1e22bf..bf2e2ddc800d3247bfef923fea2a3f28a82282d9 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -69,6 +69,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeVcsStatus | typeof WS_METHODS.subscribeWorktreeSetup | typeof WS_METHODS.subscribeProjectClones + | typeof WS_METHODS.projectsWatch | typeof WS_METHODS.terminalAttach | typeof WS_METHODS.terminalObserve; diff --git a/packages/client-runtime/src/state/projectCommands.test.ts b/packages/client-runtime/src/state/projectCommands.test.ts index 9d7d47ea9d835190a663df51995610dc047106f3..5b7cc9fd77c02f85402e5c584eebc16f8ff2606c 100644 --- a/packages/client-runtime/src/state/projectCommands.test.ts +++ b/packages/client-runtime/src/state/projectCommands.test.ts @@ -43,6 +43,7 @@ const PROJECT = { const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { const metadataRequests: string[][] = []; const file = { byteLength: 5 }; + const fileChanges = yield* SubscriptionRef.make(0); const readResponse = { wait: Effect.void as Effect.Effect }; const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: { environmentId: ENVIRONMENT_ID }, @@ -50,6 +51,7 @@ const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { Option.some({ client: { [WS_METHODS.projectsEnsureScratch]: () => Effect.succeed({ projectId: PROJECT_ID }), + [WS_METHODS.projectsWatch]: () => SubscriptionRef.changes(fileChanges), [WS_METHODS.projectsReadFile]: () => Effect.gen(function* () { const byteLength = file.byteLength; @@ -122,7 +124,49 @@ const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { const openScratch = Effect.promise(() => commands.openScratch.run(registry, { environmentId: ENVIRONMENT_ID, input: {} }), ); - return { registry, storedProject, openScratch, commands, metadataRequests, readResponse }; + return { + registry, + storedProject, + openScratch, + commands, + metadataRequests, + readResponse, + file, + fileChanges, + }; +}); + +describe("watched file reads", () => { + it.effect("refreshes a mounted file query when its host reports a disk change", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const watch = h.commands.watch({ + environmentId: ENVIRONMENT_ID, + input: { cwd: "/repo", relativePath: "foo", directory: false }, + }); + const unwatch = h.registry.mount(watch); + yield* Effect.addFinalizer(() => Effect.sync(unwatch)); + yield* AtomRegistry.getResult(h.registry, watch); + const query = h.commands.readFile({ + environmentId: ENVIRONMENT_ID, + input: { cwd: "/repo", relativePath: "foo" }, + }); + const unmount = h.registry.mount(query); + yield* Effect.addFinalizer(() => Effect.sync(unmount)); + expect( + (yield* AtomRegistry.getResult(h.registry, query, { suspendOnWaiting: true })).byteLength, + ).toBe(5); + const updated = yield* AtomRegistry.toStream(h.registry, query).pipe( + Stream.filter((result) => AsyncResult.isSuccess(result) && result.value.byteLength === 12), + Stream.runHead, + Effect.forkChild, + ); + h.file.byteLength = 12; + yield* SubscriptionRef.set(h.fileChanges, 1); + const result = yield* Fiber.join(updated); + expect(Option.isSome(result)).toBe(true); + }).pipe(Effect.scoped), + ); }); describe("writeFile metadata", () => { diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 46b85f7ff06448f340dc273f5d8853ff86d99710..b0f09224c6cba106c4845f32019b450e6da3fa47 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -25,6 +25,7 @@ import { createEnvironmentCommand, createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily, + createEnvironmentRpcSubscriptionAtomFamily, } from "./runtime.ts"; import { type CreateProjectInput, @@ -100,6 +101,11 @@ export function createProjectEnvironmentAtoms( key: ({ environmentId, input }: { environmentId: string; input: { projectId: string } }) => JSON.stringify([environmentId, input.projectId]), }; + const watch = createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:projects:watch", + tag: WS_METHODS.projectsWatch, + idleTtlMs: 0, + }); return { fileMetadata: fileMetadata.metadata, fileMetadataThread: fileMetadata.retainThread, @@ -126,6 +132,7 @@ export function createProjectEnvironmentAtoms( (result, isCurrent) => fileMetadata.rememberEntries(input.cwd, result.entries, isCurrent), ), }), + watch, listEntries: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:list-entries", tag: WS_METHODS.projectsListEntries, @@ -140,6 +147,11 @@ export function createProjectEnvironmentAtoms( readFile: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:read-file", tag: WS_METHODS.projectsReadFile, + refreshTrigger: ({ environmentId, input }) => + watch({ + environmentId, + input: { ...input, directory: false }, + }), staleTimeMs: 30_000, idleTtlMs: 5 * 60_000, execute: (input) => diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index baff3dc827059dd0ebe8103f1087855cc5ebccb3..555ea3dde99c97fd44b9d40c92c528d9e70f9447 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -441,6 +441,12 @@ export const ProjectReadFileInput = Schema.Struct({ }); export type ProjectReadFileInput = typeof ProjectReadFileInput.Type; +export const ProjectWatchInput = Schema.Struct({ + ...ProjectReadFileInput.fields, + directory: Schema.Boolean, +}); +export type ProjectWatchInput = typeof ProjectWatchInput.Type; + export const ProjectReadFileResult = Schema.Struct({ relativePath: TrimmedNonEmptyString, contents: Schema.String, @@ -467,6 +473,7 @@ export const ProjectFileOperation = Schema.Literals([ "close", "make-directory", "write-file", + "watch", ]); export type ProjectFileOperation = typeof ProjectFileOperation.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 508c56813c3eea9771befccfcbfa93fd5d6977e5..3c72cc34ad56a44c132e2f026077739586f64b63 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -225,6 +225,7 @@ import { ProjectListEntriesResult, ProjectReadFileError, ProjectReadFileInput, + ProjectWatchInput, ProjectReadFileResult, ProjectSearchContentsError, ProjectSearchContentsInput, @@ -376,6 +377,7 @@ export const WS_METHODS = { projectsRemove: "projects.remove", projectsListEntries: "projects.listEntries", projectsReadFile: "projects.readFile", + projectsWatch: "projects.watch", projectsSearchContents: "projects.searchContents", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", @@ -1233,6 +1235,13 @@ const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { error: Schema.Union([ProjectReadFileError, EnvironmentAuthorizationError]), }); +const WsProjectsWatchRpc = Rpc.make(WS_METHODS.projectsWatch, { + payload: ProjectWatchInput, + success: Schema.Number, + error: Schema.Union([ProjectReadFileError, EnvironmentAuthorizationError]), + stream: true, +}); + const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { payload: ProjectWriteFileInput, success: ProjectWriteFileResult, @@ -1994,6 +2003,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeProjectClonesRpc, WsProjectsListEntriesRpc, WsProjectsReadFileRpc, + WsProjectsWatchRpc, WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, WsProjectsEnsureScratchRpc,