diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3a5057c13f3d5..d37e8b2c1e929 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -583,9 +583,6 @@ jobs: - run: yarn playwright:install working-directory: site - - run: yarn playwright:install-deps - working-directory: site - - run: yarn playwright:test env: DEBUG: pw:api diff --git a/site/package.json b/site/package.json index 2cc3c480f0b34..e63df76dff585 100644 --- a/site/package.json +++ b/site/package.json @@ -15,8 +15,7 @@ "format:write:only": "prettier --write", "lint": "jest --selectProjects lint", "lint:fix": "FIX=true yarn lint", - "playwright:install": "playwright install", - "playwright:install-deps": "playwright install-deps", + "playwright:install": "playwright install --with-deps chromium", "playwright:test": "playwright test --config=e2e/playwright.config.ts", "storybook": "start-storybook -p 6006", "storybook:build": "build-storybook", diff --git a/site/src/components/TemplateVersionEditor/FileTree.tsx b/site/src/components/TemplateVersionEditor/FileTreeView.tsx similarity index 65% rename from site/src/components/TemplateVersionEditor/FileTree.tsx rename to site/src/components/TemplateVersionEditor/FileTreeView.tsx index c3033c6564721..ba5b2236795c8 100644 --- a/site/src/components/TemplateVersionEditor/FileTree.tsx +++ b/site/src/components/TemplateVersionEditor/FileTreeView.tsx @@ -5,104 +5,73 @@ import TreeView from "@material-ui/lab/TreeView" import TreeItem from "@material-ui/lab/TreeItem" import Menu from "@material-ui/core/Menu" import MenuItem from "@material-ui/core/MenuItem" -import { FC, useMemo, useState } from "react" -import { TemplateVersionFiles } from "util/templateVersion" +import { FC, useState } from "react" +import { FileTree } from "util/filetree" import { DockerIcon } from "components/Icons/DockerIcon" -export interface File { +const sortFileTree = (fileTree: FileTree) => (a: string, b: string) => { + const contentA = fileTree[a] + const contentB = fileTree[b] + if (typeof contentA === "object") { + return -1 + } + if (typeof contentB === "object") { + return 1 + } + return a.localeCompare(b) +} + +type ContextMenu = { path: string - content?: string - children: Record + clientX: number + clientY: number } -export const FileTree: FC<{ - onSelect: (file: File) => void - onDelete: (file: File) => void - onRename: (file: File) => void - files: TemplateVersionFiles - activeFile?: File -}> = ({ activeFile, files, onDelete, onRename, onSelect }) => { +export const FileTreeView: FC<{ + onSelect: (path: string) => void + onDelete: (path: string) => void + onRename: (path: string) => void + fileTree: FileTree + activePath?: string +}> = ({ fileTree, activePath, onDelete, onRename, onSelect }) => { const styles = useStyles() - const fileTree = useMemo>(() => { - const paths = Object.keys(files) - const roots: Record = {} - paths.forEach((path) => { - const pathParts = path.split("/") - const firstPart = pathParts.shift() - if (!firstPart) { - // Not possible! - return - } - let activeFile = roots[firstPart] - if (!activeFile) { - activeFile = { - path: firstPart, - children: {}, - } - roots[firstPart] = activeFile - } - while (pathParts.length > 0) { - const pathPart = pathParts.shift() - if (!pathPart) { - continue - } - if (!activeFile.children[pathPart]) { - activeFile.children[pathPart] = { - path: activeFile.path + "/" + pathPart, - children: {}, - } - } - activeFile = activeFile.children[pathPart] - } - activeFile.content = files[path] - activeFile.path = path - }) - return roots - }, [files]) - const [contextMenu, setContextMenu] = useState< - | { - file: File - clientX: number - clientY: number - } - | undefined - >() - - const buildTreeItems = (name: string, file: File): JSX.Element => { + const [contextMenu, setContextMenu] = useState() + + const buildTreeItems = ( + filename: string, + content?: FileTree | string, + parentPath?: string, + ): JSX.Element => { + const currentPath = parentPath ? `${parentPath}/${filename}` : filename let icon: JSX.Element | null = null - if (file.path.endsWith(".tf")) { + if (filename.endsWith(".tf")) { icon = } - if (file.path.endsWith(".md")) { + if (filename.endsWith(".md")) { icon = } - if (file.path.endsWith("Dockerfile")) { + if (filename.endsWith("Dockerfile")) { icon = } return ( { - if (file.content) { - onSelect(file) - } + onSelect(currentPath) }} onContextMenu={(event) => { event.preventDefault() - if (!file.content) { - return - } setContextMenu( contextMenu ? undefined : { - file: file, + path: currentPath, clientY: event.clientY, clientX: event.clientX, }, @@ -110,9 +79,16 @@ export const FileTree: FC<{ }} icon={icon} > - {Object.entries(file.children || {}).map(([name, file]) => { - return buildTreeItems(name, file) - })} + {typeof content === "object" ? ( + Object.keys(content) + .sort(sortFileTree(content)) + .map((filename) => { + const child = content[filename] + return buildTreeItems(filename, child, currentPath) + }) + ) : ( + <> + )} ) } @@ -124,9 +100,12 @@ export const FileTree: FC<{ aria-label="Files" className={styles.fileTree} > - {Object.entries(fileTree).map(([name, file]) => { - return buildTreeItems(name, file) - })} + {Object.keys(fileTree) + .sort(sortFileTree(fileTree)) + .map((filename) => { + const child = fileTree[filename] + return buildTreeItems(filename, child) + })} setContextMenu(undefined)} @@ -154,7 +133,7 @@ export const FileTree: FC<{ if (!contextMenu) { return } - onRename(contextMenu.file) + onRename(contextMenu.path) setContextMenu(undefined) }} > @@ -165,7 +144,7 @@ export const FileTree: FC<{ if (!contextMenu) { return } - onDelete(contextMenu.file) + onDelete(contextMenu.path) setContextMenu(undefined) }} > diff --git a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.stories.tsx b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.stories.tsx index 78b5863bd74ce..24a40ad4d4fae 100644 --- a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.stories.tsx +++ b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.stories.tsx @@ -2,7 +2,7 @@ import { Story } from "@storybook/react" import { MockTemplate, MockTemplateVersion, - MockTemplateVersionFiles, + MockTemplateVersionFileTree, MockWorkspaceBuildLogs, MockWorkspaceResource, MockWorkspaceResource2, @@ -29,7 +29,7 @@ export const Example = Template.bind({}) Example.args = { template: MockTemplate, templateVersion: MockTemplateVersion, - initialFiles: MockTemplateVersionFiles, + defaultFileTree: MockTemplateVersionFileTree, } export const Logs = Template.bind({}) @@ -37,7 +37,7 @@ export const Logs = Template.bind({}) Logs.args = { template: MockTemplate, templateVersion: MockTemplateVersion, - initialFiles: MockTemplateVersionFiles, + defaultFileTree: MockTemplateVersionFileTree, buildLogs: MockWorkspaceBuildLogs, } @@ -46,7 +46,7 @@ export const Resources = Template.bind({}) Resources.args = { template: MockTemplate, templateVersion: MockTemplateVersion, - initialFiles: MockTemplateVersionFiles, + defaultFileTree: MockTemplateVersionFileTree, buildLogs: MockWorkspaceBuildLogs, resources: [ MockWorkspaceResource, diff --git a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx index 2e7dc642797d9..1f7990099ed32 100644 --- a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx +++ b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx @@ -16,49 +16,60 @@ import { AvatarData } from "components/AvatarData/AvatarData" import { TemplateResourcesTable } from "components/TemplateResourcesTable/TemplateResourcesTable" import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs" import { FC, useCallback, useEffect, useRef, useState } from "react" -import { dashboardContentBottomPadding, navHeight } from "theme/constants" -import { TemplateVersionFiles } from "util/templateVersion" +import { navHeight, dashboardContentBottomPadding } from "theme/constants" +import { + existsFile, + FileTree, + getFileContent, + isFolder, + removeFile, + setFile, + traverse, +} from "util/filetree" import { CreateFileDialog, DeleteFileDialog, RenameFileDialog, } from "./FileDialog" -import { FileTree } from "./FileTree" +import { FileTreeView } from "./FileTreeView" import { MonacoEditor } from "./MonacoEditor" import { getStatus, TemplateVersionStatusBadge, } from "./TemplateVersionStatusBadge" -interface File { - path: string - content?: string - children: Record -} - export interface TemplateVersionEditorProps { template: Template templateVersion: TemplateVersion - initialFiles: TemplateVersionFiles - + defaultFileTree: FileTree buildLogs?: ProvisionerJobLog[] resources?: WorkspaceResource[] - disablePreview: boolean disableUpdate: boolean - - onPreview: (files: TemplateVersionFiles) => void + onPreview: (files: FileTree) => void onUpdate: () => void } const topbarHeight = navHeight +const findInitialFile = (fileTree: FileTree): string | undefined => { + let initialFile: string | undefined + + traverse(fileTree, (content, filename, path) => { + if (filename.endsWith(".tf")) { + initialFile = path + } + }) + + return initialFile +} + export const TemplateVersionEditor: FC = ({ disablePreview, disableUpdate, template, templateVersion, - initialFiles, + defaultFileTree, onPreview, onUpdate, buildLogs, @@ -69,29 +80,19 @@ export const TemplateVersionEditor: FC = ({ // This is for Storybook! return resources ? 1 : 0 }) - const [files, setFiles] = useState(initialFiles) + const [fileTree, setFileTree] = useState(defaultFileTree) const [createFileOpen, setCreateFileOpen] = useState(false) - const [deleteFileOpen, setDeleteFileOpen] = useState() - const [renameFileOpen, setRenameFileOpen] = useState() - const [activeFile, setActiveFile] = useState(() => { - const fileKeys = Object.keys(initialFiles) - for (let i = 0; i < fileKeys.length; i++) { - // Open a Terraform file by default! - if (fileKeys[i].endsWith(".tf")) { - return { - path: fileKeys[i], - content: initialFiles[fileKeys[i]], - children: {}, - } - } - } - }) + const [deleteFileOpen, setDeleteFileOpen] = useState() + const [renameFileOpen, setRenameFileOpen] = useState() + const [activePath, setActivePath] = useState(() => + findInitialFile(fileTree), + ) const triggerPreview = useCallback(() => { - onPreview(files) + onPreview(fileTree) // Switch to the build log! setSelectedTab(0) - }, [files, onPreview]) + }, [fileTree, onPreview]) // Stop ctrl+s from saving files and make ctrl+enter trigger a preview. useEffect(() => { @@ -114,7 +115,7 @@ export const TemplateVersionEditor: FC = ({ return () => { document.removeEventListener("keydown", keyListener) } - }, [files, triggerPreview]) + }, [triggerPreview]) // Automatically switch to the template preview tab when the build succeeds. const previousVersion = useRef() @@ -137,6 +138,8 @@ export const TemplateVersionEditor: FC = ({ const hasIcon = template.icon && template.icon !== "" const templateVersionSucceeded = templateVersion.job.status === "succeeded" const showBuildLogs = Boolean(buildLogs) + const editorValue = getFileContent(activePath ?? "", fileTree) as string + useEffect(() => { window.dispatchEvent(new Event("resize")) }, [showBuildLogs]) @@ -228,17 +231,10 @@ export const TemplateVersionEditor: FC = ({ onClose={() => { setCreateFileOpen(false) }} - checkExists={(path) => Boolean(files[path])} + checkExists={(path) => existsFile(path, fileTree)} onConfirm={(path) => { - setFiles({ - ...files, - [path]: "", - }) - setActiveFile({ - path, - content: "", - children: {}, - }) + setFileTree((fileTree) => setFile(path, "", fileTree)) + setActivePath(path) setCreateFileOpen(false) setDirty(true) }} @@ -248,64 +244,69 @@ export const TemplateVersionEditor: FC = ({ if (!deleteFileOpen) { throw new Error("delete file must be set") } - const deleted = { ...files } - delete deleted[deleteFileOpen.path] - setFiles(deleted) + setFileTree((fileTree) => removeFile(deleteFileOpen, fileTree)) setDeleteFileOpen(undefined) - if (activeFile?.path === deleteFileOpen.path) { - setActiveFile(undefined) + if (activePath === deleteFileOpen) { + setActivePath(undefined) } setDirty(true) }} open={Boolean(deleteFileOpen)} onClose={() => setDeleteFileOpen(undefined)} - filename={deleteFileOpen?.path || ""} + filename={deleteFileOpen || ""} /> { setRenameFileOpen(undefined) }} - filename={renameFileOpen?.path || ""} - checkExists={(path) => Boolean(files[path])} + filename={renameFileOpen || ""} + checkExists={(path) => existsFile(path, fileTree)} onConfirm={(newPath) => { if (!renameFileOpen) { return } - const renamed = { ...files } - renamed[newPath] = renamed[renameFileOpen.path] - delete renamed[renameFileOpen.path] - setFiles(renamed) - renameFileOpen.path = newPath - setActiveFile(renameFileOpen) + setFileTree((fileTree) => { + fileTree = setFile( + newPath, + getFileContent(renameFileOpen, fileTree) as string, + fileTree, + ) + fileTree = removeFile(renameFileOpen, fileTree) + return fileTree + }) + setActivePath(newPath) setRenameFileOpen(undefined) setDirty(true) }} /> - setDeleteFileOpen(file)} - onSelect={(file) => setActiveFile(file)} + onSelect={(filePath) => { + if (!isFolder(filePath, fileTree)) { + setActivePath(filePath) + } + }} onRename={(file) => setRenameFileOpen(file)} - activeFile={activeFile} + activePath={activePath} />
- {activeFile ? ( + {activePath ? ( { - if (!activeFile) { + if (!activePath) { return } - setFiles({ - ...files, - [activeFile.path]: value, - }) + setFileTree((fileTree) => + setFile(activePath, value, fileTree), + ) setDirty(true) }} /> diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx index c57970398c164..5b45f1767bd65 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx +++ b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx @@ -5,8 +5,8 @@ import { FC } from "react" import { Helmet } from "react-helmet-async" import { useParams } from "react-router-dom" import { pageTitle } from "util/page" -import { templateVersionMachine } from "xServices/templateVersion/templateVersionXService" import { templateVersionEditorMachine } from "xServices/templateVersionEditor/templateVersionEditorXService" +import { useTemplateVersionData } from "./data" type Params = { version: string @@ -16,9 +16,11 @@ type Params = { export const TemplateVersionEditorPage: FC = () => { const { version: versionName, template: templateName } = useParams() as Params const orgId = useOrganizationId() - const [versionState] = useMachine(templateVersionMachine, { - context: { templateName, versionName, orgId }, - }) + const { isSuccess, data } = useTemplateVersionData( + orgId, + templateName, + versionName, + ) const [editorState, sendEvent] = useMachine(templateVersionEditorMachine, { context: { orgId }, }) @@ -29,39 +31,32 @@ export const TemplateVersionEditorPage: FC = () => { {pageTitle(`${templateName} ยท Template Editor`)} - {versionState.context.template && - versionState.context.currentFiles && - versionState.context.currentVersion && ( - { - if (!versionState.context.template) { - throw new Error("no template") - } - sendEvent({ - type: "CREATE_VERSION", - files: files, - templateId: versionState.context.template.id, - }) - }} - onUpdate={() => { - sendEvent({ - type: "UPDATE_ACTIVE_VERSION", - }) - }} - disablePreview={editorState.hasTag("loading")} - disableUpdate={ - editorState.hasTag("loading") || - editorState.context.version?.job.status !== "succeeded" - } - resources={editorState.context.resources} - buildLogs={editorState.context.buildLogs} - /> - )} + {isSuccess && ( + { + sendEvent({ + type: "CREATE_VERSION", + fileTree, + templateId: data.template.id, + }) + }} + onUpdate={() => { + sendEvent({ + type: "UPDATE_ACTIVE_VERSION", + }) + }} + disablePreview={editorState.hasTag("loading")} + disableUpdate={ + editorState.hasTag("loading") || + editorState.context.version?.job.status !== "succeeded" + } + resources={editorState.context.resources} + buildLogs={editorState.context.buildLogs} + /> + )} ) } diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/data.ts b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/data.ts new file mode 100644 index 0000000000000..6270e746d0b2c --- /dev/null +++ b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/data.ts @@ -0,0 +1,32 @@ +import { useQuery } from "@tanstack/react-query" +import { getTemplateByName, getTemplateVersionByName } from "api/api" +import { createTemplateVersionFileTree } from "util/templateVersion" + +const getTemplateVersionData = async ( + orgId: string, + templateName: string, + versionName: string, +) => { + const [template, currentVersion] = await Promise.all([ + getTemplateByName(orgId, templateName), + getTemplateVersionByName(orgId, templateName, versionName), + ]) + const fileTree = await createTemplateVersionFileTree(currentVersion) + + return { + template, + currentVersion, + fileTree, + } +} + +export const useTemplateVersionData = ( + orgId: string, + templateName: string, + versionName: string, +) => { + return useQuery({ + queryKey: ["templateVersion", templateName, versionName], + queryFn: () => getTemplateVersionData(orgId, templateName, versionName), + }) +} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 8f57f49691432..cce61e6c75876 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -6,6 +6,7 @@ import * as TypesGen from "../api/typesGenerated" import range from "lodash/range" import { Permissions } from "xServices/auth/authXService" import { TemplateVersionFiles } from "util/templateVersion" +import { FileTree } from "util/filetree" export const MockTemplateDAUResponse: TypesGen.TemplateDAUsResponse = { entries: [ @@ -303,6 +304,42 @@ spec { `, } +export const MockTemplateVersionFileTree: FileTree = { + "README.md": "# Example\n\nThis is an example template.", + "main.tf": `// Provides info about the workspace. +data "coder_workspace" "me" {} + +// Provides the startup script used to download +// the agent and communicate with Coder. +resource "coder_agent" "dev" { +os = "linux" +arch = "amd64" +} + +resource "kubernetes_pod" "main" { +// Ensures that the Pod dies when the workspace shuts down! +count = data.coder_workspace.me.start_count +metadata { + name = "dev-\${data.coder_workspace.me.id}" +} +spec { + container { + image = "ubuntu" + command = ["sh", "-c", coder_agent.main.init_script] + env { + name = "CODER_AGENT_TOKEN" + value = coder_agent.main.token + } + } +} +} +`, + images: { + "java.Dockerfile": "FROM eclipse-temurin:17-jdk-jammy", + "python.Dockerfile": "FROM python:3.8-slim-buster", + }, +} + export const MockWorkspaceApp: TypesGen.WorkspaceApp = { id: "test-app", slug: "test-app", diff --git a/site/src/util/filetree.test.ts b/site/src/util/filetree.test.ts new file mode 100644 index 0000000000000..2f85af20e34b9 --- /dev/null +++ b/site/src/util/filetree.test.ts @@ -0,0 +1,70 @@ +import { + existsFile, + FileTree, + getFileContent, + isFolder, + removeFile, + setFile, + traverse, +} from "./filetree" + +test("setFile() set file into the file tree", () => { + let fileTree: FileTree = { + "main.tf": "terraform", + images: { "java.Dockerfile": "java dockerfile" }, + } + fileTree = setFile("images/python.Dockerfile", "python dockerfile", fileTree) + expect((fileTree.images as FileTree)["python.Dockerfile"]).toEqual( + "python dockerfile", + ) +}) + +test("getFileContent() return the file content from the file tree", () => { + const fileTree: FileTree = { + "main.tf": "terraform content", + images: { "java.Dockerfile": "java dockerfile" }, + } + expect(getFileContent("images/java.Dockerfile", fileTree)).toEqual( + "java dockerfile", + ) +}) + +test("removeFile() removes a file from the file tree", () => { + let fileTree: FileTree = { + "main.tf": "terraform content", + images: { "java.Dockerfile": "java dockerfile" }, + } + fileTree = removeFile("images", fileTree) + expect(fileTree.images).toBeUndefined() +}) + +test("existsFile() returns if there is or not a file", () => { + const fileTree: FileTree = { + "main.tf": "terraform content", + images: { "java.Dockerfile": "java dockerfile" }, + } + expect(existsFile("images/java.Dockerfile", fileTree)).toBeTruthy() + expect(existsFile("no-existent-path", fileTree)).toBeFalsy() +}) + +test("isFolder() returns when a path is a folder or not", () => { + const fileTree: FileTree = { + "main.tf": "terraform content", + images: { "java.Dockerfile": "java dockerfile" }, + } + expect(isFolder("images", fileTree)).toBeTruthy() + expect(isFolder("images/java.Dockerfile", fileTree)).toBeFalsy() +}) + +test("traverse() go trough all the file tree files", () => { + const fileTree: FileTree = { + "main.tf": "terraform content", + images: { "java.Dockerfile": "java dockerfile" }, + } + const filePaths: string[] = [] + traverse(fileTree, (_content, _filename, fullPath) => { + filePaths.push(fullPath) + }) + const expectedFilePaths = ["main.tf", "images", "images/java.Dockerfile"] + expect(filePaths).toEqual(expectedFilePaths) +}) diff --git a/site/src/util/filetree.ts b/site/src/util/filetree.ts new file mode 100644 index 0000000000000..f14018ef17271 --- /dev/null +++ b/site/src/util/filetree.ts @@ -0,0 +1,52 @@ +import set from "lodash/set" +import has from "lodash/has" +import omit from "lodash/omit" +import get from "lodash/get" + +export type FileTree = { + [key: string]: FileTree | string +} + +export const setFile = ( + path: string, + content: string, + fileTree: FileTree, +): FileTree => { + return set(fileTree, path.split("/"), content) +} + +export const existsFile = (path: string, fileTree: FileTree) => { + return has(fileTree, path.split("/")) +} + +export const removeFile = (path: string, fileTree: FileTree) => { + return omit(fileTree, path.split("/")) +} + +export const getFileContent = (path: string, fileTree: FileTree) => { + return get(fileTree, path.split("/")) as string | FileTree +} + +export const isFolder = (path: string, fileTree: FileTree) => { + const content = getFileContent(path, fileTree) + return typeof content === "object" +} + +export const traverse = ( + fileTree: FileTree, + callback: ( + content: FileTree | string, + filename: string, + fullPath: string, + ) => void, + parent?: string, +) => { + Object.keys(fileTree).forEach((filename) => { + const fullPath = parent ? `${parent}/${filename}` : filename + const content = fileTree[filename] + callback(content, filename, fullPath) + if (typeof content === "object") { + traverse(content, callback, fullPath) + } + }) +} diff --git a/site/src/util/templateVersion.ts b/site/src/util/templateVersion.ts index 13fc45c8a54a9..fbce12a9c7f6a 100644 --- a/site/src/util/templateVersion.ts +++ b/site/src/util/templateVersion.ts @@ -1,6 +1,7 @@ -import { getFile } from "api/api" +import * as API from "api/api" import { TemplateVersion } from "api/typesGenerated" import untar from "js-untar" +import { FileTree, setFile } from "./filetree" /** * Content by filename @@ -13,7 +14,7 @@ export const getTemplateVersionFiles = async ( allowedFiles: string[], ): Promise => { const files: TemplateVersionFiles = {} - const tarFile = await getFile(version.job.file_id) + const tarFile = await API.getFile(version.job.file_id) const blobs: Record = {} await untar(tarFile).then(undefined, undefined, async (file) => { @@ -37,3 +38,30 @@ export const getTemplateVersionFiles = async ( return files } + +const allowedExtensions = ["tf", "md", "Dockerfile"] + +export const createTemplateVersionFileTree = async ( + version: TemplateVersion, +): Promise => { + let fileTree: FileTree = {} + const tarFile = await API.getFile(version.job.file_id) + const blobs: Record = {} + + await untar(tarFile).then(undefined, undefined, async (file) => { + if (allowedExtensions.some((ext) => file.name.endsWith(ext))) { + blobs[file.name] = file.blob + } + }) + + // We don't want to get the blob text during untar to not block the main thread. + // Also, by doing it here, we can make all the loading in parallel. + await Promise.all( + Object.entries(blobs).map(async ([fullPath, blob]) => { + const content = await blob.text() + fileTree = setFile(fullPath, content, fileTree) + }), + ) + + return fileTree +} diff --git a/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts b/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts index 63078591cd064..15bc033f0b594 100644 --- a/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts +++ b/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts @@ -7,8 +7,8 @@ import { } from "api/typesGenerated" import { assign, createMachine } from "xstate" import * as API from "api/api" -import { TemplateVersionFiles } from "util/templateVersion" import Tar from "tar-js" +import { FileTree, traverse } from "util/filetree" export interface CreateVersionData { file: File @@ -16,9 +16,8 @@ export interface CreateVersionData { export interface TemplateVersionEditorMachineContext { orgId: string - templateId?: string - files?: TemplateVersionFiles + fileTree?: FileTree uploadResponse?: UploadResponse version?: TemplateVersion resources?: WorkspaceResource[] @@ -34,7 +33,7 @@ export const templateVersionEditorMachine = createMachine( events: {} as | { type: "CREATE_VERSION" - files: TemplateVersionFiles + fileTree: FileTree templateId: string } | { type: "CANCEL_VERSION" } @@ -131,8 +130,7 @@ export const templateVersionEditorMachine = createMachine( actions: "addBuildLog", }, CANCEL_VERSION: { - actions: "cancelBuild", - target: "idle", + target: "cancelingBuild", }, CREATE_VERSION: { actions: ["assignCreateBuild"], @@ -167,7 +165,7 @@ export const templateVersionEditorMachine = createMachine( { actions: { assignCreateBuild: assign({ - files: (_, event) => event.files, + fileTree: (_, event) => event.fileTree, templateId: (_, event) => event.templateId, buildLogs: (_, _1) => [], resources: (_, _1) => [], @@ -206,13 +204,15 @@ export const templateVersionEditorMachine = createMachine( }, services: { uploadTar: (ctx) => { - if (!ctx.files) { + if (!ctx.fileTree) { throw new Error("files must be set") } const tar = new Tar() let out: Uint8Array = new Uint8Array() - Object.entries(ctx.files).forEach(([path, content]) => { - out = tar.append(path, content) + traverse(ctx.fileTree, (content, _filename, fullPath) => { + if (typeof content === "string") { + out = tar.append(fullPath, content) + } }) return API.uploadTemplateFile(new File([out], "template.tar")) },