| author | |
| committer | |
| log | b01771c2383bbcdf154b8c4e9f1a7d5e3867e2d0 |
| tree | 789a6f9a5472d80efa28350e2a48029c7f79441f |
| parent | 363cde4114c2186c496ed97cafba4a8737ea5585 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 231 insertions(+), 35 deletions(-)
apps/web/src/components/files/FilePreviewPanel.tsx+1-35| ... | @@ -47,7 +47,6 @@ import { buildFileReviewComment } from "~/reviewCommentContext"; | ... | @@ -47,7 +47,6 @@ import { buildFileReviewComment } from "~/reviewCommentContext"; |
| 47 | import { assetEnvironment } from "~/state/assets"; | 47 | import { assetEnvironment } from "~/state/assets"; |
| 48 | import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; | 48 | import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; |
| 49 | import { previewEnvironment } from "~/state/preview"; | 49 | import { previewEnvironment } from "~/state/preview"; |
| 50 | import { projectEnvironment } from "~/state/projects"; | ||
| 51 | import { useAtomCommand } from "~/state/use-atom-command"; | 50 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 52 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; | 51 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; |
| 53 | 52 | ||
| ... | @@ -72,9 +71,8 @@ import { | ... | @@ -72,9 +71,8 @@ import { |
| 72 | setMarkdownTaskChecked, | 71 | setMarkdownTaskChecked, |
| 73 | shouldShowFileExplorer, | 72 | shouldShowFileExplorer, |
| 74 | } from "./filePreviewMode"; | 73 | } from "./filePreviewMode"; |
| 75 | import { FileSaveCoordinator } from "./fileSaveCoordinator"; | 74 | import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; |
| 76 | import { | 75 | import { |
| 77 | confirmProjectFileQueryData, | ||
| 78 | getOptimisticProjectFileQueryData, | 76 | getOptimisticProjectFileQueryData, |
| 79 | setProjectFileQueryData, | 77 | setProjectFileQueryData, |
| 80 | useProjectFileQuery, | 78 | useProjectFileQuery, |
| ... | @@ -101,7 +99,6 @@ interface FilePreviewPanelProps { | ... | @@ -101,7 +99,6 @@ interface FilePreviewPanelProps { |
| 101 | const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; | 99 | const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; |
| 102 | const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown"; | 100 | const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown"; |
| 103 | const RENDER_BROWSER_FILE_STORAGE_KEY = "t3code.renderBrowserFile"; | 101 | const RENDER_BROWSER_FILE_STORAGE_KEY = "t3code.renderBrowserFile"; |
| 104 | const FILE_SAVE_DEBOUNCE_MS = 500; | ||
| 105 | const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal"; | 102 | const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal"; |
| 106 | const FILE_LINK_REVEAL_UNSAFE_CSS = ` | 103 | const FILE_LINK_REVEAL_UNSAFE_CSS = ` |
| 107 | ${DIFF_SURFACE_THEME_UNSAFE_CSS} | 104 | ${DIFF_SURFACE_THEME_UNSAFE_CSS} |
| ... | @@ -613,37 +610,6 @@ interface FileSelectionOverride { | ... | @@ -613,37 +610,6 @@ interface FileSelectionOverride { |
| 613 | range: SelectedLineRange | null; | 610 | range: SelectedLineRange | null; |
| 614 | } | 611 | } |
| 615 | 612 | ||
| 616 | function useFileSaveCoordinator({ | ||
| 617 | environmentId, | ||
| 618 | cwd, | ||
| 619 | relativePath, | ||
| 620 | onPendingChange, | ||
| 621 | }: Pick< | ||
| 622 | EditableFileSurfaceProps, | ||
| 623 | "environmentId" | "cwd" | "relativePath" | "onPendingChange" | ||
| 624 | >): FileSaveCoordinator { | ||
| 625 | const writeFile = useAtomCommand(projectEnvironment.writeFile); | ||
| 626 | const coordinator = useMemo( | ||
| 627 | () => | ||
| 628 | new FileSaveCoordinator({ | ||
| 629 | debounceMs: FILE_SAVE_DEBOUNCE_MS, | ||
| 630 | onPendingChange: (pending) => onPendingChange(relativePath, pending), | ||
| 631 | persist: (nextContents) => | ||
| 632 | writeFile({ | ||
| 633 | environmentId, | ||
| 634 | input: { cwd, relativePath, contents: nextContents }, | ||
| 635 | }), | ||
| 636 | onConfirmed: (confirmedContents) => { | ||
| 637 | confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); | ||
| 638 | }, | ||
| 639 | }), | ||
| 640 | [cwd, environmentId, onPendingChange, relativePath, writeFile], | ||
| 641 | ); | ||
| 642 | |||
| 643 | useEffect(() => () => coordinator.dispose(), [coordinator]); | ||
| 644 | return coordinator; | ||
| 645 | } | ||
| 646 | |||
| 647 | function EditableFileSurface({ | 613 | function EditableFileSurface({ |
| 648 | environmentId, | 614 | environmentId, |
| 649 | cwd, | 615 | cwd, |
apps/web/src/components/files/useFileSaveCoordinator.test.tsx created+174| ... | @@ -0,0 +1,174 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 3 | import { act, StrictMode } from "react"; | ||
| 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 6 | |||
| 7 | const { writeFile, confirmFile } = vi.hoisted(() => ({ | ||
| 8 | writeFile: vi.fn(), | ||
| 9 | confirmFile: vi.fn(), | ||
| 10 | })); | ||
| 11 | vi.mock("~/state/projects", () => ({ projectEnvironment: { writeFile: {} } })); | ||
| 12 | vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => writeFile })); | ||
| 13 | vi.mock("./projectFilesQueryState", () => ({ confirmProjectFileQueryData: confirmFile })); | ||
| 14 | |||
| 15 | import { setMarkdownTaskChecked } from "./filePreviewMode"; | ||
| 16 | import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; | ||
| 17 | |||
| 18 | const environmentId = EnvironmentId.make("save-lifecycle-audit"); | ||
| 19 | const onPendingChange = vi.fn(); | ||
| 20 | const defaultProps = { | ||
| 21 | environmentId, | ||
| 22 | cwd: "/workspace", | ||
| 23 | relativePath: "file.txt", | ||
| 24 | onPendingChange, | ||
| 25 | }; | ||
| 26 | let renderer: ReactTestRenderer | null; | ||
| 27 | |||
| 28 | function ChangeSource(_props: { onChange: (contents: string) => void }) { | ||
| 29 | return null; | ||
| 30 | } | ||
| 31 | |||
| 32 | function FileSurface(props: Parameters<typeof useFileSaveCoordinator>[0]) { | ||
| 33 | const coordinator = useFileSaveCoordinator(props); | ||
| 34 | return <ChangeSource onChange={(contents) => coordinator.change(contents)} />; | ||
| 35 | } | ||
| 36 | |||
| 37 | function mount(props = defaultProps) { | ||
| 38 | act(() => { | ||
| 39 | renderer = create( | ||
| 40 | <StrictMode> | ||
| 41 | <FileSurface {...props} /> | ||
| 42 | </StrictMode>, | ||
| 43 | ); | ||
| 44 | }); | ||
| 45 | } | ||
| 46 | |||
| 47 | function changeHandler(): (contents: string) => void { | ||
| 48 | return renderer!.root.findByType(ChangeSource).props.onChange; | ||
| 49 | } | ||
| 50 | |||
| 51 | beforeEach(() => { | ||
| 52 | renderer = null; | ||
| 53 | vi.useFakeTimers(); | ||
| 54 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 55 | writeFile.mockReset().mockResolvedValue(AsyncResult.success(undefined)); | ||
| 56 | confirmFile.mockReset(); | ||
| 57 | onPendingChange.mockReset(); | ||
| 58 | }); | ||
| 59 | |||
| 60 | afterEach(async () => { | ||
| 61 | await act(async () => renderer?.unmount()); | ||
| 62 | vi.useRealTimers(); | ||
| 63 | vi.unstubAllGlobals(); | ||
| 64 | }); | ||
| 65 | |||
| 66 | describe("file-save React lifecycle", () => { | ||
| 67 | it("persists editor model changes after StrictMode setup replay", async () => { | ||
| 68 | mount(); | ||
| 69 | changeHandler()("AUDIT7907NATIVE\n"); | ||
| 70 | expect(onPendingChange).toHaveBeenCalledWith("file.txt", true); | ||
| 71 | await vi.advanceTimersByTimeAsync(500); | ||
| 72 | expect(writeFile).toHaveBeenCalledExactlyOnceWith({ | ||
| 73 | environmentId, | ||
| 74 | input: { cwd: "/workspace", relativePath: "file.txt", contents: "AUDIT7907NATIVE\n" }, | ||
| 75 | }); | ||
| 76 | expect(confirmFile).toHaveBeenCalledExactlyOnceWith( | ||
| 77 | environmentId, | ||
| 78 | "/workspace", | ||
| 79 | "file.txt", | ||
| 80 | "AUDIT7907NATIVE\n", | ||
| 81 | ); | ||
| 82 | expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", false); | ||
| 83 | }); | ||
| 84 | |||
| 85 | it("persists rendered Markdown task changes after StrictMode setup replay", async () => { | ||
| 86 | mount({ ...defaultProps, relativePath: "README.md" }); | ||
| 87 | const nextContents = setMarkdownTaskChecked("- [ ] task\n", 2, true); | ||
| 88 | changeHandler()(nextContents); | ||
| 89 | await vi.advanceTimersByTimeAsync(500); | ||
| 90 | expect(writeFile).toHaveBeenCalledExactlyOnceWith({ | ||
| 91 | environmentId, | ||
| 92 | input: { cwd: "/workspace", relativePath: "README.md", contents: "- [x] task\n" }, | ||
| 93 | }); | ||
| 94 | }); | ||
| 95 | |||
| 96 | it("keeps the debounce across rerenders of the same file", async () => { | ||
| 97 | mount(); | ||
| 98 | changeHandler()("first"); | ||
| 99 | await vi.advanceTimersByTimeAsync(300); | ||
| 100 | act(() => | ||
| 101 | renderer!.update( | ||
| 102 | <StrictMode> | ||
| 103 | <FileSurface {...defaultProps} /> | ||
| 104 | </StrictMode>, | ||
| 105 | ), | ||
| 106 | ); | ||
| 107 | changeHandler()("latest"); | ||
| 108 | await vi.advanceTimersByTimeAsync(499); | ||
| 109 | expect(writeFile).not.toHaveBeenCalled(); | ||
| 110 | await vi.advanceTimersByTimeAsync(1); | ||
| 111 | expect(writeFile).toHaveBeenCalledTimes(1); | ||
| 112 | expect(writeFile.mock.calls[0]![0].input.contents).toBe("latest"); | ||
| 113 | }); | ||
| 114 | |||
| 115 | it("flushes on unmount and ignores a retired editor callback", async () => { | ||
| 116 | mount(); | ||
| 117 | const retiredChange = changeHandler(); | ||
| 118 | retiredChange("pending edit"); | ||
| 119 | await act(async () => renderer!.unmount()); | ||
| 120 | renderer = null; | ||
| 121 | retiredChange("stale editor contents"); | ||
| 122 | await vi.runAllTimersAsync(); | ||
| 123 | expect(writeFile).toHaveBeenCalledTimes(1); | ||
| 124 | expect(writeFile.mock.calls[0]![0].input.contents).toBe("pending edit"); | ||
| 125 | }); | ||
| 126 | |||
| 127 | it.each([ | ||
| 128 | { relativePath: "other.txt" }, | ||
| 129 | { cwd: "/other-workspace" }, | ||
| 130 | { environmentId: EnvironmentId.make("other-environment") }, | ||
| 131 | ])("retires callbacks when the file identity changes: %j", async (change) => { | ||
| 132 | mount(); | ||
| 133 | const retiredChange = changeHandler(); | ||
| 134 | retiredChange("old file edit"); | ||
| 135 | const nextProps = { ...defaultProps, ...change }; | ||
| 136 | act(() => | ||
| 137 | renderer!.update( | ||
| 138 | <StrictMode> | ||
| 139 | <FileSurface {...nextProps} /> | ||
| 140 | </StrictMode>, | ||
| 141 | ), | ||
| 142 | ); | ||
| 143 | retiredChange("stale editor contents"); | ||
| 144 | changeHandler()("new file edit"); | ||
| 145 | await vi.runAllTimersAsync(); | ||
| 146 | expect(writeFile.mock.calls.map(([request]) => request)).toEqual([ | ||
| 147 | { | ||
| 148 | environmentId, | ||
| 149 | input: { cwd: "/workspace", relativePath: "file.txt", contents: "old file edit" }, | ||
| 150 | }, | ||
| 151 | { | ||
| 152 | environmentId: nextProps.environmentId, | ||
| 153 | input: { | ||
| 154 | cwd: nextProps.cwd, | ||
| 155 | relativePath: nextProps.relativePath, | ||
| 156 | contents: "new file edit", | ||
| 157 | }, | ||
| 158 | }, | ||
| 159 | ]); | ||
| 160 | }); | ||
| 161 | |||
| 162 | it("does not reactivate a retired callback when the same file mounts again", async () => { | ||
| 163 | mount(); | ||
| 164 | const retiredChange = changeHandler(); | ||
| 165 | await act(async () => renderer!.unmount()); | ||
| 166 | renderer = null; | ||
| 167 | mount(); | ||
| 168 | retiredChange("stale contents"); | ||
| 169 | changeHandler()("current contents"); | ||
| 170 | await vi.runAllTimersAsync(); | ||
| 171 | expect(writeFile).toHaveBeenCalledTimes(1); | ||
| 172 | expect(writeFile.mock.calls[0]![0].input.contents).toBe("current contents"); | ||
| 173 | }); | ||
| 174 | }); | ||
apps/web/src/components/files/useFileSaveCoordinator.ts created+56| ... | @@ -0,0 +1,56 @@ | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { createRef, useEffect, useMemo } from "react"; | ||
| 3 | |||
| 4 | import { projectEnvironment } from "~/state/projects"; | ||
| 5 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 6 | |||
| 7 | import { FileSaveCoordinator } from "./fileSaveCoordinator"; | ||
| 8 | import { confirmProjectFileQueryData } from "./projectFilesQueryState"; | ||
| 9 | |||
| 10 | const FILE_SAVE_DEBOUNCE_MS = 500; | ||
| 11 | |||
| 12 | interface FileSaveOptions { | ||
| 13 | environmentId: EnvironmentId; | ||
| 14 | cwd: string; | ||
| 15 | relativePath: string; | ||
| 16 | onPendingChange: (relativePath: string, pending: boolean) => void; | ||
| 17 | } | ||
| 18 | |||
| 19 | export function useFileSaveCoordinator({ | ||
| 20 | environmentId, | ||
| 21 | cwd, | ||
| 22 | relativePath, | ||
| 23 | onPendingChange, | ||
| 24 | }: FileSaveOptions): Pick<FileSaveCoordinator, "change"> { | ||
| 25 | const writeFile = useAtomCommand(projectEnvironment.writeFile); | ||
| 26 | const session = useMemo(() => { | ||
| 27 | const coordinatorRef = createRef<FileSaveCoordinator>(); | ||
| 28 | return { | ||
| 29 | change: (contents: string) => coordinatorRef.current?.change(contents), | ||
| 30 | setup: () => { | ||
| 31 | const coordinator = new FileSaveCoordinator({ | ||
| 32 | debounceMs: FILE_SAVE_DEBOUNCE_MS, | ||
| 33 | onPendingChange: (pending) => onPendingChange(relativePath, pending), | ||
| 34 | persist: (nextContents) => | ||
| 35 | writeFile({ | ||
| 36 | environmentId, | ||
| 37 | input: { cwd, relativePath, contents: nextContents }, | ||
| 38 | }), | ||
| 39 | onConfirmed: (confirmedContents) => { | ||
| 40 | confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); | ||
| 41 | }, | ||
| 42 | }); | ||
| 43 | coordinatorRef.current = coordinator; | ||
| 44 | return () => { | ||
| 45 | coordinatorRef.current = null; | ||
| 46 | coordinator.dispose(); | ||
| 47 | }; | ||
| 48 | }, | ||
| 49 | }; | ||
| 50 | }, [cwd, environmentId, onPendingChange, relativePath, writeFile]); | ||
| 51 | |||
| 52 | // StrictMode replays effect setup. Retired file sessions stay inert, while the | ||
| 53 | // replay gets a fresh coordinator instead of reusing a disposed one. | ||
| 54 | useEffect(session.setup, [session]); | ||
| 55 | return session; | ||
| 56 | } | ||