diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2db91a078afcb..45bfceb8dae6a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -6380,10 +6380,12 @@ const docTemplate = `{ "codersdk.Experiment": { "type": "string", "enum": [ - "authz_querier" + "authz_querier", + "template_editor" ], "x-enum-varnames": [ - "ExperimentAuthzQuerier" + "ExperimentAuthzQuerier", + "ExperimentTemplateEditor" ] }, "codersdk.Feature": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 2524a5170e4d6..bb45aa6f3b5c2 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5704,8 +5704,8 @@ }, "codersdk.Experiment": { "type": "string", - "enum": ["authz_querier"], - "x-enum-varnames": ["ExperimentAuthzQuerier"] + "enum": ["authz_querier", "template_editor"], + "x-enum-varnames": ["ExperimentAuthzQuerier", "ExperimentTemplateEditor"] }, "codersdk.Feature": { "type": "object", diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 00b6238b63cd8..da4a68e6753a8 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -421,6 +421,10 @@ const ( // interface for all RBAC operations. NOT READY FOR PRODUCTION USE. ExperimentAuthzQuerier Experiment = "authz_querier" + // ExperimentTemplateEditor is an internal experiment that enables the template editor + // for all users. + ExperimentTemplateEditor Experiment = "template_editor" + // Add new experiments here! // ExperimentExample Experiment = "example" ) @@ -430,7 +434,7 @@ var ( // users to opt-in to via --experimental='*'. // Experiments that are not ready for consumption by all users should // not be included here and will be essentially hidden. - ExperimentsAll = Experiments{} + ExperimentsAll = Experiments{ExperimentTemplateEditor} ) // Experiments is a list of experiments that are enabled for the deployment. diff --git a/docs/api/schemas.md b/docs/api/schemas.md index daf9939fe4a87..c04856fce4ad3 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2770,9 +2770,10 @@ CreateParameterRequest is a structure used to create a new parameter value for a #### Enumerated Values -| Value | -| --------------- | -| `authz_querier` | +| Value | +| ----------------- | +| `authz_querier` | +| `template_editor` | ## codersdk.Feature diff --git a/site/package.json b/site/package.json index c4ed02b1e4dfa..255deb0c1521c 100644 --- a/site/package.json +++ b/site/package.json @@ -67,6 +67,7 @@ "react-chartjs-2": "4.3.1", "react-color": "2.19.3", "react-dom": "18.2.0", + "react-headless-tabs": "^6.0.3", "react-helmet-async": "1.3.0", "react-i18next": "12.1.1", "react-markdown": "8.0.3", @@ -75,6 +76,7 @@ "remark-gfm": "3.0.1", "rollup-plugin-visualizer": "5.9.0", "sourcemapped-stacktrace": "1.1.11", + "tar-js": "^0.3.0", "ts-prune": "0.10.3", "tzdata": "1.0.30", "ua-parser-js": "1.0.33", @@ -125,6 +127,7 @@ "jest-esm-transformer": "^1.0.0", "jest-runner-eslint": "1.1.0", "jest-websocket-mock": "2.4.0", + "monaco-editor": "^0.34.1", "msw": "0.47.0", "prettier": "2.8.1", "resize-observer": "1.0.4", diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 08b1973b999b6..a1cd2ab296f0f 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -108,6 +108,12 @@ const GitAuthPage = lazy(() => import("./pages/GitAuthPage/GitAuthPage")) const TemplateVersionPage = lazy( () => import("./pages/TemplateVersionPage/TemplateVersionPage"), ) +const TemplateVersionEditorPage = lazy( + () => + import( + "./pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage" + ), +) const StarterTemplatesPage = lazy( () => import("./pages/StarterTemplatesPage/StarterTemplatesPage"), ) @@ -155,7 +161,13 @@ export const AppRouter: FC = () => { } /> } /> - } /> + + } /> + } + /> + diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 2007b40a360fa..8532101f6f49e 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -309,6 +309,17 @@ export const createTemplate = async ( return response.data } +export const updateActiveTemplateVersion = async ( + templateId: string, + data: TypesGen.UpdateActiveTemplateVersion, +): Promise => { + const response = await axios.patch( + `/api/v2/templates/${templateId}/versions`, + data, + ) + return response.data +} + export const updateTemplateMeta = async ( templateId: string, data: TypesGen.UpdateTemplateMeta, @@ -433,6 +444,15 @@ export const cancelWorkspaceBuild = async ( return response.data } +export const cancelTemplateVersionBuild = async ( + templateVersionId: TypesGen.TemplateVersion["id"], +): Promise => { + const response = await axios.patch( + `/api/v2/templateversions/${templateVersionId}/cancel`, + ) + return response.data +} + export const createUser = async ( user: TypesGen.CreateUserRequest, ): Promise => { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4396218ebde41..888d8e35656f7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1082,8 +1082,8 @@ export const Entitlements: Entitlement[] = [ ] // From codersdk/deployment.go -export type Experiment = "authz_querier" -export const Experiments: Experiment[] = ["authz_querier"] +export type Experiment = "authz_querier" | "template_editor" +export const Experiments: Experiment[] = ["authz_querier", "template_editor"] // From codersdk/deployment.go export type FeatureName = diff --git a/site/src/components/Dashboard/DashboardLayout.tsx b/site/src/components/Dashboard/DashboardLayout.tsx index 920c3cfe30c31..372297f931e47 100644 --- a/site/src/components/Dashboard/DashboardLayout.tsx +++ b/site/src/components/Dashboard/DashboardLayout.tsx @@ -71,6 +71,5 @@ const useStyles = makeStyles((theme) => ({ }, siteContent: { flex: 1, - paddingBottom: theme.spacing(10), }, })) diff --git a/site/src/components/Logs/Logs.tsx b/site/src/components/Logs/Logs.tsx index 6b70dd552c849..b1a101ecb5890 100644 --- a/site/src/components/Logs/Logs.tsx +++ b/site/src/components/Logs/Logs.tsx @@ -13,10 +13,12 @@ interface Line { export interface LogsProps { lines: Line[] + hideTimestamps?: boolean className?: string } export const Logs: FC> = ({ + hideTimestamps, lines, className = "", }) => { @@ -27,10 +29,14 @@ export const Logs: FC> = ({
{lines.map((line, idx) => (
- - {dayjs(line.time).format(`HH:mm:ss.SSS`)} - -      + {!hideTimestamps && ( + <> + + {dayjs(line.time).format(`HH:mm:ss.SSS`)} + +      + + )} {line.output}
))} diff --git a/site/src/components/SyntaxHighlighter/coderTheme.ts b/site/src/components/SyntaxHighlighter/coderTheme.ts index d525ccdddf033..cbeb0a6393297 100644 --- a/site/src/components/SyntaxHighlighter/coderTheme.ts +++ b/site/src/components/SyntaxHighlighter/coderTheme.ts @@ -2,10 +2,11 @@ import { Theme, useTheme } from "@material-ui/core/styles" import { useMonaco } from "@monaco-editor/react" import { useEffect, useState } from "react" import { hslToHex } from "util/colors" +import { editor } from "monaco-editor" // Theme based on https://github.com/brijeshb42/monaco-themes/blob/master/themes/Dracula.json // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The theme is not typed -export const coderTheme = (theme: Theme): Record => ({ +export const coderTheme = (theme: Theme): editor.IStandaloneThemeData => ({ base: "vs-dark", inherit: true, rules: [ diff --git a/site/src/components/TemplateLayout/TemplateLayout.tsx b/site/src/components/TemplateLayout/TemplateLayout.tsx index eb8644e242ac3..fa02caff789ac 100644 --- a/site/src/components/TemplateLayout/TemplateLayout.tsx +++ b/site/src/components/TemplateLayout/TemplateLayout.tsx @@ -31,6 +31,7 @@ import { Avatar } from "components/Avatar/Avatar" const Language = { settingsButton: "Settings", + editButton: "Edit", createButton: "Create workspace", noDescription: "", } diff --git a/site/src/components/TemplateVersionEditor/FileDialog.tsx b/site/src/components/TemplateVersionEditor/FileDialog.tsx new file mode 100644 index 0000000000000..3d7fe0f18cb44 --- /dev/null +++ b/site/src/components/TemplateVersionEditor/FileDialog.tsx @@ -0,0 +1,155 @@ +import TextField from "@material-ui/core/TextField" +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" +import { Stack } from "components/Stack/Stack" +import { ChangeEvent, FC, useState } from "react" +import Typography from "@material-ui/core/Typography" + +export const CreateFileDialog: FC<{ + onClose: () => void + checkExists: (path: string) => boolean + onConfirm: (path: string) => void + open: boolean +}> = ({ checkExists, onClose, onConfirm, open }) => { + const [pathValue, setPathValue] = useState("") + const [error, setError] = useState("") + const handleChange = (event: ChangeEvent) => { + setPathValue(event.target.value) + } + const handleConfirm = () => { + if (pathValue === "") { + setError("You must enter a path!") + return + } + if (checkExists(pathValue)) { + setError("File already exists") + return + } + onConfirm(pathValue) + setPathValue("") + } + + return ( + { + onClose() + setPathValue("") + }} + onConfirm={handleConfirm} + hideCancel={false} + type="success" + cancelText="Cancel" + confirmText="Create" + title="Create File" + description={ + + + Specify the path to a file to be created. This path can contain + slashes too! + + { + if (event.key === "Enter") { + handleConfirm() + } + }} + helperText={error} + name="file-path" + autoComplete="off" + id="file-path" + placeholder="main.tf" + value={pathValue} + onChange={handleChange} + label="File Path" + /> + + } + /> + ) +} + +export const DeleteFileDialog: FC<{ + onClose: () => void + onConfirm: () => void + open: boolean + filename: string +}> = ({ onClose, onConfirm, open, filename }) => { + return ( + + ) +} + +export const RenameFileDialog: FC<{ + onClose: () => void + onConfirm: (filename: string) => void + checkExists: (path: string) => boolean + open: boolean + filename: string +}> = ({ checkExists, onClose, onConfirm, open, filename }) => { + const [pathValue, setPathValue] = useState(filename) + const [error, setError] = useState("") + const handleChange = (event: ChangeEvent) => { + setPathValue(event.target.value) + } + const handleConfirm = () => { + if (pathValue === "") { + setError("You must enter a path!") + return + } + if (checkExists(pathValue)) { + setError("File already exists") + return + } + onConfirm(pathValue) + setPathValue("") + } + + return ( + { + onClose() + setPathValue("") + }} + onConfirm={handleConfirm} + hideCancel={false} + type="success" + cancelText="Cancel" + confirmText="Create" + title="Rename File" + description={ + + + Rename {`"${filename}"`} to something else. This path can contain + slashes too! + + { + if (event.key === "Enter") { + handleConfirm() + } + }} + helperText={error} + name="file-path" + autoComplete="off" + id="file-path" + placeholder="main.tf" + defaultValue={filename} + value={pathValue} + onChange={handleChange} + label="File Path" + /> + + } + /> + ) +} diff --git a/site/src/components/TemplateVersionEditor/FileTree.tsx b/site/src/components/TemplateVersionEditor/FileTree.tsx new file mode 100644 index 0000000000000..c3033c6564721 --- /dev/null +++ b/site/src/components/TemplateVersionEditor/FileTree.tsx @@ -0,0 +1,252 @@ +import { makeStyles } from "@material-ui/core/styles" +import ChevronRightIcon from "@material-ui/icons/ChevronRight" +import ExpandMoreIcon from "@material-ui/icons/ExpandMore" +import TreeView from "@material-ui/lab/TreeView" +import TreeItem from "@material-ui/lab/TreeItem" +import Menu from "@material-ui/core/Menu" +import MenuItem from "@material-ui/core/MenuItem" +import { FC, useMemo, useState } from "react" +import { TemplateVersionFiles } from "util/templateVersion" +import { DockerIcon } from "components/Icons/DockerIcon" + +export interface File { + path: string + content?: string + children: Record +} + +export const FileTree: FC<{ + onSelect: (file: File) => void + onDelete: (file: File) => void + onRename: (file: File) => void + files: TemplateVersionFiles + activeFile?: File +}> = ({ activeFile, files, onDelete, onRename, onSelect }) => { + const styles = useStyles() + const fileTree = useMemo>(() => { + const paths = Object.keys(files) + const roots: Record = {} + paths.forEach((path) => { + const pathParts = path.split("/") + const firstPart = pathParts.shift() + if (!firstPart) { + // Not possible! + return + } + let activeFile = roots[firstPart] + if (!activeFile) { + activeFile = { + path: firstPart, + children: {}, + } + roots[firstPart] = activeFile + } + while (pathParts.length > 0) { + const pathPart = pathParts.shift() + if (!pathPart) { + continue + } + if (!activeFile.children[pathPart]) { + activeFile.children[pathPart] = { + path: activeFile.path + "/" + pathPart, + children: {}, + } + } + activeFile = activeFile.children[pathPart] + } + activeFile.content = files[path] + activeFile.path = path + }) + return roots + }, [files]) + const [contextMenu, setContextMenu] = useState< + | { + file: File + clientX: number + clientY: number + } + | undefined + >() + + const buildTreeItems = (name: string, file: File): JSX.Element => { + let icon: JSX.Element | null = null + if (file.path.endsWith(".tf")) { + icon = + } + if (file.path.endsWith(".md")) { + icon = + } + if (file.path.endsWith("Dockerfile")) { + icon = + } + + return ( + { + if (file.content) { + onSelect(file) + } + }} + onContextMenu={(event) => { + event.preventDefault() + if (!file.content) { + return + } + setContextMenu( + contextMenu + ? undefined + : { + file: file, + clientY: event.clientY, + clientX: event.clientX, + }, + ) + }} + icon={icon} + > + {Object.entries(file.children || {}).map(([name, file]) => { + return buildTreeItems(name, file) + })} + + ) + } + + return ( + } + defaultExpandIcon={} + aria-label="Files" + className={styles.fileTree} + > + {Object.entries(fileTree).map(([name, file]) => { + return buildTreeItems(name, file) + })} + + setContextMenu(undefined)} + open={Boolean(contextMenu)} + anchorReference="anchorPosition" + anchorPosition={ + contextMenu + ? { + top: contextMenu.clientY, + left: contextMenu.clientX, + } + : undefined + } + anchorOrigin={{ + vertical: "top", + horizontal: "left", + }} + transformOrigin={{ + vertical: "top", + horizontal: "left", + }} + > + { + if (!contextMenu) { + return + } + onRename(contextMenu.file) + setContextMenu(undefined) + }} + > + Rename... + + { + if (!contextMenu) { + return + } + onDelete(contextMenu.file) + setContextMenu(undefined) + }} + > + Delete Permanently + + + + ) +} + +const useStyles = makeStyles((theme) => ({ + fileTree: {}, + fileTreeItem: { + overflow: "hidden", + userSelect: "none", + + "&:focus": { + "& > .MuiTreeItem-content": { + background: "rgba(255, 255, 255, 0.1)", + }, + }, + "& > .MuiTreeItem-content:hover": { + background: theme.palette.background.paperLight, + color: theme.palette.text.primary, + }, + + "& > .MuiTreeItem-content": { + padding: "1px 16px", + color: theme.palette.text.secondary, + + "& svg": { + width: 16, + height: 16, + }, + + "& > .MuiTreeItem-label": { + marginLeft: 4, + fontSize: 14, + color: "inherit", + }, + }, + + "&.active": { + background: theme.palette.background.paper, + + "& > .MuiTreeItem-content": { + color: theme.palette.text.primary, + }, + }, + }, + editor: { + flex: 1, + }, + preview: {}, +})) + +const FileTypeTerraform = () => ( + + file_type_terraform + + + + + +) + +const FileTypeMarkdown = () => ( + + + + + +) + +const FileTypeDockerfile = () => diff --git a/site/src/components/TemplateVersionEditor/MonacoEditor.tsx b/site/src/components/TemplateVersionEditor/MonacoEditor.tsx new file mode 100644 index 0000000000000..405a71885c24c --- /dev/null +++ b/site/src/components/TemplateVersionEditor/MonacoEditor.tsx @@ -0,0 +1,134 @@ +import { useTheme } from "@material-ui/core/styles" +import Editor from "@monaco-editor/react" +import { FC, useLayoutEffect, useMemo, useState } from "react" +import { MONOSPACE_FONT_FAMILY } from "theme/constants" +import { hslToHex } from "util/colors" +import type { editor } from "monaco-editor" + +export const MonacoEditor: FC<{ + value?: string + path?: string + onChange?: (value: string) => void +}> = ({ onChange, value, path }) => { + const theme = useTheme() + const [editor, setEditor] = useState() + useLayoutEffect(() => { + if (!editor) { + return + } + const resizeListener = () => { + editor.layout({ + height: 0, + width: 0, + }) + } + window.addEventListener("resize", resizeListener) + return () => { + window.removeEventListener("resize", resizeListener) + } + }, [editor]) + + 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]) + + return ( + { + if (onChange && newValue) { + onChange(newValue) + } + }} + onMount={(editor, monaco) => { + // This jank allows for Ctrl + Enter to work outside the editor. + // We use this keybind to trigger a build. + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Private type in Monaco! + ;(editor as any)._standaloneKeybindingService.addDynamicKeybinding( + `-editor.action.insertLineAfter`, + undefined, + () => { + // + }, + ) + + setEditor(editor) + + document.fonts.ready + .then(() => { + // Ensures that all text is measured properly. + // If this isn't done, there can be weird selection issues. + monaco.editor.remeasureFonts() + }) + .catch(() => { + // Not a biggie! + }) + + monaco.editor.defineTheme("min", { + base: "vs-dark", + inherit: true, + rules: [ + { + token: "comment", + foreground: "6B737C", + }, + { + token: "type", + foreground: "B392F0", + }, + { + token: "string", + foreground: "9DB1C5", + }, + { + token: "variable", + foreground: "BBBBBB", + }, + { + token: "identifier", + foreground: "B392F0", + }, + { + token: "delimiter.curly", + foreground: "EBB325", + }, + ], + colors: { + "editor.foreground": hslToHex(theme.palette.text.primary), + "editor.background": hslToHex(theme.palette.background.paper), + }, + }) + editor.updateOptions({ + theme: "min", + }) + }} + /> + ) +} diff --git a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.stories.tsx b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.stories.tsx new file mode 100644 index 0000000000000..78b5863bd74ce --- /dev/null +++ b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.stories.tsx @@ -0,0 +1,56 @@ +import { Story } from "@storybook/react" +import { + MockTemplate, + MockTemplateVersion, + MockTemplateVersionFiles, + MockWorkspaceBuildLogs, + MockWorkspaceResource, + MockWorkspaceResource2, + MockWorkspaceResource3, +} from "testHelpers/entities" +import { + TemplateVersionEditor, + TemplateVersionEditorProps, +} from "./TemplateVersionEditor" + +export default { + title: "components/TemplateVersionEditor", + component: TemplateVersionEditor, + parameters: { + layout: "fullscreen", + }, +} + +const Template: Story = ( + args: TemplateVersionEditorProps, +) => + +export const Example = Template.bind({}) +Example.args = { + template: MockTemplate, + templateVersion: MockTemplateVersion, + initialFiles: MockTemplateVersionFiles, +} + +export const Logs = Template.bind({}) + +Logs.args = { + template: MockTemplate, + templateVersion: MockTemplateVersion, + initialFiles: MockTemplateVersionFiles, + buildLogs: MockWorkspaceBuildLogs, +} + +export const Resources = Template.bind({}) + +Resources.args = { + template: MockTemplate, + templateVersion: MockTemplateVersion, + initialFiles: MockTemplateVersionFiles, + buildLogs: MockWorkspaceBuildLogs, + resources: [ + MockWorkspaceResource, + MockWorkspaceResource2, + MockWorkspaceResource3, + ], +} diff --git a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx new file mode 100644 index 0000000000000..ed328d67e28f5 --- /dev/null +++ b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx @@ -0,0 +1,530 @@ +import Button from "@material-ui/core/Button" +import IconButton from "@material-ui/core/IconButton" +import { makeStyles, Theme } from "@material-ui/core/styles" +import Tooltip from "@material-ui/core/Tooltip" +import CreateIcon from "@material-ui/icons/AddBox" +import BuildIcon from "@material-ui/icons/BuildOutlined" +import PreviewIcon from "@material-ui/icons/Visibility" +import { + ProvisionerJobLog, + Template, + TemplateVersion, + WorkspaceResource, +} from "api/typesGenerated" +import { Avatar } from "components/Avatar/Avatar" +import { AvatarData } from "components/AvatarData/AvatarData" +import { TemplateResourcesTable } from "components/TemplateResourcesTable/TemplateResourcesTable" +import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs" +import { FC, useCallback, useEffect, useRef, useState } from "react" +import { navHeight } from "theme/constants" +import { TemplateVersionFiles } from "util/templateVersion" +import { + CreateFileDialog, + DeleteFileDialog, + RenameFileDialog, +} from "./FileDialog" +import { FileTree } from "./FileTree" +import { MonacoEditor } from "./MonacoEditor" +import { + getStatus, + TemplateVersionStatusBadge, +} from "./TemplateVersionStatusBadge" + +interface File { + path: string + content?: string + children: Record +} + +export interface TemplateVersionEditorProps { + template: Template + templateVersion: TemplateVersion + initialFiles: TemplateVersionFiles + + buildLogs?: ProvisionerJobLog[] + resources?: WorkspaceResource[] + + disablePreview: boolean + disableUpdate: boolean + + onPreview: (files: TemplateVersionFiles) => void + onUpdate: () => void +} + +const topbarHeight = navHeight + +export const TemplateVersionEditor: FC = ({ + disablePreview, + disableUpdate, + template, + templateVersion, + initialFiles, + onPreview, + onUpdate, + buildLogs, + resources, +}) => { + const [selectedTab, setSelectedTab] = useState(() => { + // If resources are provided, show them by default! + // This is for Storybook! + return resources ? 1 : 0 + }) + const [files, setFiles] = useState(initialFiles) + const [createFileOpen, setCreateFileOpen] = useState(false) + const [deleteFileOpen, setDeleteFileOpen] = useState() + const [renameFileOpen, setRenameFileOpen] = useState() + const [activeFile, setActiveFile] = useState(() => { + const fileKeys = Object.keys(initialFiles) + for (let i = 0; i < fileKeys.length; i++) { + // Open a Terraform file by default! + if (fileKeys[i].endsWith(".tf")) { + return { + path: fileKeys[i], + content: initialFiles[fileKeys[i]], + children: {}, + } + } + } + }) + + const triggerPreview = useCallback(() => { + onPreview(files) + // Switch to the build log! + setSelectedTab(0) + }, [files, onPreview]) + + // Stop ctrl+s from saving files and make ctrl+enter trigger a preview. + useEffect(() => { + const keyListener = (event: KeyboardEvent) => { + if (!(navigator.platform.match("Mac") ? event.metaKey : event.ctrlKey)) { + return + } + switch (event.key) { + case "s": + // Prevent opening the save dialog! + event.preventDefault() + break + case "Enter": + event.preventDefault() + triggerPreview() + break + } + } + document.addEventListener("keydown", keyListener) + return () => { + document.removeEventListener("keydown", keyListener) + } + }, [files, triggerPreview]) + + // Automatically switch to the template preview tab when the build succeeds. + const previousVersion = useRef() + useEffect(() => { + if (!previousVersion.current) { + previousVersion.current = templateVersion + return + } + if ( + previousVersion.current.job.status === "running" && + templateVersion.job.status === "succeeded" + ) { + setSelectedTab(1) + setDirty(false) + } + previousVersion.current = templateVersion + }, [templateVersion]) + + const [dirty, setDirty] = useState(false) + const hasIcon = template.icon && template.icon !== "" + const templateVersionSucceeded = templateVersion.job.status === "succeeded" + const showBuildLogs = Boolean(buildLogs) + useEffect(() => { + window.dispatchEvent(new Event("resize")) + }, [showBuildLogs]) + const styles = useStyles({ + templateVersionSucceeded, + showBuildLogs, + }) + + return ( +
+
+
+ + ) + } + /> +
Used By: {template.active_user_count} developers
+
+ +
+
+ Build Status: + +
+ + + + + + + + +
+
+ +
+
+
+ Template Editor +
+ + { + setCreateFileOpen(true) + event.currentTarget.blur() + }} + > + + + +
+ { + setCreateFileOpen(false) + }} + checkExists={(path) => Boolean(files[path])} + onConfirm={(path) => { + setFiles({ + ...files, + [path]: "", + }) + setActiveFile({ + path, + content: "", + children: {}, + }) + setCreateFileOpen(false) + setDirty(true) + }} + /> + { + if (!deleteFileOpen) { + throw new Error("delete file must be set") + } + const deleted = { ...files } + delete deleted[deleteFileOpen.path] + setFiles(deleted) + setDeleteFileOpen(undefined) + if (activeFile?.path === deleteFileOpen.path) { + setActiveFile(undefined) + } + setDirty(true) + }} + open={Boolean(deleteFileOpen)} + onClose={() => setDeleteFileOpen(undefined)} + filename={deleteFileOpen?.path || ""} + /> + { + setRenameFileOpen(undefined) + }} + filename={renameFileOpen?.path || ""} + checkExists={(path) => Boolean(files[path])} + onConfirm={(newPath) => { + if (!renameFileOpen) { + return + } + const renamed = { ...files } + renamed[newPath] = renamed[renameFileOpen.path] + delete renamed[renameFileOpen.path] + setFiles(renamed) + renameFileOpen.path = newPath + setActiveFile(renameFileOpen) + setRenameFileOpen(undefined) + setDirty(true) + }} + /> +
+ setDeleteFileOpen(file)} + onSelect={(file) => setActiveFile(file)} + onRename={(file) => setRenameFileOpen(file)} + activeFile={activeFile} + /> +
+ +
+
+ {activeFile ? ( + { + if (!activeFile) { + return + } + setFiles({ + ...files, + [activeFile.path]: value, + }) + setDirty(true) + }} + /> + ) : ( +
No file opened
+ )} +
+ +
+
+ + + {!disableUpdate && ( + + )} +
+ +
+ {buildLogs && ( + + )} + {templateVersion.job.error && ( +
+ {templateVersion.job.error} +
+ )} +
+ +
+ {resources && ( + r.workspace_transition === "start", + )} + /> + )} +
+
+ + {templateVersionSucceeded && ( + <> +
+ + )} +
+
+
+ ) +} + +const useStyles = makeStyles< + Theme, + { + templateVersionSucceeded: boolean + showBuildLogs: boolean + } +>((theme) => ({ + root: { + height: `calc(100vh - ${navHeight}px)`, + background: theme.palette.background.default, + flex: 1, + display: "flex", + flexDirection: "column", + }, + topbar: { + padding: theme.spacing(2), + borderBottom: `1px solid ${theme.palette.divider}`, + display: "flex", + alignItems: "center", + justifyContent: "space-between", + height: topbarHeight, + }, + topbarSides: { + display: "flex", + alignItems: "center", + gap: 16, + }, + buildStatus: { + display: "flex", + alignItems: "center", + gap: 8, + }, + sidebarAndEditor: { + display: "flex", + flex: 1, + }, + sidebar: { + minWidth: 256, + }, + sidebarTitle: { + fontSize: 12, + textTransform: "uppercase", + padding: "8px 16px", + color: theme.palette.text.hint, + display: "flex", + alignItems: "center", + }, + sidebarActions: { + marginLeft: "auto", + "& svg": { + fill: theme.palette.text.hint, + }, + }, + editorPane: { + display: "grid", + width: "100%", + gridTemplateColumns: (props) => + props.showBuildLogs ? "0.6fr 0.4fr" : "1fr 0fr", + height: `calc(100vh - ${navHeight + topbarHeight}px)`, + overflow: "hidden", + }, + editor: { + flex: 1, + }, + panelWrapper: { + flex: 1, + display: "flex", + flexDirection: "column", + borderLeft: `1px solid ${theme.palette.divider}`, + overflowY: "auto", + }, + panel: { + "&.hidden": { + display: "none", + }, + }, + tabs: { + borderBottom: `1px solid ${theme.palette.divider}`, + display: "flex", + boxShadow: "#000000 0 6px 6px -6px inset", + + "& .MuiTab-root": { + padding: 0, + fontSize: 14, + textTransform: "none", + letterSpacing: "unset", + }, + }, + tab: { + cursor: "pointer", + padding: "8px 12px", + fontSize: 14, + background: "transparent", + fontFamily: "inherit", + border: 0, + color: theme.palette.text.hint, + transition: "150ms ease all", + display: "flex", + gap: 8, + alignItems: "center", + justifyContent: "center", + + "& svg": { + maxWidth: 16, + maxHeight: 16, + }, + + "&.active": { + color: "white", + background: theme.palette.background.paperLight, + }, + }, + tabBar: { + padding: "8px 16px", + position: "sticky", + top: 0, + background: theme.palette.background.default, + borderBottom: `1px solid ${theme.palette.divider}`, + color: theme.palette.text.hint, + textTransform: "uppercase", + fontSize: 12, + + "&.top": { + borderTop: `1px solid ${theme.palette.divider}`, + }, + }, + buildLogs: { + display: "flex", + flexDirection: "column-reverse", + overflowY: "auto", + }, + buildLogError: { + whiteSpace: "pre-wrap", + }, + resources: { + // padding: 16, + }, +})) diff --git a/site/src/components/TemplateVersionEditor/TemplateVersionStatusBadge.tsx b/site/src/components/TemplateVersionEditor/TemplateVersionStatusBadge.tsx new file mode 100644 index 0000000000000..5dc4c5872cb21 --- /dev/null +++ b/site/src/components/TemplateVersionEditor/TemplateVersionStatusBadge.tsx @@ -0,0 +1,66 @@ +import { TemplateVersion } from "api/typesGenerated" +import { FC, ReactNode } from "react" +import { PaletteIndex } from "theme/palettes" + +import CircularProgress from "@material-ui/core/CircularProgress" +import ErrorIcon from "@material-ui/icons/ErrorOutline" +import CheckIcon from "@material-ui/icons/CheckOutlined" +import { Pill } from "components/Pill/Pill" + +export const TemplateVersionStatusBadge: FC<{ + version: TemplateVersion +}> = ({ version }) => { + const { text, icon, type } = getStatus(version) + return +} + +const LoadingIcon: FC = () => { + return +} + +export const getStatus = ( + version: TemplateVersion, +): { + type?: PaletteIndex + text: string + icon: ReactNode +} => { + switch (version.job.status) { + case "running": + return { + type: "info", + text: "Running", + icon: , + } + case "pending": + return { + text: "Pending", + icon: , + type: "info", + } + case "canceling": + return { + type: "warning", + text: "Canceling", + icon: , + } + case "canceled": + return { + type: "warning", + text: "Canceled", + icon: , + } + case "failed": + return { + type: "error", + text: "Failed", + icon: , + } + case "succeeded": + return { + type: "success", + text: "Success", + icon: , + } + } +} diff --git a/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx b/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx index ea857defc75ec..7ba82f2b7075f 100644 --- a/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx +++ b/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx @@ -1,4 +1,4 @@ -import { makeStyles } from "@material-ui/core/styles" +import { makeStyles, Theme } from "@material-ui/core/styles" import dayjs from "dayjs" import { FC, Fragment } from "react" import { ProvisionerJobLog } from "../../api/typesGenerated" @@ -39,12 +39,21 @@ const getStageDurationInSeconds = (logs: ProvisionerJobLog[]) => { export interface WorkspaceBuildLogsProps { logs: ProvisionerJobLog[] + hideTimestamps?: boolean + + // If true, render different styles that fit the template editor pane + // a bit better. + templateEditorPane?: boolean } -export const WorkspaceBuildLogs: FC = ({ logs }) => { +export const WorkspaceBuildLogs: FC = ({ + hideTimestamps, + logs, + templateEditorPane, +}) => { const groupedLogsByStage = groupLogsByStage(logs) const stages = Object.keys(groupedLogsByStage) - const styles = useStyles() + const styles = useStyles({ templateEditorPane: Boolean(templateEditorPane) }) return (
@@ -69,7 +78,7 @@ export const WorkspaceBuildLogs: FC = ({ logs }) => {
)}
- {!isEmpty && } + {!isEmpty && } ) })} @@ -77,10 +86,16 @@ export const WorkspaceBuildLogs: FC = ({ logs }) => { ) } -const useStyles = makeStyles((theme) => ({ +const useStyles = makeStyles< + Theme, + { + templateEditorPane: boolean + } +>((theme) => ({ logs: { border: `1px solid ${theme.palette.divider}`, - borderRadius: theme.shape.borderRadius, + borderRadius: (props) => + props.templateEditorPane ? "0px" : theme.shape.borderRadius, fontFamily: MONOSPACE_FONT_FAMILY, }, diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx new file mode 100644 index 0000000000000..f4e6596d25921 --- /dev/null +++ b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx @@ -0,0 +1,73 @@ +import { useMachine } from "@xstate/react" +import { TemplateVersionEditor } from "components/TemplateVersionEditor/TemplateVersionEditor" +import { useOrganizationId } from "hooks/useOrganizationId" +import { FC } from "react" +import { Helmet } from "react-helmet-async" +import { useParams } from "react-router-dom" +import { pageTitle } from "util/page" +import { templateVersionMachine } from "xServices/templateVersion/templateVersionXService" +import { templateVersionEditorMachine } from "xServices/templateVersionEditor/templateVersionEditorXService" + +type Params = { + version: string + template: string +} + +export const TemplateVersionEditorPage: FC = () => { + const { version: versionName, template: templateName } = useParams() as Params + const orgId = useOrganizationId() + const [versionState] = useMachine(templateVersionMachine, { + context: { templateName, versionName, orgId }, + }) + const [editorState, sendEvent] = useMachine(templateVersionEditorMachine, { + context: { orgId }, + }) + + return ( + <> + + {pageTitle(`${templateName} ยท Template Editor`)} + + + {versionState.context.template && + versionState.context.currentFiles && + versionState.context.currentVersion && ( + { + if (!versionState.context.template) { + throw new Error("no template") + } + // Send a cancel just in case a version is already being created! + sendEvent({ + type: "CANCEL_VERSION", + }) + sendEvent({ + type: "CREATE_VERSION", + files: files, + templateId: versionState.context.template.id, + }) + }} + onUpdate={() => { + sendEvent({ + type: "UPDATE_ACTIVE_VERSION", + }) + }} + disablePreview={editorState.hasTag("loading")} + disableUpdate={ + editorState.hasTag("loading") || + editorState.context.version?.job.status !== "succeeded" + } + resources={editorState.context.resources} + buildLogs={editorState.context.buildLogs} + /> + )} + + ) +} + +export default TemplateVersionEditorPage diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionPage.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionPage.tsx index ed836d58fd79b..b83ff5cbc10c1 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionPage.tsx +++ b/site/src/pages/TemplateVersionPage/TemplateVersionPage.tsx @@ -1,4 +1,5 @@ import { useMachine } from "@xstate/react" +import { useDashboard } from "components/Dashboard/DashboardProvider" import { useOrganizationId } from "hooks/useOrganizationId" import { useTab } from "hooks/useTab" import { FC } from "react" @@ -22,6 +23,7 @@ export const TemplateVersionPage: FC = () => { }) const tab = useTab("file", "0") const { t } = useTranslation("templateVersionPage") + const dashboard = useDashboard() return ( <> @@ -36,6 +38,7 @@ export const TemplateVersionPage: FC = () => { versionName={versionName} templateName={templateName} tab={tab} + canEdit={dashboard.experiments.includes("template_editor")} /> ) diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionPageView.stories.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionPageView.stories.tsx index caefc6a57261a..f7cf56cc99d30 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionPageView.stories.tsx +++ b/site/src/pages/TemplateVersionPage/TemplateVersionPageView.stories.tsx @@ -41,6 +41,7 @@ const defaultArgs = { tab, templateName: MockTemplate.name, versionName: MockTemplateVersion.name, + canEdit: true, context: { orgId: MockOrganization.id, versionName: MockTemplateVersion.name, diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx index 144f24d0e557f..6b45d0da56bf3 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx +++ b/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx @@ -1,5 +1,9 @@ +import Button from "@material-ui/core/Button" +import Link from "@material-ui/core/Link" import { makeStyles } from "@material-ui/core/styles" +import EditIcon from "@material-ui/icons/Edit" import { AlertBanner } from "components/AlertBanner/AlertBanner" +import { DockerIcon } from "components/Icons/DockerIcon" import { MarkdownIcon } from "components/Icons/MarkdownIcon" import { TerraformIcon } from "components/Icons/TerraformIcon" import { Loader } from "components/Loader/Loader" @@ -11,16 +15,15 @@ import { } from "components/PageHeader/PageHeader" import { Stack } from "components/Stack/Stack" import { Stats, StatsItem } from "components/Stats/Stats" +import { SyntaxHighlighter } from "components/SyntaxHighlighter/SyntaxHighlighter" import { UseTabResult } from "hooks/useTab" import { FC } from "react" import { useTranslation } from "react-i18next" -import { Link } from "react-router-dom" +import { Link as RouterLink } from "react-router-dom" import { combineClasses } from "util/combineClasses" import { createDayString } from "util/createDayString" -import { TemplateVersionMachineContext } from "xServices/templateVersion/templateVersionXService" import { TemplateVersionFiles } from "util/templateVersion" -import { SyntaxHighlighter } from "components/SyntaxHighlighter/SyntaxHighlighter" -import { DockerIcon } from "components/Icons/DockerIcon" +import { TemplateVersionMachineContext } from "xServices/templateVersion/templateVersionXService" const iconByExtension: Record = { tf: , @@ -101,6 +104,7 @@ export interface TemplateVersionPageViewProps { */ versionName: string templateName: string + canEdit: boolean tab: UseTabResult context: TemplateVersionMachineContext } @@ -110,13 +114,26 @@ export const TemplateVersionPageView: FC = ({ tab, versionName, templateName, + canEdit, }) => { const { currentFiles, error, currentVersion, previousFiles } = context const { t } = useTranslation("templateVersionPage") return ( - + + + + ) : undefined + } + > {t("header.caption")} {versionName} @@ -131,7 +148,9 @@ export const TemplateVersionPageView: FC = ({ {templateName} + + {templateName} + } /> data, }), + assignTemplate: assign({ + template: (_, { data }) => data.template, + }), assignVersions: assign({ currentVersion: (_, { data }) => data.currentVersion, previousVersion: (_, { data }) => data.previousVersion, @@ -106,6 +149,13 @@ export const templateVersionMachine = createMachine( previousVersion, } }, + loadTemplate: async ({ orgId, templateName }) => { + const template = await getTemplateByName(orgId, templateName) + + return { + template, + } + }, loadFiles: async ({ currentVersion, previousVersion }) => { if (!currentVersion) { throw new Error("Version is not defined") diff --git a/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts b/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts new file mode 100644 index 0000000000000..35103310196c0 --- /dev/null +++ b/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts @@ -0,0 +1,260 @@ +import { + ProvisionerJobLog, + TemplateVersion, + UploadResponse, + WorkspaceResource, +} from "api/typesGenerated" +import { assign, createMachine } from "xstate" +import * as API from "api/api" +import { TemplateVersionFiles } from "util/templateVersion" +import * as Tar from "tar-js" + +export interface CreateVersionData { + file: File +} + +export interface TemplateVersionEditorMachineContext { + orgId: string + + templateId?: string + files?: TemplateVersionFiles + uploadResponse?: UploadResponse + version?: TemplateVersion + resources?: WorkspaceResource[] + buildLogs?: ProvisionerJobLog[] +} + +export const templateVersionEditorMachine = createMachine( + { + predictableActionArguments: true, + id: "templateVersionEditor", + schema: { + context: {} as TemplateVersionEditorMachineContext, + events: {} as + | { + type: "CREATE_VERSION" + files: TemplateVersionFiles + templateId: string + } + | { type: "CANCEL_VERSION" } + | { type: "ADD_BUILD_LOG"; log: ProvisionerJobLog } + | { type: "UPDATE_ACTIVE_VERSION" }, + services: {} as { + createBuild: { + data: TemplateVersion + } + cancelBuild: { + data: TemplateVersion + } + }, + }, + initial: "idle", + states: { + idle: { + on: { + CREATE_VERSION: { + actions: ["assignCreateBuild"], + target: "uploadTar", + }, + UPDATE_ACTIVE_VERSION: { + target: "updatingActiveVersion", + }, + }, + }, + updatingActiveVersion: { + tags: "loading", + invoke: { + id: "updateActiveVersion", + src: "updateActiveVersion", + onDone: { + target: "idle", + }, + }, + }, + uploadTar: { + tags: "loading", + invoke: { + id: "uploadTar", + src: "uploadTar", + onDone: { + target: "creatingBuild", + actions: ["assignUploadResponse"], + }, + }, + }, + creatingBuild: { + tags: "loading", + invoke: { + id: "createBuild", + src: "createBuild", + onDone: { + actions: ["assignBuild"], + target: "watchingBuildLogs", + }, + }, + }, + watchingBuildLogs: { + tags: "loading", + invoke: { + id: "watchBuildLogs", + src: "watchBuildLogs", + onDone: { + target: "fetchingVersion", + }, + }, + on: { + ADD_BUILD_LOG: { + actions: ["addBuildLog"], + }, + CANCEL_VERSION: { + actions: ["cancelBuild"], + target: "idle", + }, + CREATE_VERSION: { + actions: ["cancelBuild", "assignCreateBuild"], + target: "uploadTar", + }, + }, + }, + fetchingVersion: { + tags: "loading", + invoke: { + id: "fetchVersion", + src: "fetchVersion", + onDone: { + actions: ["assignBuild"], + target: "fetchResources", + }, + }, + }, + fetchResources: { + tags: "loading", + invoke: { + id: "getResources", + src: "getResources", + onDone: { + actions: ["assignResources"], + target: "idle", + }, + }, + }, + }, + }, + { + actions: { + assignCreateBuild: assign({ + files: (_, event) => event.files, + templateId: (_, event) => event.templateId, + buildLogs: [], + resources: [], + }), + assignResources: assign({ + resources: (_, event) => event.data, + }), + assignUploadResponse: assign({ + uploadResponse: (_, event) => event.data, + }), + assignBuild: assign({ + version: (_, event) => event.data, + }), + addBuildLog: assign({ + buildLogs: (context, event) => { + const previousLogs = context.buildLogs ?? [] + return [...previousLogs, event.log] + }, + // Instead of periodically fetching the version, + // we just assume the state is running after the first log. + // + // The machine fetches the version after the log stream ends anyways! + version: (context) => { + if (!context.version || context.buildLogs?.length !== 0) { + return context.version + } + return { + ...context.version, + job: { + ...context.version.job, + status: "running", + }, + } + }, + }), + }, + services: { + uploadTar: (ctx) => { + if (!ctx.files) { + throw new Error("files must be set") + } + const tar = new Tar() + let out: Uint8Array = new Uint8Array() + Object.entries(ctx.files).forEach(([path, content]) => { + out = tar.append(path, content) + }) + return API.uploadTemplateFile(new File([out], "template.tar")) + }, + createBuild: (ctx) => { + if (!ctx.uploadResponse) { + throw new Error("no upload response") + } + return API.createTemplateVersion(ctx.orgId, { + provisioner: "terraform", + storage_method: "file", + tags: {}, + template_id: ctx.templateId, + file_id: ctx.uploadResponse.hash, + }) + }, + fetchVersion: (ctx) => { + if (!ctx.version) { + throw new Error("template version must be set") + } + return API.getTemplateVersion(ctx.version.id) + }, + watchBuildLogs: (ctx) => async (callback) => { + return new Promise((resolve, reject) => { + if (!ctx.version) { + return reject("version must be set") + } + const proto = location.protocol === "https:" ? "wss:" : "ws:" + const socket = new WebSocket( + `${proto}//${location.host}/api/v2/templateversions/${ctx.version?.id}/logs?follow=true`, + ) + socket.binaryType = "blob" + socket.addEventListener("message", (event) => { + callback({ type: "ADD_BUILD_LOG", log: JSON.parse(event.data) }) + }) + socket.addEventListener("error", () => { + reject(new Error("socket errored")) + }) + socket.addEventListener("close", () => { + // When the socket closes, logs have finished streaming! + resolve() + }) + }) + }, + getResources: (ctx) => { + if (!ctx.version) { + throw new Error("template version must be set") + } + return API.getTemplateVersionResources(ctx.version.id) + }, + cancelBuild: (ctx) => { + if (!ctx.version) { + throw new Error("template version must be set") + } + return API.cancelTemplateVersionBuild(ctx.version.id) + }, + updateActiveVersion: (ctx) => { + if (!ctx.templateId) { + throw new Error("template must be set") + } + if (!ctx.version) { + throw new Error("template version must be set") + } + return API.updateActiveTemplateVersion(ctx.templateId, { + id: ctx.version.id, + }) + }, + }, + }, +) diff --git a/site/yarn.lock b/site/yarn.lock index 783cabe62d606..e77b30bacfd31 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -10832,6 +10832,11 @@ mock-socket@^9.1.0: resolved "https://registry.yarnpkg.com/mock-socket/-/mock-socket-9.1.5.tgz#2c4e44922ad556843b6dfe09d14ed8041fa2cdeb" integrity sha512-3DeNIcsQixWHHKk6NdoBhWI4t1VMj5/HzfnI1rE/pLl5qKx7+gd4DNA07ehTaZ6MoUU053si6Hd+YtiM/tQZfg== +monaco-editor@^0.34.1: + version "0.34.1" + resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.34.1.tgz#1b75c4ad6bc4c1f9da656d740d98e0b850a22f87" + integrity sha512-FKc80TyiMaruhJKKPz5SpJPIjL+dflGvz4CpuThaPMc94AyN7SeC9HQ8hrvaxX7EyHdJcUY5i4D0gNyJj1vSZQ== + moo-color@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74" @@ -12207,6 +12212,11 @@ react-fast-compare@^3.2.0: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== +react-headless-tabs@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/react-headless-tabs/-/react-headless-tabs-6.0.3.tgz#c10c899befdaa4f3fb3a16422810254ecc8e668d" + integrity sha512-VBkN/P1Mpc/4WPcbl0Is+LvVzlqjWxX3G6kOxGQZX63zKaVpU0WP5NBS43JqqgZTG7nJ3JMYTHVXe79uTmRg7w== + react-helmet-async@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/react-helmet-async/-/react-helmet-async-1.3.0.tgz#7bd5bf8c5c69ea9f02f6083f14ce33ef545c222e" @@ -13658,6 +13668,11 @@ tapable@^2.1.1, tapable@^2.2.0: resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== +tar-js@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/tar-js/-/tar-js-0.3.0.tgz#6949aabfb0ba18bb1562ae51a439fd0f30183a17" + integrity sha512-9uqP2hJUZNKRkwPDe5nXxXdzo6w+BFBPq9x/tyi5/U/DneuSesO/HMb0y5TeWpfcv49YDJTs7SrrZeeu8ZHWDA== + tar-stream@^2.0.1: version "2.2.0" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"