diff --git a/biome.jsonc b/biome.jsonc index 51b6936f6e..4583f5465d 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -111,7 +111,6 @@ // "@mui/material/useMediaQuery": "Use Tailwind responsive classes or custom hook instead.", // "@mui/system": "Use Tailwind CSS instead.", // "@mui/utils": "Use native alternatives or utility libraries instead.", - // "@mui/x-tree-view": "Use a Tailwind-compatible alternative.", // "@emotion/css": "Use Tailwind CSS instead.", // "@emotion/react": "Use Tailwind CSS instead.", "@emotion/styled": "Use Tailwind CSS instead.", diff --git a/site/package.json b/site/package.json index 855a4faecc..acac10abdf 100644 --- a/site/package.json +++ b/site/package.json @@ -59,7 +59,6 @@ "@monaco-editor/react": "4.7.0", "@mui/material": "5.18.0", "@mui/system": "5.18.0", - "@mui/x-tree-view": "7.29.10", "@novnc/novnc": "^1.5.0", "@pierre/diffs": "1.1.0-beta.19", "@tanstack/react-query-devtools": "5.77.0", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index a64431cef0..1473dca2fb 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -79,9 +79,6 @@ importers: '@mui/system': specifier: 5.18.0 version: 5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2) - '@mui/x-tree-view': - specifier: 7.29.10 - version: 7.29.10(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2))(@mui/system@5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) '@novnc/novnc': specifier: ^1.5.0 version: 1.5.0 @@ -1255,28 +1252,6 @@ packages: '@types/react': optional: true - '@mui/x-internals@7.29.0': - resolution: {integrity: sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA==, tarball: https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz} - engines: {node: '>=14.0.0'} - peerDependencies: - react: ^17.0.0 || ^18.0.0 || ^19.0.0 - - '@mui/x-tree-view@7.29.10': - resolution: {integrity: sha512-/ZcM582yIaQN2PmadIlQYRJzc3yXV7bh463J4GHtTmFw+PEjzUfzETBWe3VxmU3EPgIFzVQPjqAAJwylmQSJOg==, tarball: https://registry.npmjs.org/@mui/x-tree-view/-/x-tree-view-7.29.10.tgz} - engines: {node: '>=14.0.0'} - peerDependencies: - '@emotion/react': ^11.9.0 - '@emotion/styled': ^11.8.1 - '@mui/material': ^5.15.14 || ^6.0.0 || ^7.0.0 - '@mui/system': ^5.15.14 || ^6.0.0 || ^7.0.0 - react: ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/react': - optional: true - '@emotion/styled': - optional: true - '@napi-rs/wasm-runtime@1.0.7': resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==, tarball: https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz} @@ -7316,33 +7291,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 - '@mui/x-internals@7.29.0(@types/react@19.2.7)(react@19.2.2)': - dependencies: - '@babel/runtime': 7.26.10 - '@mui/utils': 5.17.1(@types/react@19.2.7)(react@19.2.2) - react: 19.2.2 - transitivePeerDependencies: - - '@types/react' - - '@mui/x-tree-view@7.29.10(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2))(@mui/system@5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)': - dependencies: - '@babel/runtime': 7.26.10 - '@mui/material': 5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) - '@mui/system': 5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2) - '@mui/utils': 5.17.1(@types/react@19.2.7)(react@19.2.2) - '@mui/x-internals': 7.29.0(@types/react@19.2.7)(react@19.2.2) - '@types/react-transition-group': 4.4.12(@types/react@19.2.7) - clsx: 2.1.1 - prop-types: 15.8.1 - react: 19.2.2 - react-dom: 19.2.2(react@19.2.2) - react-transition-group: 4.4.5(react-dom@19.2.2(react@19.2.2))(react@19.2.2) - optionalDependencies: - '@emotion/react': 11.14.0(@types/react@19.2.7)(react@19.2.2) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2) - transitivePeerDependencies: - - '@types/react' - '@napi-rs/wasm-runtime@1.0.7': dependencies: '@emnapi/core': 1.7.1 diff --git a/site/src/modules/templates/TemplateFiles/TemplateFileIcon.tsx b/site/src/modules/templates/TemplateFiles/TemplateFileIcon.tsx index eeccd83636..2209ece300 100644 --- a/site/src/modules/templates/TemplateFiles/TemplateFileIcon.tsx +++ b/site/src/modules/templates/TemplateFiles/TemplateFileIcon.tsx @@ -1,10 +1,4 @@ -import { - BracesIcon, - FileCodeIcon, - FileIcon, - FolderIcon, - TerminalIcon, -} from "lucide-react"; +import { BracesIcon, FileCodeIcon, FileIcon, TerminalIcon } from "lucide-react"; import type { ComponentProps, ElementType, FC } from "react"; import { DockerIcon } from "#/components/Icons/DockerIcon"; @@ -47,13 +41,7 @@ const FileTypeMarkdown: FC> = (props) => ( ); -export const getTemplateFileIcon = ( - filename: string, - isFolder: boolean, -): ElementType => { - if (isFolder) { - return FolderIcon; - } +export const getTemplateFileIcon = (filename: string): ElementType => { if (filename.endsWith(".tf")) { return FileTypeTerraform; } diff --git a/site/src/modules/templates/TemplateFiles/TemplateFileTree.stories.tsx b/site/src/modules/templates/TemplateFiles/TemplateFileTree.stories.tsx index ce210ab3a4..f366352ab2 100644 --- a/site/src/modules/templates/TemplateFiles/TemplateFileTree.stories.tsx +++ b/site/src/modules/templates/TemplateFiles/TemplateFileTree.stories.tsx @@ -1,23 +1,34 @@ -import { useTheme } from "@emotion/react"; import type { Meta, StoryObj } from "@storybook/react-vite"; +import { action } from "storybook/actions"; import { chromatic } from "#/testHelpers/chromatic"; import type { FileTree } from "#/utils/filetree"; import { TemplateFileTree } from "./TemplateFileTree"; const fileTree: FileTree = { - "main.tf": "resource aws_instance my_instance {}", - "variables.tf": "variable my_var {}", - "outputs.tf": "output my_output {}", - "README.md": "# Example\n\nThis is an example.", - "install.sh": "#!/bin/bash\necho 'Installing...'", - "config.json": '{"name": "example"}', - "docker-compose.yml": "version: '3'", - Dockerfile: "FROM ubuntu:latest", - "app.py": "print('Hello')", - folder: { - "nested.tf": "resource aws_instance my_instance {}", - "data.csv": "col1,col2\n1,2", + "boundary-config.yaml": "- secure: yup", + "configure-chrome-flags.sh": "#!/bin/bash", + Dockerfile: "FROM ubuntu:26.04", + files: { + etc: { + apt: { + "sources.list.d": { + "ppa.list": "wow you found my secret hiding spot", + }, + }, + }, + usr: { + local: { + bin: { + gh: "#!/bin/bash", + }, + }, + }, }, + ".env": "TOKEN=1", + "main.tf": 'resource "wibble" "wobble" {}', + Makefile: "build:\n\tgo build\n.PHONY: build", + "README.md": "# Congratulations on being able to read", + "update-keys.sh": "#!/bin/bash", }; const meta: Meta = { @@ -27,18 +38,13 @@ const meta: Meta = { args: { fileTree, activePath: "main.tf", + onDelete: action("delete"), + onRename: action("rename"), }, decorators: [ (Story) => { - const theme = useTheme(); return ( -
+
); diff --git a/site/src/modules/templates/TemplateFiles/TemplateFileTree.tsx b/site/src/modules/templates/TemplateFiles/TemplateFileTree.tsx index 25a8f36584..043bdb4d57 100644 --- a/site/src/modules/templates/TemplateFiles/TemplateFileTree.tsx +++ b/site/src/modules/templates/TemplateFiles/TemplateFileTree.tsx @@ -1,9 +1,24 @@ -import { css } from "@emotion/react"; -import Menu from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; -import { SimpleTreeView, TreeItem } from "@mui/x-tree-view"; -import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; -import { type CSSProperties, type FC, type JSX, useState } from "react"; +import { + EllipsisIcon, + FolderIcon, + FolderOpenIcon, + PencilIcon, + Trash2Icon, +} from "lucide-react"; +import { type FC, type JSX, useState } from "react"; +import { Button } from "#/components/Button/Button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "#/components/Collapsible/Collapsible"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "#/components/DropdownMenu/DropdownMenu"; +import { cn } from "#/utils/cn"; import type { FileTree } from "#/utils/filetree"; import { getTemplateFileIcon } from "./TemplateFileIcon"; @@ -25,12 +40,6 @@ function compareFileTreeEntries( return isFolder(contentA) ? -1 : 1; } -type ContextMenu = { - path: string; - clientX: number; - clientY: number; -}; - interface TemplateFilesTreeProps { onSelect: (path: string) => void; onDelete?: (path: string) => void; @@ -53,16 +62,15 @@ export const TemplateFileTree: FC = ({ onSelect, Label, }) => { - const [contextMenu, setContextMenu] = useState(); - const buildTreeItems = ( label: string, filename: string, content?: FileTree | string, parentPath?: string, + depth = 0, ): JSX.Element => { const currentPath = parentPath ? `${parentPath}/${filename}` : filename; - // Used to group empty folders in one single label like VSCode does + // Used to group empty folders in one single label like VSCode does. const shouldGroupFolder = isFolder(content) && Object.keys(content).length === 1 && @@ -77,175 +85,220 @@ export const TemplateFileTree: FC = ({ firstChildFileName, child, currentPath, + depth, ); } - const templateFileIcon = getTemplateFileIcon(filename, isFolder(content)); + const isActive = currentPath === activePath; - return ( - - ) : ( - label - ) - } - css={(theme) => css` - overflow: hidden; - user-select: none; + const labelContent = Label ? ( + + + ); + } + + const Icon = getTemplateFileIcon(filename); + + return ( + } + isHidden={isHiddenFile} + isActive={isActive} + depth={depth} + onClick={() => onSelect(currentPath)} + onDelete={onDelete && (() => onDelete(currentPath))} + onRename={onRename && (() => onRename(currentPath))} + /> ); }; return ( - +
{Object.entries(fileTree) .sort(compareFileTreeEntries) .map(([filename, child]) => buildTreeItems(filename, filename, child))} - - 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.path); - setContextMenu(undefined); - }} - > - Rename - - { - if (!contextMenu) { - return; - } - onDelete?.(contextMenu.path); - setContextMenu(undefined); - }} - > - Delete - - - +
); }; -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("/")); +interface TreeNodeProps { + label: React.ReactNode; + icon: React.ReactNode; + isHidden: boolean; + isActive: boolean; + depth: number; + onClick: () => void; + onDelete?: () => void; + onRename?: () => void; +} + +const nodeClasses = + "flex-grow flex h-8 cursor-pointer select-none items-center gap-2 " + + "border-none bg-transparent px-4 text-[13px] text-left " + + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link focus-visible:ring-inset"; + +const FileNode: FC = ({ + label, + icon, + isHidden, + isActive, + depth, + onClick, + onDelete, + onRename, +}) => { + return ( +
+ + +
+ ); +}; + +interface FolderNodeProps extends Omit { + children: React.ReactNode; +} + +const FolderNode: FC = ({ + label, + isHidden, + isActive, + depth, + onClick, + onDelete, + onRename, + children, +}) => { + const [open, setOpen] = useState(true); + + return ( + +
+ + + + +
+ {children} +
+ ); +}; + +interface MoreMenuProps { + onRename?: () => void; + onDelete?: () => void; +} + +const MoreMenu: FC = ({ onRename, onDelete }) => { + if (!onRename && !onDelete) { + return null; } - return result; + + return ( + + + + + + {onRename && ( + + + Rename + + )} + {onDelete && ( + + + Delete… + + )} + + + ); }; diff --git a/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx b/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx index fd59e16b18..a4476ec663 100644 --- a/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx +++ b/site/src/modules/templates/TemplateFiles/TemplateFiles.tsx @@ -93,7 +93,7 @@ export const TemplateFiles: FC = ({ {Object.keys(currentFiles) .sort((a, b) => a.localeCompare(b)) .map((filename) => { - const TemplateFileIcon = getTemplateFileIcon(filename, false); + const TemplateFileIcon = getTemplateFileIcon(filename); const info = fileInfo(filename); return (