From 4635cbdd20ea6a1c0b5715683a487a421e67c6b9 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 2 Feb 2024 16:42:07 +0000 Subject: [PATCH 01/18] Display all files in the editor --- .../templates/TemplateFiles/TemplateFiles.tsx | 14 ++--- .../TemplateVersionEditorPage/FileDialog.tsx | 17 +----- .../MonacoEditor.tsx | 21 +------- .../TemplateVersionEditor.tsx | 54 ++++++++++++++----- .../TemplateVersionEditorPage.tsx | 38 ++++++------- site/src/utils/templateVersion.ts | 39 ++++---------- 6 files changed, 77 insertions(+), 106 deletions(-) diff --git a/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx b/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx index ba37caff6ec77..54305a8d1b4ba 100644 --- a/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx +++ b/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx @@ -5,10 +5,10 @@ import { MarkdownIcon } from "components/Icons/MarkdownIcon"; import { TerraformIcon } from "components/Icons/TerraformIcon"; import { SyntaxHighlighter } from "components/SyntaxHighlighter/SyntaxHighlighter"; import { UseTabResult, useTab } from "hooks/useTab"; -import { AllowedExtension, TemplateVersionFiles } from "utils/templateVersion"; +import { TemplateVersionFiles } from "utils/templateVersion"; import InsertDriveFileOutlined from "@mui/icons-material/InsertDriveFileOutlined"; -const iconByExtension: Record = { +const iconByExtension: Record = { tf: , md: , mkd: , @@ -27,7 +27,7 @@ const getExtension = (filename: string) => { return filename; }; -const languageByExtension: Record = { +const languageByExtension: Record = { tf: "hcl", md: "markdown", mkd: "markdown", @@ -61,7 +61,7 @@ export const TemplateFiles: FC = ({
{filenames.map((filename, index) => { const tabValue = index.toString(); - const extension = getExtension(filename) as AllowedExtension; + const extension = getExtension(filename); const icon = iconByExtension[extension]; const hasDiff = baseFiles && @@ -87,11 +87,7 @@ export const TemplateFiles: FC = ({
); diff --git a/site/src/pages/TemplateVersionEditorPage/FileDialog.tsx b/site/src/pages/TemplateVersionEditorPage/FileDialog.tsx index 64936397e6f30..e9b7e1a245176 100644 --- a/site/src/pages/TemplateVersionEditorPage/FileDialog.tsx +++ b/site/src/pages/TemplateVersionEditorPage/FileDialog.tsx @@ -2,7 +2,6 @@ import TextField from "@mui/material/TextField"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; import { Stack } from "components/Stack/Stack"; import { type ChangeEvent, type FC, useState } from "react"; -import { allowedExtensions, isAllowedFile } from "utils/templateVersion"; import { type FileTree, isFolder, validatePath } from "utils/filetree"; interface CreateFileDialogProps { @@ -34,13 +33,7 @@ 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); @@ -155,13 +148,7 @@ 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; - } + //Check if a folder is renamed to a file const [_, extension] = pathValue.split("."); if (isFolder(filename, fileTree) && extension) { diff --git a/site/src/pages/TemplateVersionEditorPage/MonacoEditor.tsx b/site/src/pages/TemplateVersionEditorPage/MonacoEditor.tsx index 7667afc4431b7..63c5bc4cc87a9 100644 --- a/site/src/pages/TemplateVersionEditorPage/MonacoEditor.tsx +++ b/site/src/pages/TemplateVersionEditorPage/MonacoEditor.tsx @@ -1,7 +1,7 @@ import { useTheme } from "@emotion/react"; import Editor, { loader } from "@monaco-editor/react"; import * as monaco from "monaco-editor"; -import { type FC, useEffect, useMemo } from "react"; +import { type FC, useEffect } from "react"; import { MONOSPACE_FONT_FAMILY } from "theme/constants"; loader.config({ monaco }); @@ -19,24 +19,6 @@ export const MonacoEditor: FC = ({ }) => { const theme = useTheme(); - const language = useMemo(() => { - if (path?.endsWith(".tf")) { - return "hcl"; - } - if (path?.endsWith(".md")) { - return "markdown"; - } - if (path?.endsWith(".json")) { - return "json"; - } - if (path?.endsWith(".yaml")) { - return "yaml"; - } - if (path?.endsWith("Dockerfile")) { - return "dockerfile"; - } - }, [path]); - useEffect(() => { document.fonts.ready .then(() => { @@ -54,7 +36,6 @@ export const MonacoEditor: FC = ({ return ( = ({ }, [templateVersion]); const editorValue = getFileContent(activePath ?? "", fileTree) as string; + const isEditorValueBinary = isBinaryData(editorValue); // Auto scroll const buildLogsRef = useRef(null); @@ -448,19 +449,23 @@ export const TemplateVersionEditor: FC = ({ >
{activePath ? ( - { - if (!activePath) { - return; - } - setFileTree((fileTree) => - updateFile(activePath, value, fileTree), - ); - setDirty(true); - }} - /> + isEditorValueBinary ? ( +

File type not supported

+ ) : ( + { + if (!activePath) { + return; + } + setFileTree((fileTree) => + updateFile(activePath, value, fileTree), + ); + setDirty(true); + }} + /> + ) ) : (
No file opened
)} @@ -616,6 +621,29 @@ export const TemplateVersionEditor: FC = ({ ); }; +function isBinaryData(s: string): boolean { + // Remove unicode characters from the string like emojis. + const asciiString = s.replace(/[\u007F-\uFFFF]/g, ""); + + // Create a set of all printable ASCII characters (and some control characters). + const textChars = new Set( + [7, 8, 9, 10, 12, 13, 27].concat( + Array.from({ length: 128 }, (_, i) => i + 32), + ), + ); + + const isBinaryString = (str: string): boolean => { + for (let i = 0; i < str.length; i++) { + if (!textChars.has(str.charCodeAt(i))) { + return true; + } + } + return false; + }; + + return isBinaryString(asciiString); +} + const styles = { tab: (theme) => ({ "&:not(:disabled)": { diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx index 143002096ddc7..64a648d494f09 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx @@ -25,10 +25,7 @@ import { import { file, uploadFile } from "api/queries/files"; import { TarFileTypeCodes, TarReader, TarWriter } from "utils/tar"; import { FileTree, traverse } from "utils/filetree"; -import { - createTemplateVersionFileTree, - isAllowedFile, -} from "utils/templateVersion"; +import { createTemplateVersionFileTree } from "utils/templateVersion"; import { displayError } from "components/GlobalSnackbar/utils"; import { FullScreenLoader } from "components/Loader/FullScreenLoader"; @@ -253,7 +250,8 @@ const useFileTree = (templateVersion: TemplateVersion | undefined) => { }; if (fileQuery.data) { - initializeFileTree(fileQuery.data).catch(() => { + initializeFileTree(fileQuery.data).catch((reason) => { + console.error(reason); displayError("Error on initializing the editor"); }); } @@ -332,22 +330,20 @@ const generateVersionFiles = async ( // Add previous non editable files for (const file of tarReader.fileInfo) { - if (!isAllowedFile(file.name)) { - if (file.type === TarFileTypeCodes.Dir) { - tar.addFolder(file.name, { - mode: file.mode, // https://github.com/beatgammit/tar-js/blob/master/lib/tar.js#L42 - mtime: file.mtime, - user: file.user, - group: file.group, - }); - } else { - tar.addFile(file.name, tarReader.getTextFile(file.name) as string, { - mode: file.mode, // https://github.com/beatgammit/tar-js/blob/master/lib/tar.js#L42 - mtime: file.mtime, - user: file.user, - group: file.group, - }); - } + if (file.type === TarFileTypeCodes.Dir) { + tar.addFolder(file.name, { + mode: file.mode, // https://github.com/beatgammit/tar-js/blob/master/lib/tar.js#L42 + mtime: file.mtime, + user: file.user, + group: file.group, + }); + } else { + tar.addFile(file.name, tarReader.getTextFile(file.name) as string, { + mode: file.mode, // https://github.com/beatgammit/tar-js/blob/master/lib/tar.js#L42 + mtime: file.mtime, + user: file.user, + group: file.group, + }); } } // Add the editable files diff --git a/site/src/utils/templateVersion.ts b/site/src/utils/templateVersion.ts index 153f46b432d53..d9bb4a0cf5faf 100644 --- a/site/src/utils/templateVersion.ts +++ b/site/src/utils/templateVersion.ts @@ -1,5 +1,6 @@ -import { FileTree, createFile } from "./filetree"; -import { TarReader } from "./tar"; +import set from "lodash/set"; +import { FileTree } from "./filetree"; +import { TarFileTypeCodes, TarReader } from "./tar"; // Content by filename export type TemplateVersionFiles = Record; @@ -11,41 +12,23 @@ export const getTemplateVersionFiles = async ( const tarReader = new TarReader(); await tarReader.readFile(tarFile); for (const file of tarReader.fileInfo) { - if (isAllowedFile(file.name)) { - files[file.name] = tarReader.getTextFile(file.name) as string; - } + files[file.name] = tarReader.getTextFile(file.name) as string; } return files; }; -export const allowedExtensions = [ - "tf", - "md", - "mkd", - "Dockerfile", - "protobuf", - "sh", - "tpl", -] as const; - -export type AllowedExtension = (typeof allowedExtensions)[number]; - -export const isAllowedFile = (name: string) => { - return allowedExtensions.some((ext) => name.endsWith(ext)); -}; - export const createTemplateVersionFileTree = async ( tarReader: TarReader, ): Promise => { let fileTree: FileTree = {}; for (const file of tarReader.fileInfo) { - if (isAllowedFile(file.name)) { - fileTree = createFile( - file.name, - fileTree, - tarReader.getTextFile(file.name) as string, - ); - } + fileTree = set( + fileTree, + file.name.split("/"), + file.type === TarFileTypeCodes.Dir + ? {} + : (tarReader.getTextFile(file.name) as string), + ); } return fileTree; }; From 541c95398a3d98072b4b53876e4347c22cf6de90 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 2 Feb 2024 16:54:11 +0000 Subject: [PATCH 02/18] Adjust group padding --- site/src/pages/TemplateVersionEditorPage/FileTreeView.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/src/pages/TemplateVersionEditorPage/FileTreeView.tsx b/site/src/pages/TemplateVersionEditorPage/FileTreeView.tsx index ed07680ec1d00..4c11203eaa57c 100644 --- a/site/src/pages/TemplateVersionEditorPage/FileTreeView.tsx +++ b/site/src/pages/TemplateVersionEditorPage/FileTreeView.tsx @@ -89,6 +89,7 @@ export const FileTreeView: FC = ({ margin-left: 4px; font-size: 13px; color: inherit; + white-space: nowrap; } &.Mui-selected { @@ -103,10 +104,11 @@ export const FileTreeView: FC = ({ & .MuiTreeItem-group { margin-left: 0; + position: relative; // We need to find a better way to recursive padding here & .MuiTreeItem-content { - padding-left: calc(var(--level) * 40px); + padding-left: calc(8px + (var(--level) + 1) * 8px); } } `} From 1785b905a7e93b2db078816119417c5cd052d330 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 2 Feb 2024 17:05:44 +0000 Subject: [PATCH 03/18] Show error for unsupported files --- .../TemplateVersionEditor.tsx | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx index f23010a0af3bf..86bc06bcbe9a2 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx @@ -55,6 +55,7 @@ import { } from "components/FullPageLayout/Topbar"; import { Sidebar } from "components/FullPageLayout/Sidebar"; import { ProvisionerTagsPopover } from "./ProvisionerTagsPopover"; +import WarningOutlined from "@mui/icons-material/WarningOutlined"; type Tab = "logs" | "resources" | undefined; // Undefined is to hide the tab @@ -450,7 +451,44 @@ export const TemplateVersionEditor: FC = ({
{activePath ? ( isEditorValueBinary ? ( -

File type not supported

+
+
+ +

+ The file is not displayed in the text editor because it + is either binary or uses an unsupported text encoding. +

+
+
) : ( Date: Fri, 2 Feb 2024 17:27:43 +0000 Subject: [PATCH 04/18] Save version on URL even when building --- .../TemplateVersionEditorPage.tsx | 40 +++++++------------ 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx index 64a648d494f09..5e10d77bbf585 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx @@ -23,7 +23,7 @@ import { templateVersionVariables, } from "api/queries/templates"; import { file, uploadFile } from "api/queries/files"; -import { TarFileTypeCodes, TarReader, TarWriter } from "utils/tar"; +import { TarReader, TarWriter } from "utils/tar"; import { FileTree, traverse } from "utils/filetree"; import { createTemplateVersionFileTree } from "utils/templateVersion"; import { displayError } from "components/GlobalSnackbar/utils"; @@ -78,6 +78,13 @@ export const TemplateVersionEditorPage: FC = () => { const [lastSuccessfulPublishedVersion, setLastSuccessfulPublishedVersion] = useState(); + const navigateToVersion = (version: TemplateVersion) => { + return navigate( + `/templates/${templateName}/versions/${version.name}/edit`, + { replace: true }, + ); + }; + // Optimistically update the template version data job status to make the // build action feels faster const onBuildStart = () => { @@ -97,6 +104,7 @@ export const TemplateVersionEditorPage: FC = () => { const onBuildEnds = (newVersion: TemplateVersion) => { setCurrentVersionName(newVersion.name); queryClient.setQueryData(templateVersionOptions.queryKey, newVersion); + navigateToVersion(newVersion); }; // Provisioner Tags @@ -163,10 +171,7 @@ export const TemplateVersionEditorPage: FC = () => { templateVersionOptions.queryKey, publishedVersion, ); - navigate( - `/templates/${templateName}/versions/${publishedVersion.name}/edit`, - { replace: true }, - ); + navigateToVersion(publishedVersion); }} isAskingPublishParameters={isPublishingDialogOpen} isPublishing={publishVersionMutation.isLoading} @@ -328,37 +333,20 @@ const generateVersionFiles = async ( ) => { const tar = new TarWriter(); - // Add previous non editable files - for (const file of tarReader.fileInfo) { - if (file.type === TarFileTypeCodes.Dir) { - tar.addFolder(file.name, { - mode: file.mode, // https://github.com/beatgammit/tar-js/blob/master/lib/tar.js#L42 - mtime: file.mtime, - user: file.user, - group: file.group, - }); - } else { - tar.addFile(file.name, tarReader.getTextFile(file.name) as string, { - mode: file.mode, // https://github.com/beatgammit/tar-js/blob/master/lib/tar.js#L42 - mtime: file.mtime, - user: file.user, - group: file.group, - }); - } - } - // 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; } + const baseFileInfo = tarReader.fileInfo.find((i) => i.name === fullPath); + if (typeof content === "string") { - tar.addFile(fullPath, content); + tar.addFile(fullPath, content, baseFileInfo); return; } - tar.addFolder(fullPath); + tar.addFolder(fullPath, baseFileInfo); }); const blob = (await tar.write()) as Blob; return new File([blob], "template.tar"); From ce75edd5c76411b16e811e724e39bd3088d76db6 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 2 Feb 2024 17:31:42 +0000 Subject: [PATCH 05/18] Remove unecessary state --- .../TemplateVersionEditorPage.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx index 5e10d77bbf585..4f597748159e1 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx @@ -37,16 +37,14 @@ type Params = { export const TemplateVersionEditorPage: FC = () => { const queryClient = useQueryClient(); const navigate = useNavigate(); - const { version: initialVersionName, template: templateName } = + const { version: versionName, template: templateName } = useParams() as Params; const orgId = useOrganizationId(); - const [currentVersionName, setCurrentVersionName] = - useState(initialVersionName); const templateQuery = useQuery(templateByName(orgId, templateName)); const templateVersionOptions = templateVersionByName( orgId, templateName, - currentVersionName, + versionName, ); const templateVersionQuery = useQuery({ ...templateVersionOptions, @@ -102,7 +100,6 @@ export const TemplateVersionEditorPage: FC = () => { }; const onBuildEnds = (newVersion: TemplateVersion) => { - setCurrentVersionName(newVersion.name); queryClient.setQueryData(templateVersionOptions.queryKey, newVersion); navigateToVersion(newVersion); }; @@ -164,7 +161,6 @@ export const TemplateVersionEditorPage: FC = () => { ...templateVersionQuery.data, ...data, }; - setCurrentVersionName(publishedVersion.name); setIsPublishingDialogOpen(false); setLastSuccessfulPublishedVersion(publishedVersion); queryClient.setQueryData( @@ -195,7 +191,6 @@ export const TemplateVersionEditorPage: FC = () => { } disableUpdate={ templateVersionQuery.data.job.status !== "succeeded" || - templateVersionQuery.data.name === initialVersionName || templateVersionQuery.data.name === lastSuccessfulPublishedVersion?.name } From 4403a183cce24e7e4c1abbe565a688848a9de00d Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 2 Feb 2024 17:58:49 +0000 Subject: [PATCH 06/18] Keep user navigation from URL --- .../FileTreeView.tsx | 10 +++++++ .../TemplateVersionEditor.tsx | 28 +++++------------ .../TemplateVersionEditorPage.tsx | 30 ++++++++++++++++++- 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/site/src/pages/TemplateVersionEditorPage/FileTreeView.tsx b/site/src/pages/TemplateVersionEditorPage/FileTreeView.tsx index 4c11203eaa57c..d0c5096ed151e 100644 --- a/site/src/pages/TemplateVersionEditorPage/FileTreeView.tsx +++ b/site/src/pages/TemplateVersionEditorPage/FileTreeView.tsx @@ -151,6 +151,7 @@ export const FileTreeView: FC = ({ defaultCollapseIcon={} defaultExpandIcon={} aria-label="Files" + defaultExpanded={activePath ? expandablePaths(activePath) : []} defaultSelected={activePath} > {Object.keys(fileTree) @@ -234,3 +235,12 @@ const FileTypeMarkdown: FC = () => ( ); + +const expandablePaths = (path: string) => { + const paths = path.split("/"); + const result = []; + for (let i = 1; i < paths.length; i++) { + result.push(paths.slice(0, i).join("/")); + } + return result; +}; diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx index 86bc06bcbe9a2..1d6fc02a20156 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx @@ -30,7 +30,6 @@ import { isFolder, moveFile, removeFile, - traverse, updateFile, } from "utils/filetree"; import { @@ -83,20 +82,10 @@ export interface TemplateVersionEditorProps { defaultTab?: Tab; provisionerTags: Record; onUpdateProvisionerTags: (tags: Record) => void; + activePath: string | undefined; + onActivePathChange: (path: string | undefined) => void; } -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, @@ -121,6 +110,8 @@ export const TemplateVersionEditor: FC = ({ defaultTab, provisionerTags, onUpdateProvisionerTags, + activePath, + onActivePathChange, }) => { const theme = useTheme(); const [selectedTab, setSelectedTab] = useState(defaultTab); @@ -129,9 +120,6 @@ export const TemplateVersionEditor: FC = ({ const [deleteFileOpen, setDeleteFileOpen] = useState(); const [renameFileOpen, setRenameFileOpen] = useState(); const [dirty, setDirty] = useState(false); - const [activePath, setActivePath] = useState(() => - findInitialFile(fileTree), - ); const triggerPreview = useCallback(() => { onPreview(fileTree); @@ -382,7 +370,7 @@ export const TemplateVersionEditor: FC = ({ checkExists={(path) => existsFile(path, fileTree)} onConfirm={(path) => { setFileTree((fileTree) => createFile(path, fileTree, "")); - setActivePath(path); + onActivePathChange(path); setCreateFileOpen(false); setDirty(true); }} @@ -397,7 +385,7 @@ export const TemplateVersionEditor: FC = ({ ); setDeleteFileOpen(undefined); if (activePath === deleteFileOpen) { - setActivePath(undefined); + onActivePathChange(undefined); } setDirty(true); }} @@ -420,7 +408,7 @@ export const TemplateVersionEditor: FC = ({ setFileTree((fileTree) => moveFile(renameFileOpen, newPath, fileTree), ); - setActivePath(newPath); + onActivePathChange(newPath); setRenameFileOpen(undefined); setDirty(true); }} @@ -431,7 +419,7 @@ export const TemplateVersionEditor: FC = ({ onDelete={(file) => setDeleteFileOpen(file)} onSelect={(filePath) => { if (!isFolder(filePath, fileTree)) { - setActivePath(filePath); + onActivePathChange(filePath); } }} onRename={(file) => setRenameFileOpen(file)} diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx index 4f597748159e1..25afb46179b58 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx @@ -1,7 +1,7 @@ import { type FC, useEffect, useState } from "react"; import { Helmet } from "react-helmet-async"; import { useMutation, useQuery, useQueryClient } from "react-query"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { TemplateVersionEditor } from "./TemplateVersionEditor"; import { useOrganizationId } from "contexts/auth/useOrganizationId"; import { pageTitle } from "utils/page"; @@ -76,6 +76,20 @@ export const TemplateVersionEditorPage: FC = () => { const [lastSuccessfulPublishedVersion, setLastSuccessfulPublishedVersion] = useState(); + // File navigation + const [searchParams, setSearchParams] = useSearchParams(); + // It can be undefined when a selected file is deleted + const activePath: string | undefined = + searchParams.get("path") ?? findInitialFile(fileTree ?? {}); + const onActivePathChange = (path: string | undefined) => { + if (path) { + searchParams.set("path", path); + } else { + searchParams.delete("path"); + } + setSearchParams(searchParams); + }; + const navigateToVersion = (version: TemplateVersion) => { return navigate( `/templates/${templateName}/versions/${version.name}/edit`, @@ -122,6 +136,8 @@ export const TemplateVersionEditorPage: FC = () => { {templateQuery.data && templateVersionQuery.data && fileTree ? ( { + let initialFile: string | undefined; + + traverse(fileTree, (content, filename, path) => { + if (filename.endsWith(".tf")) { + initialFile = path; + } + }); + + return initialFile; +}; + export default TemplateVersionEditorPage; From a4184672bc37a74d18bda343678686f3d2ef32d6 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 5 Feb 2024 14:24:59 +0000 Subject: [PATCH 07/18] Show stack files --- .../SyntaxHighlighter/SyntaxHighlighter.tsx | 2 +- .../TemplateFiles/TemplateFileTree.tsx} | 4 +- .../TemplateFiles/TemplateFiles.stories.tsx | 2 - .../templates/TemplateFiles/TemplateFiles.tsx | 277 +++++++++--------- .../templates/TemplateFiles/isBinaryData.ts | 22 ++ .../TemplateFilesPage/TemplateFilesPage.tsx | 14 +- .../TemplateVersionEditor.tsx | 28 +- .../TemplateVersionPage.tsx | 3 - .../TemplateVersionPageView.stories.tsx | 8 - .../TemplateVersionPageView.tsx | 9 +- site/src/utils/templateVersion.ts | 8 +- 11 files changed, 171 insertions(+), 206 deletions(-) rename site/src/{pages/TemplateVersionEditorPage/FileTreeView.tsx => modules/templates/TemplateFiles/TemplateFileTree.tsx} (98%) create mode 100644 site/src/modules/templates/TemplateFiles/isBinaryData.ts diff --git a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx index e34659cef104d..41fce94f86e39 100644 --- a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx +++ b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx @@ -7,7 +7,7 @@ loader.config({ monaco }); interface SyntaxHighlighterProps { value: string; - language: string; + language?: string; editorProps?: ComponentProps & ComponentProps; compareWith?: string; diff --git a/site/src/pages/TemplateVersionEditorPage/FileTreeView.tsx b/site/src/modules/templates/TemplateFiles/TemplateFileTree.tsx similarity index 98% rename from site/src/pages/TemplateVersionEditorPage/FileTreeView.tsx rename to site/src/modules/templates/TemplateFiles/TemplateFileTree.tsx index d0c5096ed151e..8bcd70b72bbf7 100644 --- a/site/src/pages/TemplateVersionEditorPage/FileTreeView.tsx +++ b/site/src/modules/templates/TemplateFiles/TemplateFileTree.tsx @@ -28,7 +28,7 @@ type ContextMenu = { clientY: number; }; -interface FileTreeViewProps { +interface TemplateFilesTreeProps { onSelect: (path: string) => void; onDelete: (path: string) => void; onRename: (path: string) => void; @@ -36,7 +36,7 @@ interface FileTreeViewProps { activePath?: string; } -export const FileTreeView: FC = ({ +export const TemplateFileTree: FC = ({ fileTree, activePath, onDelete, diff --git a/site/src/modules/templates/TemplateFiles/TemplateFiles.stories.tsx b/site/src/modules/templates/TemplateFiles/TemplateFiles.stories.tsx index f878fcedf96c1..1f9b346fcb589 100644 --- a/site/src/modules/templates/TemplateFiles/TemplateFiles.stories.tsx +++ b/site/src/modules/templates/TemplateFiles/TemplateFiles.stories.tsx @@ -1,4 +1,3 @@ -import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { chromatic } from "testHelpers/chromatic"; import { TemplateFiles } from "./TemplateFiles"; @@ -19,7 +18,6 @@ const meta: Meta = { args: { currentFiles: exampleFiles, baseFiles: exampleFiles, - tab: { value: "0", set: action("change tab") }, }, }; diff --git a/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx b/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx index 54305a8d1b4ba..b7a3f6b1827c7 100644 --- a/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx +++ b/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx @@ -1,186 +1,173 @@ -import { type Interpolation, type Theme } from "@emotion/react"; -import { useEffect, type FC } from "react"; -import { DockerIcon } from "components/Icons/DockerIcon"; -import { MarkdownIcon } from "components/Icons/MarkdownIcon"; -import { TerraformIcon } from "components/Icons/TerraformIcon"; +import { useTheme, type Interpolation, type Theme } from "@emotion/react"; +import { type FC } from "react"; import { SyntaxHighlighter } from "components/SyntaxHighlighter/SyntaxHighlighter"; -import { UseTabResult, useTab } from "hooks/useTab"; import { TemplateVersionFiles } from "utils/templateVersion"; -import InsertDriveFileOutlined from "@mui/icons-material/InsertDriveFileOutlined"; - -const iconByExtension: Record = { - tf: , - md: , - mkd: , - Dockerfile: , - protobuf: , - sh: , - tpl: , -}; - -const getExtension = (filename: string) => { - if (filename.includes(".")) { - const [_, extension] = filename.split("."); - return extension; - } - - return filename; -}; +import RadioButtonCheckedOutlined from "@mui/icons-material/RadioButtonCheckedOutlined"; +import { Pill } from "components/Pill/Pill"; +import { Link } from "react-router-dom"; const languageByExtension: Record = { tf: "hcl", + hcl: "hcl", md: "markdown", mkd: "markdown", Dockerfile: "dockerfile", - sh: "bash", + sh: "shell", tpl: "tpl", protobuf: "protobuf", + nix: "dockerfile", }; - interface TemplateFilesProps { currentFiles: TemplateVersionFiles; /** * Files used to compare with current files */ baseFiles?: TemplateVersionFiles; - tab: UseTabResult; } export const TemplateFiles: FC = ({ currentFiles, baseFiles, - tab, }) => { const filenames = Object.keys(currentFiles); - const selectedFilename = filenames[Number(tab.value)]; - const currentFile = currentFiles[selectedFilename]; - const previousFile = baseFiles && baseFiles[selectedFilename]; + const theme = useTheme(); + const filesWithDiff = filenames.filter( + (filename) => fileInfo(filename).hasDiff, + ); - return ( -
-
- {filenames.map((filename, index) => { - const tabValue = index.toString(); - const extension = getExtension(filename); - const icon = iconByExtension[extension]; - const hasDiff = - baseFiles && - baseFiles[filename] && - currentFiles[filename] !== baseFiles[filename]; + function fileInfo(filename: string) { + const value = currentFiles[filename].trim(); + const previousValue = baseFiles ? baseFiles[filename].trim() : undefined; + const hasDiff = previousValue && value !== previousValue; - return ( - - ); - })} -
+ return { + value, + previousValue, + hasDiff, + }; + } - + return ( +
+ {filesWithDiff.length > 0 && ( +
+ ({ + fontSize: 13, + fontWeight: 500, + color: theme.roles.warning.fill.outline, + })} + > + {filesWithDiff.length} files have changes + + +
+ )} +
+ {[...filenames] + .sort((a, b) => a.localeCompare(b)) + .map((filename) => { + const info = fileInfo(filename); + + return ( +
+
+ {filename} + {info.hasDiff && ( + + )} +
+ { + editor.updateOptions({ + scrollBeyondLastLine: false, + }); + }, + }} + /> +
+ ); + })} +
); }; -export const useFileTab = (templateFiles: TemplateVersionFiles | undefined) => { - // Tabs The default tab is the tab that has main.tf but until we loads the - // files and check if main.tf exists we don't know which tab is the default - // one so we just use empty string - const tab = useTab("file", ""); - const isLoaded = tab.value !== ""; - useEffect(() => { - if (templateFiles && !isLoaded) { - const terraformFileIndex = Object.keys(templateFiles).indexOf("main.tf"); - // If main.tf exists use the index if not just use the first tab - tab.set(terraformFileIndex !== -1 ? terraformFileIndex.toString() : "0"); - } - }, [isLoaded, tab, templateFiles]); - - return { - ...tab, - isLoaded, - }; +const numberOfLines = (content: string) => { + return content.split("\n").length; }; const styles = { - tabs: (theme) => ({ - display: "flex", - alignItems: "baseline", - borderBottom: `1px solid ${theme.palette.divider}`, - gap: 1, - overflowX: "auto", - }), - - tab: (theme) => ({ - background: "transparent", - border: 0, - padding: "0 24px", + files: { display: "flex", - alignItems: "center", - height: 48, - opacity: 0.85, - cursor: "pointer", - gap: 4, - position: "relative", - color: theme.palette.text.secondary, - whiteSpace: "nowrap", - - "& svg": { - width: 22, - maxHeight: 16, - }, - - "&:hover": { - backgroundColor: theme.palette.action.hover, - }, - }), - - tabActive: (theme) => ({ - opacity: 1, - background: theme.palette.action.hover, - color: theme.palette.text.primary, - - "&:after": { - content: '""', - display: "block", - height: 1, - width: "100%", - bottom: 0, - left: 0, - backgroundColor: theme.palette.primary.main, - position: "absolute", - }, - }), - - tabDiff: (theme) => ({ - height: 6, - width: 6, - backgroundColor: theme.palette.warning.light, - borderRadius: "100%", - marginLeft: 4, - }), - - codeWrapper: (theme) => ({ - background: theme.palette.background.paper, - }), + flexDirection: "column", + gap: 16, + }, - files: (theme) => ({ + filePanel: (theme) => ({ borderRadius: 8, border: `1px solid ${theme.palette.divider}`, }), - prism: { - borderRadius: 0, - }, + fileHeader: (theme) => ({ + padding: "8px 16px", + borderBottom: `1px solid ${theme.palette.divider}`, + fontSize: 13, + fontWeight: 500, + display: "flex", + gap: 8, + alignItems: "center", + }), } satisfies Record>; diff --git a/site/src/modules/templates/TemplateFiles/isBinaryData.ts b/site/src/modules/templates/TemplateFiles/isBinaryData.ts new file mode 100644 index 0000000000000..caeacc4370ec6 --- /dev/null +++ b/site/src/modules/templates/TemplateFiles/isBinaryData.ts @@ -0,0 +1,22 @@ +export function isBinaryData(s: string): boolean { + // Remove unicode characters from the string like emojis. + const asciiString = s.replace(/[\u007F-\uFFFF]/g, ""); + + // Create a set of all printable ASCII characters (and some control characters). + const textChars = new Set( + [7, 8, 9, 10, 12, 13, 27].concat( + Array.from({ length: 128 }, (_, i) => i + 32), + ), + ); + + const isBinaryString = (str: string): boolean => { + for (let i = 0; i < str.length; i++) { + if (!textChars.has(str.charCodeAt(i))) { + return true; + } + } + return false; + }; + + return isBinaryString(asciiString); +} diff --git a/site/src/pages/TemplatePage/TemplateFilesPage/TemplateFilesPage.tsx b/site/src/pages/TemplatePage/TemplateFilesPage/TemplateFilesPage.tsx index a3cf599ef8f0a..850c00939fa73 100644 --- a/site/src/pages/TemplatePage/TemplateFilesPage/TemplateFilesPage.tsx +++ b/site/src/pages/TemplatePage/TemplateFilesPage/TemplateFilesPage.tsx @@ -3,10 +3,7 @@ import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; import { previousTemplateVersion, templateFiles } from "api/queries/templates"; import { Loader } from "components/Loader/Loader"; -import { - TemplateFiles, - useFileTab, -} from "modules/templates/TemplateFiles/TemplateFiles"; +import { TemplateFiles } from "modules/templates/TemplateFiles/TemplateFiles"; import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout"; import { useOrganizationId } from "contexts/auth/useOrganizationId"; import { getTemplatePageTitle } from "../utils"; @@ -24,7 +21,6 @@ const TemplateFilesPage: FC = () => { ...templateFiles(previousTemplate?.job.file_id ?? ""), enabled: Boolean(previousTemplate), }); - const tab = useFileTab(currentFiles); return ( <> @@ -32,12 +28,8 @@ const TemplateFilesPage: FC = () => { Codestin Search App - {previousFiles && currentFiles && tab.isLoaded ? ( - + {previousFiles && currentFiles ? ( + ) : ( )} diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx index 1d6fc02a20156..180258bb6e018 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx @@ -37,7 +37,7 @@ import { DeleteFileDialog, RenameFileDialog, } from "./FileDialog"; -import { FileTreeView } from "./FileTreeView"; +import { TemplateFileTree } from "modules/templates/TemplateFiles/TemplateFileTree"; import { MissingTemplateVariablesDialog } from "./MissingTemplateVariablesDialog"; import { MonacoEditor } from "./MonacoEditor"; import { PublishTemplateVersionDialog } from "./PublishTemplateVersionDialog"; @@ -55,6 +55,7 @@ import { import { Sidebar } from "components/FullPageLayout/Sidebar"; import { ProvisionerTagsPopover } from "./ProvisionerTagsPopover"; import WarningOutlined from "@mui/icons-material/WarningOutlined"; +import { isBinaryData } from "modules/templates/TemplateFiles/isBinaryData"; type Tab = "logs" | "resources" | undefined; // Undefined is to hide the tab @@ -414,7 +415,7 @@ export const TemplateVersionEditor: FC = ({ }} />
- setDeleteFileOpen(file)} onSelect={(filePath) => { @@ -647,29 +648,6 @@ export const TemplateVersionEditor: FC = ({ ); }; -function isBinaryData(s: string): boolean { - // Remove unicode characters from the string like emojis. - const asciiString = s.replace(/[\u007F-\uFFFF]/g, ""); - - // Create a set of all printable ASCII characters (and some control characters). - const textChars = new Set( - [7, 8, 9, 10, 12, 13, 27].concat( - Array.from({ length: 128 }, (_, i) => i + 32), - ), - ); - - const isBinaryString = (str: string): boolean => { - for (let i = 0; i < str.length; i++) { - if (!textChars.has(str.charCodeAt(i))) { - return true; - } - } - return false; - }; - - return isBinaryString(asciiString); -} - const styles = { tab: (theme) => ({ "&:not(:disabled)": { diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionPage.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionPage.tsx index 7110c33dc47b8..bb307dac66316 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionPage.tsx +++ b/site/src/pages/TemplateVersionPage/TemplateVersionPage.tsx @@ -5,7 +5,6 @@ import { useParams } from "react-router-dom"; import { usePermissions } from "contexts/auth/usePermissions"; import { useOrganizationId } from "contexts/auth/useOrganizationId"; import { pageTitle } from "utils/page"; -import { useFileTab } from "modules/templates/TemplateFiles/TemplateFiles"; import TemplateVersionPageView from "./TemplateVersionPageView"; import { templateByName, @@ -43,7 +42,6 @@ export const TemplateVersionPage: FC = () => { ...templateFiles(activeVersionQuery.data?.job.file_id ?? ""), enabled: Boolean(activeVersionQuery.data), }); - const tab = useFileTab(selectedVersionFilesQuery.data); const permissions = usePermissions(); const versionId = selectedVersionQuery.data?.id; @@ -75,7 +73,6 @@ export const TemplateVersionPage: FC = () => { baseFiles={activeVersionFilesQuery.data} versionName={versionName} templateName={templateName} - tab={tab} createWorkspaceUrl={ permissions.updateTemplates ? createWorkspaceUrl : undefined } diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionPageView.stories.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionPageView.stories.tsx index 83d2bb623f47a..4081245b40ca4 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionPageView.stories.tsx +++ b/site/src/pages/TemplateVersionPage/TemplateVersionPageView.stories.tsx @@ -1,5 +1,3 @@ -import { action } from "@storybook/addon-actions"; -import { UseTabResult } from "hooks/useTab"; import { mockApiError, MockTemplate, @@ -11,11 +9,6 @@ import { } from "./TemplateVersionPageView"; import type { Meta, StoryObj } from "@storybook/react"; -const tab: UseTabResult = { - value: "0", - set: action("changeTab"), -}; - const readmeContent = `--- name:Template test --- @@ -28,7 +21,6 @@ You can add instructions here \`\`\``; const defaultArgs: TemplateVersionPageViewProps = { - tab, templateName: MockTemplate.name, versionName: MockTemplateVersion.name, currentVersion: MockTemplateVersion, diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx index c8aa6d6c0e262..ab2be78b6761c 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx +++ b/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx @@ -14,7 +14,6 @@ import { import { Stack } from "components/Stack/Stack"; import { Stats, StatsItem } from "components/Stats/Stats"; import { TemplateFiles } from "modules/templates/TemplateFiles/TemplateFiles"; -import { UseTabResult } from "hooks/useTab"; import type { TemplateVersion } from "api/typesGenerated"; import { createDayString } from "utils/createDayString"; import { TemplateVersionFiles } from "utils/templateVersion"; @@ -23,7 +22,6 @@ import { ErrorAlert } from "components/Alert/ErrorAlert"; export interface TemplateVersionPageViewProps { versionName: string; templateName: string; - tab: UseTabResult; createWorkspaceUrl?: string; error: unknown; currentVersion: TemplateVersion | undefined; @@ -32,7 +30,6 @@ export interface TemplateVersionPageViewProps { } export const TemplateVersionPageView: FC = ({ - tab, versionName, templateName, createWorkspaceUrl, @@ -100,11 +97,7 @@ export const TemplateVersionPageView: FC = ({ /> - + )} diff --git a/site/src/utils/templateVersion.ts b/site/src/utils/templateVersion.ts index d9bb4a0cf5faf..c43ad8715c5cc 100644 --- a/site/src/utils/templateVersion.ts +++ b/site/src/utils/templateVersion.ts @@ -1,6 +1,7 @@ import set from "lodash/set"; import { FileTree } from "./filetree"; import { TarFileTypeCodes, TarReader } from "./tar"; +import { isBinaryData } from "modules/templates/TemplateFiles/isBinaryData"; // Content by filename export type TemplateVersionFiles = Record; @@ -12,7 +13,12 @@ export const getTemplateVersionFiles = async ( const tarReader = new TarReader(); await tarReader.readFile(tarFile); for (const file of tarReader.fileInfo) { - files[file.name] = tarReader.getTextFile(file.name) as string; + if (file.type === TarFileTypeCodes.File) { + const content = tarReader.getTextFile(file.name) as string; + if (!isBinaryData(content)) { + files[file.name] = tarReader.getTextFile(file.name) as string; + } + } } return files; }; From 0c72ecca8eebe76bf1e9a8c1101cbda6ab110d77 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 5 Feb 2024 14:25:16 +0000 Subject: [PATCH 08/18] Remove unecessary imports --- site/src/modules/templates/TemplateFiles/TemplateFiles.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx b/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx index b7a3f6b1827c7..aea2c08ddba98 100644 --- a/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx +++ b/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx @@ -3,8 +3,6 @@ import { type FC } from "react"; import { SyntaxHighlighter } from "components/SyntaxHighlighter/SyntaxHighlighter"; import { TemplateVersionFiles } from "utils/templateVersion"; import RadioButtonCheckedOutlined from "@mui/icons-material/RadioButtonCheckedOutlined"; -import { Pill } from "components/Pill/Pill"; -import { Link } from "react-router-dom"; const languageByExtension: Record = { tf: "hcl", From 2353ea92edd85183b2721126ec02ed61947ca6a7 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 5 Feb 2024 15:41:16 +0000 Subject: [PATCH 09/18] Add file tree on template files --- .../TemplateFiles/TemplateFileTree.tsx | 18 +- .../templates/TemplateFiles/TemplateFiles.tsx | 157 ++++++++++++------ 2 files changed, 120 insertions(+), 55 deletions(-) diff --git a/site/src/modules/templates/TemplateFiles/TemplateFileTree.tsx b/site/src/modules/templates/TemplateFiles/TemplateFileTree.tsx index 8bcd70b72bbf7..0946b5d066d1b 100644 --- a/site/src/modules/templates/TemplateFiles/TemplateFileTree.tsx +++ b/site/src/modules/templates/TemplateFiles/TemplateFileTree.tsx @@ -30,10 +30,11 @@ type ContextMenu = { interface TemplateFilesTreeProps { onSelect: (path: string) => void; - onDelete: (path: string) => void; - onRename: (path: string) => void; + onDelete?: (path: string) => void; + onRename?: (path: string) => void; fileTree: FileTree; activePath?: string; + Label?: FC<{ path: string; filename: string; isFolder: boolean }>; } export const TemplateFileTree: FC = ({ @@ -42,6 +43,7 @@ export const TemplateFileTree: FC = ({ onDelete, onRename, onSelect, + Label, }) => { const [contextMenu, setContextMenu] = useState(); const buildTreeItems = ( @@ -69,7 +71,13 @@ export const TemplateFileTree: FC = ({ + ) : ( + filename + ) + } css={(theme) => css` overflow: hidden; user-select: none; @@ -187,7 +195,7 @@ export const TemplateFileTree: FC = ({ if (!contextMenu) { return; } - onRename(contextMenu.path); + onRename && onRename(contextMenu.path); setContextMenu(undefined); }} > @@ -198,7 +206,7 @@ export const TemplateFileTree: FC = ({ if (!contextMenu) { return; } - onDelete(contextMenu.path); + onDelete && onDelete(contextMenu.path); setContextMenu(undefined); }} > diff --git a/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx b/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx index aea2c08ddba98..87877efc1a3a3 100644 --- a/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx +++ b/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx @@ -1,8 +1,11 @@ import { useTheme, type Interpolation, type Theme } from "@emotion/react"; -import { type FC } from "react"; +import { useMemo, type FC, useCallback } from "react"; import { SyntaxHighlighter } from "components/SyntaxHighlighter/SyntaxHighlighter"; import { TemplateVersionFiles } from "utils/templateVersion"; import RadioButtonCheckedOutlined from "@mui/icons-material/RadioButtonCheckedOutlined"; +import { FileTree } from "utils/filetree"; +import set from "lodash/fp/set"; +import { TemplateFileTree } from "./TemplateFileTree"; const languageByExtension: Record = { tf: "hcl", @@ -29,21 +32,34 @@ export const TemplateFiles: FC = ({ }) => { const filenames = Object.keys(currentFiles); const theme = useTheme(); - const filesWithDiff = filenames.filter( - (filename) => fileInfo(filename).hasDiff, + + const fileInfo = useCallback( + (filename: string) => { + const value = currentFiles[filename].trim(); + const previousValue = baseFiles ? baseFiles[filename].trim() : undefined; + const hasDiff = previousValue && value !== previousValue; + + return { + value, + previousValue, + hasDiff, + }; + }, + [baseFiles, currentFiles], ); - function fileInfo(filename: string) { - const value = currentFiles[filename].trim(); - const previousValue = baseFiles ? baseFiles[filename].trim() : undefined; - const hasDiff = previousValue && value !== previousValue; + const filesWithDiff = filenames.filter( + (filename) => fileInfo(filename).hasDiff && false, + ); - return { - value, - previousValue, - hasDiff, - }; - } + const fileTree: FileTree = useMemo(() => { + let tree: FileTree = {}; + for (const filename of filenames) { + const info = fileInfo(filename); + tree = set(filename.split("/"), info.value, tree); + } + return tree; + }, [fileInfo, filenames]); return (
@@ -99,45 +115,74 @@ export const TemplateFiles: FC = ({
)} -
- {[...filenames] - .sort((a, b) => a.localeCompare(b)) - .map((filename) => { - const info = fileInfo(filename); +
+
+ { + if (isFolder) { + return <>{filename}; + } - return ( -
-
- {filename} - {info.hasDiff && ( - - )} -
- { - editor.updateOptions({ - scrollBeyondLastLine: false, - }); - }, + const hasDiff = fileInfo(path).hasDiff; + return ( + -
- ); - })} + > + {filename} + + ); + }} + /> +
+ +
+ {[...filenames] + .sort((a, b) => a.localeCompare(b)) + .map((filename) => { + const info = fileInfo(filename); + + return ( +
+
+ {filename} + {info.hasDiff && ( + + )} +
+ { + editor.updateOptions({ + scrollBeyondLastLine: false, + }); + }, + }} + /> +
+ ); + })} +
); @@ -148,10 +193,22 @@ const numberOfLines = (content: string) => { }; const styles = { + sidebar: (theme) => ({ + width: 240, + flexShrink: 0, + borderRadius: 8, + overflow: "auto", + border: `1px solid ${theme.palette.divider}`, + padding: "4px 0", + position: "sticky", + top: 32, + }), + files: { display: "flex", flexDirection: "column", gap: 16, + flex: 1, }, filePanel: (theme) => ({ From 0c2ffc93d09ef50a6a2e7ee99fc49de7ba461359 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 5 Feb 2024 17:31:22 +0000 Subject: [PATCH 10/18] Add edit button --- .../templates/TemplateFiles/TemplateFiles.tsx | 83 ++++++------------- .../TemplateFilesPage/TemplateFilesPage.tsx | 7 +- .../TemplateVersionPageView.tsx | 7 +- 3 files changed, 39 insertions(+), 58 deletions(-) diff --git a/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx b/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx index 87877efc1a3a3..62158c4308346 100644 --- a/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx +++ b/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx @@ -6,6 +6,8 @@ import RadioButtonCheckedOutlined from "@mui/icons-material/RadioButtonCheckedOu import { FileTree } from "utils/filetree"; import set from "lodash/fp/set"; import { TemplateFileTree } from "./TemplateFileTree"; +import { Link } from "react-router-dom"; +import EditOutlined from "@mui/icons-material/EditOutlined"; const languageByExtension: Record = { tf: "hcl", @@ -24,11 +26,15 @@ interface TemplateFilesProps { * Files used to compare with current files */ baseFiles?: TemplateVersionFiles; + versionName: string; + templateName: string; } export const TemplateFiles: FC = ({ currentFiles, baseFiles, + versionName, + templateName, }) => { const filenames = Object.keys(currentFiles); const theme = useTheme(); @@ -48,10 +54,6 @@ export const TemplateFiles: FC = ({ [baseFiles, currentFiles], ); - const filesWithDiff = filenames.filter( - (filename) => fileInfo(filename).hasDiff && false, - ); - const fileTree: FileTree = useMemo(() => { let tree: FileTree = {}; for (const filename of filenames) { @@ -63,58 +65,6 @@ export const TemplateFiles: FC = ({ return (
- {filesWithDiff.length > 0 && ( -
- ({ - fontSize: 13, - fontWeight: 500, - color: theme.roles.warning.fill.outline, - })} - > - {filesWithDiff.length} files have changes - - -
- )}
= ({ }} /> )} + +
+ + + Edit + +
{ {previousFiles && currentFiles ? ( - + ) : ( )} diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx index ab2be78b6761c..3636be30f459d 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx +++ b/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx @@ -97,7 +97,12 @@ export const TemplateVersionPageView: FC = ({ /> - + )} From 98aa0544c0674486bbb4486fdad365dc4f86a9c0 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 5 Feb 2024 17:35:41 +0000 Subject: [PATCH 11/18] Use same editor bg --- site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx | 3 +++ site/src/modules/templates/TemplateFiles/TemplateFiles.tsx | 1 + 2 files changed, 4 insertions(+) diff --git a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx index 41fce94f86e39..f52f2523e4e59 100644 --- a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx +++ b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx @@ -2,6 +2,7 @@ import { type ComponentProps, type FC } from "react"; import Editor, { DiffEditor, loader } from "@monaco-editor/react"; import * as monaco from "monaco-editor"; import { useCoderTheme } from "./coderTheme"; +import { useTheme } from "@emotion/react"; loader.config({ monaco }); @@ -20,6 +21,7 @@ export const SyntaxHighlighter: FC = ({ editorProps, }) => { const hasDiff = compareWith && value !== compareWith; + const theme = useTheme(); const coderTheme = useCoderTheme(); const commonProps = { language, @@ -45,6 +47,7 @@ export const SyntaxHighlighter: FC = ({ css={{ padding: "8px 0", height: "100%", + backgroundColor: theme.monaco.colors["editor.background"], }} > {hasDiff ? ( diff --git a/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx b/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx index 62158c4308346..f3d4a75179635 100644 --- a/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx +++ b/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx @@ -185,6 +185,7 @@ const styles = { filePanel: (theme) => ({ borderRadius: 8, border: `1px solid ${theme.palette.divider}`, + overflow: "hidden", }), fileHeader: (theme) => ({ From 8508e69fd067b237875976bfb75eaffb156c7451 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 6 Feb 2024 11:00:27 +0000 Subject: [PATCH 12/18] Add storybook for visual testing --- .../TemplateFileTree.stories.tsx | 51 +++++++++++++++++++ .../TemplateFiles/TemplateFiles.stories.tsx | 10 ++++ 2 files changed, 61 insertions(+) create mode 100644 site/src/modules/templates/TemplateFiles/TemplateFileTree.stories.tsx diff --git a/site/src/modules/templates/TemplateFiles/TemplateFileTree.stories.tsx b/site/src/modules/templates/TemplateFiles/TemplateFileTree.stories.tsx new file mode 100644 index 0000000000000..c5ab3a86dfb39 --- /dev/null +++ b/site/src/modules/templates/TemplateFiles/TemplateFileTree.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { chromatic } from "testHelpers/chromatic"; +import { TemplateFileTree } from "./TemplateFileTree"; +import { FileTree } from "utils/filetree"; +import { useTheme } from "@emotion/react"; + +const fileTree: FileTree = { + "main.tf": "resource aws_instance my_instance {}", + "variables.tf": "variable my_var {}", + "outputs.tf": "output my_output {}", + folder: { + "nested.tf": "resource aws_instance my_instance {}", + }, +}; + +const meta: Meta = { + title: "modules/templates/TemplateFileTree", + parameters: { chromatic }, + component: TemplateFileTree, + args: { + fileTree, + activePath: "main.tf", + }, + decorators: [ + (Story) => { + const theme = useTheme(); + return ( +
+ +
+ ); + }, + ], +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = {}; + +export const NestedOpen: Story = { + args: { + activePath: "folder/nested.tf", + }, +}; diff --git a/site/src/modules/templates/TemplateFiles/TemplateFiles.stories.tsx b/site/src/modules/templates/TemplateFiles/TemplateFiles.stories.tsx index 1f9b346fcb589..e7f4e815b6003 100644 --- a/site/src/modules/templates/TemplateFiles/TemplateFiles.stories.tsx +++ b/site/src/modules/templates/TemplateFiles/TemplateFiles.stories.tsx @@ -26,4 +26,14 @@ type Story = StoryObj; const Example: Story = {}; +export const WithDiff: Story = { + args: { + currentFiles: { + ...exampleFiles, + "main.tf": `${exampleFiles["main.tf"]} - with changes`, + }, + baseFiles: exampleFiles, + }, +}; + export { Example as TemplateFiles }; From 94775b1a2fc1beb4162825fb272551cdc6ae1f9e Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 6 Feb 2024 11:18:32 +0000 Subject: [PATCH 13/18] Fix tests --- .../TemplateVersionEditorPage.test.tsx | 6 ++++++ site/src/testHelpers/templateFiles.tar | Bin 27648 -> 10240 bytes 2 files changed, 6 insertions(+) diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx index c49d68110009d..491c004bf971b 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx @@ -31,6 +31,12 @@ jest.mock( }), ); +// Occasionally, Jest encounters HTML5 canvas errors. As the MonacoEditor is not +// required for these tests, we can safely mock it. +jest.mock("pages/TemplateVersionEditorPage/MonacoEditor", () => ({ + MonacoEditor: () =>
, +})); + const renderTemplateEditorPage = () => { renderWithAuth(, { route: `/templates/${MockTemplate.name}/versions/${MockTemplateVersion.name}/edit`, diff --git a/site/src/testHelpers/templateFiles.tar b/site/src/testHelpers/templateFiles.tar index b0bbdff89fcb7d9b8c1990e2af1086a1a746506a..551108de07d8d4a96514c430d8edb3ff54aa6c66 100644 GIT binary patch delta 135 zcmZp;!PpSMo?4NZTac4FS%5EOvI8TpshNohgMop8p}DC6G7S_00b>S36H_w-6GKCD z19JugLn8x269xr?$%%|9yrsn@iA4$u49WQ^sYMe7rCGs@$q#uhF;~}eZBDdU#=g10 KVmc}@i5&nc>LxD$ literal 27648 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 Date: Tue, 6 Feb 2024 12:15:17 +0000 Subject: [PATCH 14/18] Fix test --- site/src/modules/templates/TemplateFiles/TemplateFiles.tsx | 2 +- .../pages/TemplateVersionPage/TemplateVersionPage.test.tsx | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx b/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx index f3d4a75179635..fcbd30b78fea6 100644 --- a/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx +++ b/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx @@ -93,7 +93,7 @@ export const TemplateFiles: FC = ({ />
-
+
{[...filenames] .sort((a, b) => a.localeCompare(b)) .map((filename) => { diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionPage.test.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionPage.test.tsx index 2f33cc0384c3e..6b386ab39469f 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionPage.test.tsx +++ b/site/src/pages/TemplateVersionPage/TemplateVersionPage.test.tsx @@ -4,7 +4,7 @@ import { } from "testHelpers/renderHelpers"; import TemplateVersionPage from "./TemplateVersionPage"; import * as templateVersionUtils from "utils/templateVersion"; -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import * as CreateDayString from "utils/createDayString"; const TEMPLATE_NAME = "coder-ts"; @@ -36,7 +36,8 @@ describe("TemplateVersionPage", () => { beforeEach(setup); it("shows files", () => { - expect(screen.getByText(TERRAFORM_FILENAME)).toBeInTheDocument(); - expect(screen.getByText(README_FILENAME)).toBeInTheDocument(); + const files = screen.getByTestId("template-files-content"); + expect(within(files).getByText(TERRAFORM_FILENAME)).toBeInTheDocument(); + expect(within(files).getByText(README_FILENAME)).toBeInTheDocument(); }); }); From 955bced3a64f90c3df497640f261998c0c1add63 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 7 Feb 2024 12:48:35 +0000 Subject: [PATCH 15/18] wip: group empty folders --- .../TemplateFileTree.stories.tsx | 17 +++++++++++++++ .../TemplateFiles/TemplateFileTree.tsx | 21 +++++++++++++++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/site/src/modules/templates/TemplateFiles/TemplateFileTree.stories.tsx b/site/src/modules/templates/TemplateFiles/TemplateFileTree.stories.tsx index c5ab3a86dfb39..401a7e6d8b201 100644 --- a/site/src/modules/templates/TemplateFiles/TemplateFileTree.stories.tsx +++ b/site/src/modules/templates/TemplateFiles/TemplateFileTree.stories.tsx @@ -49,3 +49,20 @@ export const NestedOpen: Story = { activePath: "folder/nested.tf", }, }; + +export const GroupEmptyFolders: Story = { + args: { + fileTree: { + "main.tf": "resource aws_instance my_instance {}", + "variables.tf": "variable my_var {}", + "outputs.tf": "output my_output {}", + folder: { + "other-folder": { + another: { + "nested.tf": "resource aws_instance my_instance {}", + }, + }, + }, + }, + }, +}; diff --git a/site/src/modules/templates/TemplateFiles/TemplateFileTree.tsx b/site/src/modules/templates/TemplateFiles/TemplateFileTree.tsx index 0946b5d066d1b..37dccfa29fed6 100644 --- a/site/src/modules/templates/TemplateFiles/TemplateFileTree.tsx +++ b/site/src/modules/templates/TemplateFiles/TemplateFileTree.tsx @@ -46,14 +46,23 @@ export const TemplateFileTree: FC = ({ Label, }) => { const [contextMenu, setContextMenu] = useState(); + + const isFolder = (content?: FileTree | string): content is FileTree => + typeof content === "object"; + const buildTreeItems = ( filename: string, content?: FileTree | string, parentPath?: string, ): JSX.Element => { const currentPath = parentPath ? `${parentPath}/${filename}` : filename; - const isFolder = typeof content === "object"; - let icon: JSX.Element | null = isFolder ? null : ( + // Used to group empty folders in one single label like VSCode does + const shouldGroupFolderLabel = + isFolder(content) && + Object.keys(content).length === 1 && + isFolder(Object.keys(content)[0]); + + let icon: JSX.Element | null = isFolder(content) ? null : ( ); @@ -73,7 +82,11 @@ export const TemplateFileTree: FC = ({ key={currentPath} label={ Label ? ( -