From 27403a9c5d0bce544d3ccb92fb4d340f952eac69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kayla=20=E3=81=AF=E3=81=AA?= Date: Thu, 7 May 2026 22:29:06 +0000 Subject: [PATCH] fix(site): keep TemplateVersionEditor file tree in sync with the page The editor kept its own local copy of the file tree, while the page kept the initial copy loaded from the tar. The page resolved the active path against its stale tree, so creating a file fell back to the entrypoint and renaming threw 'File is not a text file' from getFileText. Lift the file tree state up to TemplateVersionEditorPage so there is a single source of truth. Add regression tests for create and rename. Generated by Coder Agents. --- .../TemplateVersionEditor.stories.tsx | 3 +- .../TemplateVersionEditor.tsx | 17 +++-- .../TemplateVersionEditorPage.test.tsx | 72 +++++++++++++++++++ .../TemplateVersionEditorPage.tsx | 22 +++--- 4 files changed, 94 insertions(+), 20 deletions(-) diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx index b4a553e4ec898..9fb9a8c6e8e69 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx @@ -31,7 +31,8 @@ const meta: Meta = { activePath: "main.tf", template: MockTemplate, templateVersion: MockTemplateVersion, - defaultFileTree: MockTemplateVersionFileTree, + fileTree: MockTemplateVersionFileTree, + onFileTreeChange: action("onFileTreeChange"), onPublish: action("onPublish"), onConfirmPublish: action("onConfirmPublish"), onCancelPublish: action("onCancelPublish"), diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx index 29e8767b3e36a..9c7e122ff2042 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx @@ -78,7 +78,8 @@ type Tab = "logs" | "resources" | undefined; // Undefined is to hide the tab interface TemplateVersionEditorProps { template: Template; templateVersion: TemplateVersion; - defaultFileTree: FileTree; + fileTree: FileTree; + onFileTreeChange: (updater: (fileTree: FileTree) => FileTree) => void; buildLogs?: ProvisionerJobLog[]; resources?: WorkspaceResource[]; isBuilding: boolean; @@ -108,7 +109,8 @@ export const TemplateVersionEditor: FC = ({ canPublish, template, templateVersion, - defaultFileTree, + fileTree, + onFileTreeChange, onPreview, onPublish, onConfirmPublish, @@ -133,7 +135,6 @@ export const TemplateVersionEditor: FC = ({ const navigate = useNavigate(); const getLink = useLinks(); const [selectedTab, setSelectedTab] = useState(defaultTab); - const [fileTree, setFileTree] = useState(defaultFileTree); const [createFileOpen, setCreateFileOpen] = useState(false); const [deleteFileOpen, setDeleteFileOpen] = useState(); const [renameFileOpen, setRenameFileOpen] = useState(); @@ -351,7 +352,9 @@ export const TemplateVersionEditor: FC = ({ }} checkExists={(path) => existsFile(path, fileTree)} onConfirm={(path) => { - setFileTree((fileTree) => createFile(path, fileTree, "")); + onFileTreeChange((fileTree) => + createFile(path, fileTree, ""), + ); onActivePathChange(path); setCreateFileOpen(false); setDirty(true); @@ -362,7 +365,7 @@ export const TemplateVersionEditor: FC = ({ if (!deleteFileOpen) { throw new Error("delete file must be set"); } - setFileTree((fileTree) => + onFileTreeChange((fileTree) => removeFile(deleteFileOpen, fileTree), ); setDeleteFileOpen(undefined); @@ -387,7 +390,7 @@ export const TemplateVersionEditor: FC = ({ if (!renameFileOpen) { return; } - setFileTree((fileTree) => + onFileTreeChange((fileTree) => moveFile(renameFileOpen, newPath, fileTree), ); onActivePathChange(newPath); @@ -433,7 +436,7 @@ export const TemplateVersionEditor: FC = ({ if (!activePath) { return; } - setFileTree((fileTree) => + onFileTreeChange((fileTree) => updateFile(activePath, value, fileTree), ); setDirty(true); diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx index 157ec23a2592a..2c660fcbaa103 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx @@ -295,6 +295,78 @@ test("Preserves the currently open file path when building a template version", expect(router.state.location.search).toBe("?path=myfile.tf"); }); +test("Creating a new file opens it in the editor", async () => { + const user = userEvent.setup(); + const { router } = renderWithAuth(, { + route: `/templates/${MockTemplate.name}/versions/${MockTemplateVersion.name}/edit`, + path: "/templates/:template/versions/:version/edit", + }); + + // Wait for the default entrypoint file to load. + const editor = await screen.findByTestId("monaco-editor"); + await waitFor(() => { + expect(editor).not.toHaveValue(""); + }); + + const createButton = await screen.findByRole("button", { + name: "Create File", + }); + await user.click(createButton); + + const dialog = await screen.findByTestId("dialog"); + const pathField = within(dialog).getByLabelText("File Path"); + await user.type(pathField, "newfile.tf"); + await user.click(within(dialog).getByRole("button", { name: "Create" })); + + // The new (empty) file should be opened in the editor and the URL path + // query parameter should reflect the new file. + await waitFor(() => { + expect(screen.getByTestId("monaco-editor")).toHaveValue(""); + }); + expect(router.state.location.search).toBe("?path=newfile.tf"); +}); + +test("Renaming a file does not throw and opens the new path", async () => { + const user = userEvent.setup(); + const { router } = renderWithAuth(, { + route: `/templates/${MockTemplate.name}/versions/${MockTemplateVersion.name}/edit`, + path: "/templates/:template/versions/:version/edit", + }); + + // Wait for the default entrypoint file to load and capture its content so + // we can confirm the same content is shown after renaming. + const editor = await screen.findByTestId("monaco-editor"); + await waitFor(() => { + expect(editor).not.toHaveValue(""); + }); + if (!(editor instanceof HTMLTextAreaElement)) { + throw new Error("editor is not a textarea"); + } + const originalContent = editor.value; + + // Open the file actions menu for the active file and click Rename. + const fileActions = await screen.findByRole("button", { + name: "File actions", + }); + await user.click(fileActions); + await user.click(await screen.findByRole("menuitem", { name: /rename/i })); + + const dialog = await screen.findByTestId("dialog"); + const pathField = within(dialog).getByLabelText("File Path"); + await user.clear(pathField); + await user.type(pathField, "renamed.tf"); + await user.click(within(dialog).getByRole("button", { name: "Rename" })); + + // The renamed file should still be open with its original content and + // the URL path query parameter should reflect the new name. Previously + // this path threw "File is not a text file" because the parent's stale + // file tree fell back to the old entrypoint name. + await waitFor(() => { + expect(screen.getByTestId("monaco-editor")).toHaveValue(originalContent); + }); + expect(router.state.location.search).toBe("?path=renamed.tf"); +}); + describe.each([ { testName: "Do not ask when template version has no errors", diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx index d46f06b7baca0..96281a1d9ed83 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx @@ -72,7 +72,7 @@ const TemplateVersionEditorPage: FC = () => { const logs = useWatchVersionLogs(activeTemplateVersion, { onDone: activeTemplateVersionQuery.refetch, }); - const { fileTree, tarFile } = useFileTree(activeTemplateVersion); + const { fileTree, setFileTree, tarFile } = useFileTree(activeTemplateVersion); const { missingVariables, setIsMissingVariablesDialogOpen, @@ -138,7 +138,10 @@ const TemplateVersionEditorPage: FC = () => { onActivePathChange={onActivePathChange} template={templateQuery.data} templateVersion={activeTemplateVersion} - defaultFileTree={fileTree} + fileTree={fileTree} + onFileTreeChange={(updater) => { + setFileTree((current) => (current ? updater(current) : current)); + }} onPreview={async (newFileTree) => { if (!tarFile) { return; @@ -244,13 +247,8 @@ const useFileTree = (templateVersion: TemplateVersion | undefined) => { ...file(templateVersion?.job.file_id ?? ""), enabled: templateVersion !== undefined, }); - const [state, setState] = useState<{ - fileTree?: FileTree; - tarFile?: TarReader; - }>({ - fileTree: undefined, - tarFile: undefined, - }); + const [fileTree, setFileTree] = useState(undefined); + const [tarFile, setTarFile] = useState(undefined); useEffect(() => { let stale = false; @@ -262,8 +260,8 @@ const useFileTree = (templateVersion: TemplateVersion | undefined) => { if (stale) { return; } - const fileTree = createTemplateVersionFileTree(tarFile); - setState({ fileTree, tarFile }); + setFileTree(createTemplateVersionFileTree(tarFile)); + setTarFile(tarFile); } catch (error) { console.error(error); toast.error("Error on initializing the editor.", { @@ -281,7 +279,7 @@ const useFileTree = (templateVersion: TemplateVersion | undefined) => { }; }, [fileQuery.data]); - return state; + return { fileTree, setFileTree, tarFile }; }; const useMissingVariables = (templateVersion: TemplateVersion | undefined) => {