diff --git a/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx b/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx index 7a151c2809f57..3c6bdf26a1d19 100644 --- a/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx +++ b/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx @@ -87,7 +87,11 @@ const useStyles = makeStyles((theme) => ({ color: theme.palette.text.primary, }, - "& p": { + "& p:not(.MuiFormHelperText-root)": { + margin: 0, + }, + + "& > p": { margin: theme.spacing(1, 0), }, }, diff --git a/site/src/components/TemplateVersionEditor/FileDialog.tsx b/site/src/components/TemplateVersionEditor/FileDialog.tsx index 3d7fe0f18cb44..12e4664424057 100644 --- a/site/src/components/TemplateVersionEditor/FileDialog.tsx +++ b/site/src/components/TemplateVersionEditor/FileDialog.tsx @@ -3,15 +3,18 @@ import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" import { Stack } from "components/Stack/Stack" import { ChangeEvent, FC, useState } from "react" import Typography from "@material-ui/core/Typography" +import { allowedExtensions, isAllowedFile } from "util/templateVersion" +import { FileTree, validatePath } from "util/filetree" export const CreateFileDialog: FC<{ onClose: () => void checkExists: (path: string) => boolean onConfirm: (path: string) => void open: boolean -}> = ({ checkExists, onClose, onConfirm, open }) => { + fileTree: FileTree +}> = ({ checkExists, onClose, onConfirm, open, fileTree }) => { const [pathValue, setPathValue] = useState("") - const [error, setError] = useState("") + const [error, setError] = useState() const handleChange = (event: ChangeEvent) => { setPathValue(event.target.value) } @@ -24,7 +27,20 @@ export const CreateFileDialog: FC<{ setError("File already exists") return } + if (!isAllowedFile(pathValue)) { + const extensions = allowedExtensions.join(", ") + setError( + `This extension is not allowed. You only can create files with the following extensions: ${extensions}.`, + ) + return + } + const pathError = validatePath(pathValue, fileTree) + if (pathError) { + setError(pathError) + return + } onConfirm(pathValue) + setError(undefined) setPathValue("") } @@ -33,6 +49,7 @@ export const CreateFileDialog: FC<{ open={open} onClose={() => { onClose() + setError(undefined) setPathValue("") }} onConfirm={handleConfirm} @@ -42,10 +59,10 @@ export const CreateFileDialog: FC<{ confirmText="Create" title="Create File" description={ - + Specify the path to a file to be created. This path can contain - slashes too! + slashes too. } @@ -82,7 +103,12 @@ export const DeleteFileDialog: FC<{ open={open} onConfirm={onConfirm} title="Delete File" - description={`Are you sure you want to delete "${filename}"?`} + description={ + <> + Are you sure you want to delete {filename}? It will + be deleted permanently. + + } /> ) } @@ -93,9 +119,10 @@ export const RenameFileDialog: FC<{ checkExists: (path: string) => boolean open: boolean filename: string -}> = ({ checkExists, onClose, onConfirm, open, filename }) => { + fileTree: FileTree +}> = ({ checkExists, onClose, onConfirm, open, filename, fileTree }) => { const [pathValue, setPathValue] = useState(filename) - const [error, setError] = useState("") + const [error, setError] = useState() const handleChange = (event: ChangeEvent) => { setPathValue(event.target.value) } @@ -108,7 +135,20 @@ export const RenameFileDialog: FC<{ setError("File already exists") return } + if (!isAllowedFile(pathValue)) { + const extensions = allowedExtensions.join(", ") + setError( + `This extension is not allowed. You only can rename files with the following extensions: ${extensions}.`, + ) + return + } + const pathError = validatePath(pathValue, fileTree) + if (pathError) { + setError(pathError) + return + } onConfirm(pathValue) + setError(undefined) setPathValue("") } @@ -117,20 +157,21 @@ export const RenameFileDialog: FC<{ open={open} onClose={() => { onClose() + setError(undefined) setPathValue("") }} onConfirm={handleConfirm} hideCancel={false} type="success" cancelText="Cancel" - confirmText="Create" + confirmText="Rename" title="Rename File" description={ - - - Rename {`"${filename}"`} to something else. This path can contain - slashes too! - + +

+ Rename {filename} to something else. This path can + contain slashes too! +

{ @@ -138,12 +179,12 @@ export const RenameFileDialog: FC<{ handleConfirm() } }} + error={Boolean(error)} helperText={error} name="file-path" autoComplete="off" id="file-path" - placeholder="main.tf" - defaultValue={filename} + placeholder={filename} value={pathValue} onChange={handleChange} label="File Path" diff --git a/site/src/components/TemplateVersionEditor/FileTreeView.tsx b/site/src/components/TemplateVersionEditor/FileTreeView.tsx index b0b7f41fb57b1..8118301c596b2 100644 --- a/site/src/components/TemplateVersionEditor/FileTreeView.tsx +++ b/site/src/components/TemplateVersionEditor/FileTreeView.tsx @@ -67,7 +67,8 @@ export const FileTreeView: FC<{ onSelect(currentPath) }} onContextMenu={(event) => { - event.preventDefault() + event.preventDefault() // Avoid default browser behavior + event.stopPropagation() // Avoid trigger parent context menu setContextMenu( contextMenu ? undefined @@ -137,7 +138,7 @@ export const FileTreeView: FC<{ setContextMenu(undefined) }} > - Rename... + Rename { @@ -148,7 +149,7 @@ export const FileTreeView: FC<{ setContextMenu(undefined) }} > - Delete Permanently + Delete diff --git a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx index 0ef80162ae375..6cb8482a46606 100644 --- a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx +++ b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx @@ -18,13 +18,15 @@ import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuild import { FC, useCallback, useEffect, useRef, useState } from "react" import { navHeight, dashboardContentBottomPadding } from "theme/constants" import { + createFile, existsFile, FileTree, getFileContent, isFolder, + moveFile, removeFile, - setFile, traverse, + updateFile, } from "util/filetree" import { CreateFileDialog, @@ -216,13 +218,14 @@ export const TemplateVersionEditor: FC = ({ { setCreateFileOpen(false) }} checkExists={(path) => existsFile(path, fileTree)} onConfirm={(path) => { - setFileTree((fileTree) => setFile(path, "", fileTree)) + setFileTree((fileTree) => createFile(path, fileTree, "")) setActivePath(path) setCreateFileOpen(false) setDirty(true) @@ -245,6 +248,7 @@ export const TemplateVersionEditor: FC = ({ filename={deleteFileOpen || ""} /> { setRenameFileOpen(undefined) @@ -255,15 +259,9 @@ export const TemplateVersionEditor: FC = ({ if (!renameFileOpen) { return } - setFileTree((fileTree) => { - fileTree = setFile( - newPath, - getFileContent(renameFileOpen, fileTree) as string, - fileTree, - ) - fileTree = removeFile(renameFileOpen, fileTree) - return fileTree - }) + setFileTree((fileTree) => + moveFile(renameFileOpen, newPath, fileTree), + ) setActivePath(newPath) setRenameFileOpen(undefined) setDirty(true) @@ -294,7 +292,7 @@ export const TemplateVersionEditor: FC = ({ return } setFileTree((fileTree) => - setFile(activePath, value, fileTree), + updateFile(activePath, value, fileTree), ) setDirty(true) }} diff --git a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx index 36f5dc155ddf2..601d8560d97e8 100644 --- a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx +++ b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx @@ -46,7 +46,7 @@ export const WorkspaceSchedule: FC< return (
- +
{Language.timezoneLabel} {timezone} diff --git a/site/src/pages/WorkspaceChangeVersionPage/WorkspaceChangeVersionForm.tsx b/site/src/pages/WorkspaceChangeVersionPage/WorkspaceChangeVersionForm.tsx index bcca66b5a9364..e294abe20539f 100644 --- a/site/src/pages/WorkspaceChangeVersionPage/WorkspaceChangeVersionForm.tsx +++ b/site/src/pages/WorkspaceChangeVersionPage/WorkspaceChangeVersionForm.tsx @@ -39,12 +39,7 @@ export const WorkspaceChangeVersionForm: FC<{ return (
- +
diff --git a/site/src/util/filetree.test.ts b/site/src/util/filetree.test.ts index 2f85af20e34b9..18b63788de1a6 100644 --- a/site/src/util/filetree.test.ts +++ b/site/src/util/filetree.test.ts @@ -3,17 +3,22 @@ import { FileTree, getFileContent, isFolder, + moveFile, removeFile, - setFile, + createFile, traverse, } from "./filetree" -test("setFile() set file into the file tree", () => { +test("createFile() 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) + fileTree = createFile( + "images/python.Dockerfile", + fileTree, + "python dockerfile", + ) expect((fileTree.images as FileTree)["python.Dockerfile"]).toEqual( "python dockerfile", ) @@ -29,13 +34,65 @@ test("getFileContent() return the file content from the file tree", () => { ) }) -test("removeFile() removes a file from the file tree", () => { +test("removeFile() removes a file from a folder", () => { let fileTree: FileTree = { "main.tf": "terraform content", - images: { "java.Dockerfile": "java dockerfile" }, + images: { + "java.Dockerfile": "java dockerfile", + "python.Dockerfile": "python Dockerfile", + }, + } + fileTree = removeFile("images/python.Dockerfile", fileTree) + const expectedFileTree = { + "main.tf": "terraform content", + images: { + "java.Dockerfile": "java dockerfile", + }, + } + expect(expectedFileTree).toEqual(fileTree) +}) + +test("removeFile() removes a file from root", () => { + let fileTree: FileTree = { + "main.tf": "terraform content", + images: { + "java.Dockerfile": "java dockerfile", + "python.Dockerfile": "python Dockerfile", + }, + } + fileTree = removeFile("main.tf", fileTree) + const expectedFileTree = { + images: { + "java.Dockerfile": "java dockerfile", + "python.Dockerfile": "python Dockerfile", + }, + } + expect(expectedFileTree).toEqual(fileTree) +}) + +test("moveFile() moves a file from in file tree", () => { + let fileTree: FileTree = { + "main.tf": "terraform content", + images: { + "java.Dockerfile": "java dockerfile", + "python.Dockerfile": "python Dockerfile", + }, + } + fileTree = moveFile( + "images/java.Dockerfile", + "other/java.Dockerfile", + fileTree, + ) + const expectedFileTree = { + "main.tf": "terraform content", + images: { + "python.Dockerfile": "python Dockerfile", + }, + other: { + "java.Dockerfile": "java dockerfile", + }, } - fileTree = removeFile("images", fileTree) - expect(fileTree.images).toBeUndefined() + expect(fileTree).toEqual(expectedFileTree) }) test("existsFile() returns if there is or not a file", () => { diff --git a/site/src/util/filetree.ts b/site/src/util/filetree.ts index f14018ef17271..9b5ea60b0aa7c 100644 --- a/site/src/util/filetree.ts +++ b/site/src/util/filetree.ts @@ -1,15 +1,46 @@ import set from "lodash/set" import has from "lodash/has" -import omit from "lodash/omit" +import unset from "lodash/unset" import get from "lodash/get" export type FileTree = { [key: string]: FileTree | string } -export const setFile = ( +export const createFile = ( path: string, - content: string, + fileTree: FileTree, + value: string, +): FileTree => { + if (existsFile(path, fileTree)) { + throw new Error(`File ${path} already exists`) + } + const pathError = validatePath(path, fileTree) + if (pathError) { + throw new Error(pathError) + } + + return set(fileTree, path.split("/"), value) +} + +export const validatePath = ( + path: string, + fileTree: FileTree, +): string | undefined => { + const paths = path.split("/") + paths.pop() // The last item is the filename + for (let i = 0; i <= paths.length; i++) { + const path = paths.slice(0, i + 1) + const pathStr = path.join("/") + if (existsFile(pathStr, fileTree) && !isFolder(pathStr, fileTree)) { + return `Invalid path. The path ${path} is not a folder` + } + } +} + +export const updateFile = ( + path: string, + content: FileTree | string, fileTree: FileTree, ): FileTree => { return set(fileTree, path.split("/"), content) @@ -20,7 +51,23 @@ export const existsFile = (path: string, fileTree: FileTree) => { } export const removeFile = (path: string, fileTree: FileTree) => { - return omit(fileTree, path.split("/")) + const updatedFileTree = { ...fileTree } + unset(updatedFileTree, path.split("/")) + return updatedFileTree +} + +export const moveFile = ( + currentPath: string, + newPath: string, + fileTree: FileTree, +) => { + const content = getFileContent(currentPath, fileTree) + if (typeof content !== "string") { + throw new Error("Move folders is not allowed") + } + fileTree = removeFile(currentPath, fileTree) + fileTree = createFile(newPath, fileTree, content) + return fileTree } export const getFileContent = (path: string, fileTree: FileTree) => { diff --git a/site/src/util/templateVersion.ts b/site/src/util/templateVersion.ts index 9122036149440..67eb18805a7c0 100644 --- a/site/src/util/templateVersion.ts +++ b/site/src/util/templateVersion.ts @@ -1,6 +1,6 @@ import * as API from "api/api" import { TemplateVersion } from "api/typesGenerated" -import { FileTree, setFile } from "./filetree" +import { FileTree, createFile } from "./filetree" import { TarReader } from "./tar" /** @@ -23,7 +23,7 @@ export const getTemplateVersionFiles = async ( return files } -const allowedExtensions = ["tf", "md", "Dockerfile"] +export const allowedExtensions = ["tf", "md", "Dockerfile"] export const isAllowedFile = (name: string) => { return allowedExtensions.some((ext) => name.endsWith(ext)) @@ -35,10 +35,10 @@ export const createTemplateVersionFileTree = async ( let fileTree: FileTree = {} for (const file of tarReader.fileInfo) { if (isAllowedFile(file.name)) { - fileTree = setFile( + fileTree = createFile( file.name, - tarReader.getTextFile(file.name) as string, fileTree, + tarReader.getTextFile(file.name) as string, ) } } diff --git a/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts b/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts index 88ded1c42c01b..75b28b2888bd2 100644 --- a/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts +++ b/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts @@ -252,11 +252,17 @@ export const templateVersionEditorMachine = createMachine( } // Add the editable files traverse(fileTree, (content, _filename, fullPath) => { + // When a file is deleted. Don't add it to the tar. + if (content === undefined) { + return + } + if (typeof content === "string") { tar.addFile(fullPath, content) - } else { - tar.addFolder(fullPath) + return } + + tar.addFolder(fullPath) }) const blob = await tar.write() return API.uploadTemplateFile(new File([blob], "template.tar"))