mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): allow any file extension on template editor (#12000)
This commit is contained in:
@@ -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 {}",
|
||||
},
|
||||
},
|
||||
};
|
||||
+73
-15
@@ -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.
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user