{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)
+ })}
+
+
+
+ )
+}
+
+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 = () => (
+
+)
+
+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 &&