feat(site): allow any file extension on template editor (#12000)

This commit is contained in:
Bruno Quaresma
2024-02-07 13:24:28 -03:00
committed by GitHub
parent 4e7b208068
commit 4b1bac31b6
20 changed files with 517 additions and 333 deletions
@@ -2,12 +2,13 @@ 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 });
interface SyntaxHighlighterProps {
value: string;
language: string;
language?: string;
editorProps?: ComponentProps<typeof Editor> &
ComponentProps<typeof DiffEditor>;
compareWith?: string;
@@ -20,6 +21,7 @@ export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
editorProps,
}) => {
const hasDiff = compareWith && value !== compareWith;
const theme = useTheme();
const coderTheme = useCoderTheme();
const commonProps = {
language,
@@ -45,6 +47,7 @@ export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
css={{
padding: "8px 0",
height: "100%",
backgroundColor: theme.monaco.colors["editor.background"],
}}
>
{hasDiff ? (
@@ -0,0 +1,86 @@
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<typeof TemplateFileTree> = {
title: "modules/templates/TemplateFileTree",
parameters: { chromatic },
component: TemplateFileTree,
args: {
fileTree,
activePath: "main.tf",
},
decorators: [
(Story) => {
const theme = useTheme();
return (
<div
css={{
maxWidth: 260,
borderRadius: 8,
border: `1px solid ${theme.palette.divider}`,
}}
>
<Story />
</div>
);
},
],
};
export default meta;
type Story = StoryObj<typeof TemplateFileTree>;
export const Example: Story = {};
export const NestedOpen: Story = {
args: {
activePath: "folder/nested.tf",
},
};
export const GroupEmptyFolders: Story = {
args: {
activePath: "folder/other-folder/another/nested.tf",
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 {}",
},
},
},
},
},
};
export const GreyOutHiddenFiles: Story = {
args: {
fileTree: {
".vite": {
"config.json": "resource aws_instance my_instance {}",
},
".nextjs": {
"nested.tf": "resource aws_instance my_instance {}",
},
".terraform.lock.hcl": "{}",
"main.tf": "resource aws_instance my_instance {}",
"variables.tf": "variable my_var {}",
"outputs.tf": "output my_output {}",
},
},
};
@@ -28,30 +28,59 @@ type ContextMenu = {
clientY: number;
};
interface FileTreeViewProps {
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;
label: string;
isFolder: boolean;
}>;
}
export const FileTreeView: FC<FileTreeViewProps> = ({
export const TemplateFileTree: FC<TemplateFilesTreeProps> = ({
fileTree,
activePath,
onDelete,
onRename,
onSelect,
Label,
}) => {
const [contextMenu, setContextMenu] = useState<ContextMenu | undefined>();
const isFolder = (content?: FileTree | string): content is FileTree =>
typeof content === "object";
const buildTreeItems = (
label: string,
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 shouldGroupFolder =
isFolder(content) &&
Object.keys(content).length === 1 &&
isFolder(Object.values(content)[0]);
const isHiddenFile = currentPath.startsWith(".");
if (shouldGroupFolder) {
const firstChildFileName = Object.keys(content)[0];
const child = content[firstChildFileName];
return buildTreeItems(
`${label} / ${firstChildFileName}`,
firstChildFileName,
child,
currentPath,
);
}
let icon: JSX.Element | null = isFolder(content) ? null : (
<FormatAlignLeftOutlined />
);
@@ -69,26 +98,40 @@ export const FileTreeView: FC<FileTreeViewProps> = ({
<TreeItem
nodeId={currentPath}
key={currentPath}
label={filename}
label={
Label ? (
<Label
path={currentPath}
label={label}
filename={filename}
isFolder={isFolder(content)}
/>
) : (
label
)
}
css={(theme) => css`
overflow: hidden;
user-select: none;
& > .MuiTreeItem-content {
padding: 2px 16px;
color: ${theme.palette.text.secondary};
color: ${isHiddenFile
? theme.palette.text.disabled
: theme.palette.text.secondary};
height: 32px;
& svg {
width: 12px;
height: 12px;
color: ${theme.palette.text.secondary};
color: currentColor;
}
& > .MuiTreeItem-label {
margin-left: 4px;
font-size: 13px;
color: inherit;
white-space: nowrap;
}
&.Mui-selected {
@@ -103,10 +146,11 @@ export const FileTreeView: FC<FileTreeViewProps> = ({
& .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);
}
}
`}
@@ -114,6 +158,10 @@ export const FileTreeView: FC<FileTreeViewProps> = ({
onSelect(currentPath);
}}
onContextMenu={(event) => {
const hasContextActions = onRename || onDelete;
if (!hasContextActions) {
return;
}
event.preventDefault(); // Avoid default browser behavior
event.stopPropagation(); // Avoid trigger parent context menu
setContextMenu(
@@ -133,12 +181,12 @@ export const FileTreeView: FC<FileTreeViewProps> = ({
} as CSSProperties
}
>
{isFolder &&
{isFolder(content) &&
Object.keys(content)
.sort(sortFileTree(content))
.map((filename) => {
const child = content[filename];
return buildTreeItems(filename, child, currentPath);
return buildTreeItems(filename, filename, child, currentPath);
})}
</TreeItem>
);
@@ -149,13 +197,14 @@ export const FileTreeView: FC<FileTreeViewProps> = ({
defaultCollapseIcon={<ExpandMoreIcon />}
defaultExpandIcon={<ChevronRightIcon />}
aria-label="Files"
defaultExpanded={activePath ? expandablePaths(activePath) : []}
defaultSelected={activePath}
>
{Object.keys(fileTree)
.sort(sortFileTree(fileTree))
.map((filename) => {
const child = fileTree[filename];
return buildTreeItems(filename, child);
return buildTreeItems(filename, filename, child);
})}
<Menu
@@ -184,7 +233,7 @@ export const FileTreeView: FC<FileTreeViewProps> = ({
if (!contextMenu) {
return;
}
onRename(contextMenu.path);
onRename && onRename(contextMenu.path);
setContextMenu(undefined);
}}
>
@@ -195,7 +244,7 @@ export const FileTreeView: FC<FileTreeViewProps> = ({
if (!contextMenu) {
return;
}
onDelete(contextMenu.path);
onDelete && onDelete(contextMenu.path);
setContextMenu(undefined);
}}
>
@@ -232,3 +281,12 @@ const FileTypeMarkdown: FC = () => (
<polygon points="22.955 20.636 18.864 16.136 21.591 16.136 21.591 11.364 24.318 11.364 24.318 16.136 27.045 16.136 22.955 20.636" />
</svg>
);
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;
};
@@ -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<typeof TemplateFiles> = {
args: {
currentFiles: exampleFiles,
baseFiles: exampleFiles,
tab: { value: "0", set: action("change tab") },
},
};
@@ -28,4 +26,14 @@ type Story = StoryObj<typeof TemplateFiles>;
const Example: Story = {};
export const WithDiff: Story = {
args: {
currentFiles: {
...exampleFiles,
"main.tf": `${exampleFiles["main.tf"]} - with changes`,
},
baseFiles: exampleFiles,
},
};
export { Example as TemplateFiles };
@@ -1,190 +1,200 @@
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 { useMemo, type FC, useCallback } from "react";
import { SyntaxHighlighter } from "components/SyntaxHighlighter/SyntaxHighlighter";
import { UseTabResult, useTab } from "hooks/useTab";
import { AllowedExtension, TemplateVersionFiles } from "utils/templateVersion";
import InsertDriveFileOutlined from "@mui/icons-material/InsertDriveFileOutlined";
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";
import { Link } from "react-router-dom";
import EditOutlined from "@mui/icons-material/EditOutlined";
const iconByExtension: Record<AllowedExtension, JSX.Element> = {
tf: <TerraformIcon />,
md: <MarkdownIcon />,
mkd: <MarkdownIcon />,
Dockerfile: <DockerIcon />,
protobuf: <InsertDriveFileOutlined />,
sh: <InsertDriveFileOutlined />,
tpl: <InsertDriveFileOutlined />,
};
const getExtension = (filename: string) => {
if (filename.includes(".")) {
const [_, extension] = filename.split(".");
return extension;
}
return filename;
};
const languageByExtension: Record<AllowedExtension, string> = {
const languageByExtension: Record<string, string> = {
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;
versionName: string;
templateName: string;
}
export const TemplateFiles: FC<TemplateFilesProps> = ({
currentFiles,
baseFiles,
tab,
versionName,
templateName,
}) => {
const filenames = Object.keys(currentFiles);
const selectedFilename = filenames[Number(tab.value)];
const currentFile = currentFiles[selectedFilename];
const previousFile = baseFiles && baseFiles[selectedFilename];
const theme = useTheme();
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],
);
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 (
<div css={styles.files}>
<div css={styles.tabs}>
{filenames.map((filename, index) => {
const tabValue = index.toString();
const extension = getExtension(filename) as AllowedExtension;
const icon = iconByExtension[extension];
const hasDiff =
baseFiles &&
baseFiles[filename] &&
currentFiles[filename] !== baseFiles[filename];
<div>
<div css={{ display: "flex", alignItems: "flex-start", gap: 32 }}>
<div css={styles.sidebar}>
<TemplateFileTree
fileTree={fileTree}
onSelect={function (path: string): void {
window.location.hash = path;
}}
Label={({ path, filename, isFolder }) => {
if (isFolder) {
return <>{filename}</>;
}
return (
<button
css={[styles.tab, tabValue === tab.value && styles.tabActive]}
onClick={() => {
tab.set(tabValue);
}}
key={filename}
>
{icon}
{filename}
{hasDiff && <div css={styles.tabDiff} />}
</button>
);
})}
const hasDiff = fileInfo(path).hasDiff;
return (
<span
css={{
color: hasDiff
? theme.roles.warning.fill.outline
: undefined,
}}
>
{filename}
</span>
);
}}
/>
</div>
<div css={styles.files} data-testid="template-files-content">
{[...filenames]
.sort((a, b) => a.localeCompare(b))
.map((filename) => {
const info = fileInfo(filename);
return (
<div key={filename} css={styles.filePanel} id={filename}>
<header css={styles.fileHeader}>
{filename}
{info.hasDiff && (
<RadioButtonCheckedOutlined
css={{
width: 14,
height: 14,
color: theme.roles.warning.fill.outline,
}}
/>
)}
<div css={{ marginLeft: "auto" }}>
<Link
to={`/templates/${templateName}/versions/${versionName}/edit?path=${filename}`}
css={{
display: "flex",
gap: 4,
alignItems: "center",
fontSize: 14,
color: theme.palette.text.secondary,
textDecoration: "none",
"&:hover": {
color: theme.palette.text.primary,
},
}}
>
<EditOutlined css={{ fontSize: "inherit" }} />
Edit
</Link>
</div>
</header>
<SyntaxHighlighter
language={
languageByExtension[filename.split(".").pop() ?? ""]
}
value={info.value}
compareWith={info.previousValue}
editorProps={{
// 18 is the editor line height
height: Math.min(numberOfLines(info.value) * 18, 560),
onMount: (editor) => {
editor.updateOptions({
scrollBeyondLastLine: false,
});
},
}}
/>
</div>
);
})}
</div>
</div>
<SyntaxHighlighter
value={currentFile}
compareWith={previousFile}
language={
languageByExtension[
getExtension(selectedFilename) as AllowedExtension
]
}
/>
</div>
);
};
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) => ({
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",
alignItems: "baseline",
borderBottom: `1px solid ${theme.palette.divider}`,
gap: 1,
overflowX: "auto",
}),
flexDirection: "column",
gap: 16,
flex: 1,
},
tab: (theme) => ({
background: "transparent",
border: 0,
padding: "0 24px",
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,
}),
files: (theme) => ({
filePanel: (theme) => ({
borderRadius: 8,
border: `1px solid ${theme.palette.divider}`,
overflow: "hidden",
}),
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<string, Interpolation<Theme>>;
@@ -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);
}
@@ -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,11 +28,12 @@ const TemplateFilesPage: FC = () => {
<title>{getTemplatePageTitle("Source Code", template)}</title>
</Helmet>
{previousFiles && currentFiles && tab.isLoaded ? (
{previousFiles && currentFiles ? (
<TemplateFiles
currentFiles={currentFiles}
baseFiles={previousFiles}
tab={tab}
versionName={activeVersion.name}
templateName={template.name}
/>
) : (
<Loader />
@@ -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<CreateFileDialogProps> = ({
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<RenameFileDialogProps> = ({
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) {
@@ -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<MonacoEditorProps> = ({
}) => {
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<MonacoEditorProps> = ({
return (
<Editor
value={value}
language={language}
theme="vs-dark"
options={{
automaticLayout: true,
@@ -26,6 +26,7 @@ const meta: Meta<typeof TemplateVersionEditor> = {
},
component: TemplateVersionEditor,
args: {
activePath: "main.tf",
template: MockTemplate,
templateVersion: MockTemplateVersion,
defaultFileTree: MockTemplateVersionFileTree,
@@ -26,11 +26,10 @@ import {
createFile,
existsFile,
FileTree,
getFileContent,
getFileText,
isFolder,
moveFile,
removeFile,
traverse,
updateFile,
} from "utils/filetree";
import {
@@ -38,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 +54,8 @@ import {
} from "components/FullPageLayout/Topbar";
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
@@ -82,20 +83,10 @@ export interface TemplateVersionEditorProps {
defaultTab?: Tab;
provisionerTags: Record<string, string>;
onUpdateProvisionerTags: (tags: Record<string, string>) => 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<TemplateVersionEditorProps> = ({
disablePreview,
disableUpdate,
@@ -120,6 +111,8 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
defaultTab,
provisionerTags,
onUpdateProvisionerTags,
activePath,
onActivePathChange,
}) => {
const theme = useTheme();
const [selectedTab, setSelectedTab] = useState<Tab>(defaultTab);
@@ -128,9 +121,6 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
const [deleteFileOpen, setDeleteFileOpen] = useState<string>();
const [renameFileOpen, setRenameFileOpen] = useState<string>();
const [dirty, setDirty] = useState(false);
const [activePath, setActivePath] = useState<string | undefined>(() =>
findInitialFile(fileTree),
);
const triggerPreview = useCallback(() => {
onPreview(fileTree);
@@ -177,7 +167,9 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
previousVersion.current = templateVersion;
}, [templateVersion]);
const editorValue = getFileContent(activePath ?? "", fileTree) as string;
const editorValue = activePath ? getFileText(activePath, fileTree) : "";
const isEditorValueBinary =
typeof editorValue === "string" ? isBinaryData(editorValue) : false;
// Auto scroll
const buildLogsRef = useRef<HTMLDivElement>(null);
@@ -380,7 +372,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
checkExists={(path) => existsFile(path, fileTree)}
onConfirm={(path) => {
setFileTree((fileTree) => createFile(path, fileTree, ""));
setActivePath(path);
onActivePathChange(path);
setCreateFileOpen(false);
setDirty(true);
}}
@@ -395,7 +387,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
);
setDeleteFileOpen(undefined);
if (activePath === deleteFileOpen) {
setActivePath(undefined);
onActivePathChange(undefined);
}
setDirty(true);
}}
@@ -418,18 +410,18 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
setFileTree((fileTree) =>
moveFile(renameFileOpen, newPath, fileTree),
);
setActivePath(newPath);
onActivePathChange(newPath);
setRenameFileOpen(undefined);
setDirty(true);
}}
/>
</div>
<FileTreeView
<TemplateFileTree
fileTree={fileTree}
onDelete={(file) => setDeleteFileOpen(file)}
onSelect={(filePath) => {
if (!isFolder(filePath, fileTree)) {
setActivePath(filePath);
onActivePathChange(filePath);
}
}}
onRename={(file) => setRenameFileOpen(file)}
@@ -448,19 +440,60 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
>
<div css={{ flex: 1, overflowY: "auto" }} data-chromatic="ignore">
{activePath ? (
<MonacoEditor
value={editorValue}
path={activePath}
onChange={(value) => {
if (!activePath) {
return;
}
setFileTree((fileTree) =>
updateFile(activePath, value, fileTree),
);
setDirty(true);
}}
/>
isEditorValueBinary ? (
<div
role="alert"
css={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 40,
}}
>
<div
css={{
display: "flex",
flexDirection: "column",
alignItems: "center",
maxWidth: 420,
textAlign: "center",
}}
>
<WarningOutlined
css={{
fontSize: 48,
color: theme.roles.warning.fill.outline,
}}
/>
<p
css={{
margin: 0,
padding: 0,
marginTop: 24,
}}
>
The file is not displayed in the text editor because it
is either binary or uses an unsupported text encoding.
</p>
</div>
</div>
) : (
<MonacoEditor
value={editorValue}
path={activePath}
onChange={(value) => {
if (!activePath) {
return;
}
setFileTree((fileTree) =>
updateFile(activePath, value, fileTree),
);
setDirty(true);
}}
/>
)
) : (
<div>No file opened</div>
)}
@@ -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: () => <div />,
}));
const renderTemplateEditorPage = () => {
renderWithAuth(<TemplateVersionEditorPage />, {
route: `/templates/${MockTemplate.name}/versions/${MockTemplateVersion.name}/edit`,
@@ -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";
@@ -23,12 +23,9 @@ 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,
isAllowedFile,
} from "utils/templateVersion";
import { createTemplateVersionFileTree } from "utils/templateVersion";
import { displayError } from "components/GlobalSnackbar/utils";
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
@@ -40,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,
@@ -81,6 +76,27 @@ export const TemplateVersionEditorPage: FC = () => {
const [lastSuccessfulPublishedVersion, setLastSuccessfulPublishedVersion] =
useState<TemplateVersion>();
// 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`,
{ replace: true },
);
};
// Optimistically update the template version data job status to make the
// build action feels faster
const onBuildStart = () => {
@@ -98,8 +114,8 @@ export const TemplateVersionEditorPage: FC = () => {
};
const onBuildEnds = (newVersion: TemplateVersion) => {
setCurrentVersionName(newVersion.name);
queryClient.setQueryData(templateVersionOptions.queryKey, newVersion);
navigateToVersion(newVersion);
};
// Provisioner Tags
@@ -120,6 +136,8 @@ export const TemplateVersionEditorPage: FC = () => {
{templateQuery.data && templateVersionQuery.data && fileTree ? (
<TemplateVersionEditor
activePath={activePath}
onActivePathChange={onActivePathChange}
template={templateQuery.data}
templateVersion={templateVersionQuery.data}
defaultFileTree={fileTree}
@@ -159,17 +177,13 @@ export const TemplateVersionEditorPage: FC = () => {
...templateVersionQuery.data,
...data,
};
setCurrentVersionName(publishedVersion.name);
setIsPublishingDialogOpen(false);
setLastSuccessfulPublishedVersion(publishedVersion);
queryClient.setQueryData(
templateVersionOptions.queryKey,
publishedVersion,
);
navigate(
`/templates/${templateName}/versions/${publishedVersion.name}/edit`,
{ replace: true },
);
navigateToVersion(publishedVersion);
}}
isAskingPublishParameters={isPublishingDialogOpen}
isPublishing={publishVersionMutation.isLoading}
@@ -193,7 +207,6 @@ export const TemplateVersionEditorPage: FC = () => {
}
disableUpdate={
templateVersionQuery.data.job.status !== "succeeded" ||
templateVersionQuery.data.name === initialVersionName ||
templateVersionQuery.data.name ===
lastSuccessfulPublishedVersion?.name
}
@@ -253,7 +266,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");
});
}
@@ -330,39 +344,20 @@ const generateVersionFiles = async (
) => {
const tar = new TarWriter();
// 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,
});
}
}
}
// 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");
@@ -393,4 +388,16 @@ const publishVersion = async (options: {
return Promise.all(publishActions);
};
const findInitialFile = (fileTree: FileTree): string | undefined => {
let initialFile: string | undefined;
traverse(fileTree, (content, filename, path) => {
if (filename.endsWith(".tf")) {
initialFile = path;
}
});
return initialFile;
};
export default TemplateVersionEditorPage;
@@ -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();
});
});
@@ -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
}
@@ -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,
@@ -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<TemplateVersionPageViewProps> = ({
tab,
versionName,
templateName,
createWorkspaceUrl,
@@ -101,9 +98,10 @@ export const TemplateVersionPageView: FC<TemplateVersionPageViewProps> = ({
</Stats>
<TemplateFiles
tab={tab}
currentFiles={currentFiles}
baseFiles={baseFiles}
templateName={templateName}
versionName={versionName}
/>
</>
)}
Binary file not shown.
+8
View File
@@ -74,6 +74,14 @@ export const getFileContent = (path: string, fileTree: FileTree) => {
return get(fileTree, path.split("/")) as string | FileTree;
};
export const getFileText = (path: string, fileTree: FileTree) => {
const content = getFileContent(path, fileTree);
if (typeof content !== "string") {
throw new Error("File is not a text file");
}
return content;
};
export const isFolder = (path: string, fileTree: FileTree) => {
const content = getFileContent(path, fileTree);
return typeof content === "object";
+16 -27
View File
@@ -1,5 +1,7 @@
import { FileTree, createFile } from "./filetree";
import { TarReader } from "./tar";
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<string, string>;
@@ -11,41 +13,28 @@ 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;
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;
};
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<FileTree> => {
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;
};