refactor: replace @mui/x-tree-view with simple tree components (#24266)

This commit is contained in:
Kayla はな
2026-04-17 13:01:34 -06:00
committed by GitHub
parent 3f6b40a833
commit 72c3563257
7 changed files with 250 additions and 257 deletions
-1
View File
@@ -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.",
-1
View File
@@ -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",
-52
View File
@@ -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
@@ -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<ComponentProps<"svg">> = (props) => (
</svg>
);
export const getTemplateFileIcon = (
filename: string,
isFolder: boolean,
): ElementType => {
if (isFolder) {
return FolderIcon;
}
export const getTemplateFileIcon = (filename: string): ElementType => {
if (filename.endsWith(".tf")) {
return FileTypeTerraform;
}
@@ -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<typeof TemplateFileTree> = {
@@ -27,18 +38,13 @@ const meta: Meta<typeof TemplateFileTree> = {
args: {
fileTree,
activePath: "main.tf",
onDelete: action("delete"),
onRename: action("rename"),
},
decorators: [
(Story) => {
const theme = useTheme();
return (
<div
css={{
maxWidth: 260,
borderRadius: 8,
border: `1px solid ${theme.palette.divider}`,
}}
>
<div className="max-w-[260px] rounded-lg border border-solid border-border-default">
<Story />
</div>
);
@@ -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<TemplateFilesTreeProps> = ({
onSelect,
Label,
}) => {
const [contextMenu, setContextMenu] = useState<ContextMenu | undefined>();
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<TemplateFilesTreeProps> = ({
firstChildFileName,
child,
currentPath,
depth,
);
}
const templateFileIcon = getTemplateFileIcon(filename, isFolder(content));
const isActive = currentPath === activePath;
return (
<TreeItem
slots={{ icon: templateFileIcon }}
itemId={currentPath}
key={currentPath}
label={
Label ? (
<Label
path={currentPath}
label={label}
filename={filename}
isFolder={isFolder(content)}
/>
) : (
label
)
}
css={(theme) => css`
overflow: hidden;
user-select: none;
const labelContent = Label ? (
<Label
path={currentPath}
label={label}
filename={filename}
isFolder={isFolder(content)}
/>
) : (
label
);
& > .MuiTreeItem-content {
border-radius: 0;
padding: 2px 16px;
color: ${
isHiddenFile
? theme.palette.text.disabled
: theme.palette.text.secondary
};
height: 32px;
& svg {
width: 12px;
height: 12px;
color: currentColor;
}
& > .MuiTreeItem-label {
margin-left: 4px;
font-size: 13px;
color: inherit;
white-space: nowrap;
}
&.Mui-selected {
color: ${theme.roles.active.text};
background: ${theme.roles.active.background};
}
&.Mui-focused {
box-shadow: inset 0 0 0 1px ${theme.palette.primary.main};
}
}
& .MuiTreeItem-group {
margin-left: 0;
position: relative;
// We need to find a better way to recursive padding here
& .MuiTreeItem-content {
padding-left: calc(8px + (var(--level) + 1) * 8px);
}
}
`}
onClick={() => {
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(
contextMenu
? undefined
: {
path: currentPath,
clientY: event.clientY,
clientX: event.clientX,
},
);
}}
style={
{
"--level": parentPath ? parentPath.split("/").length : 0,
} as CSSProperties
}
>
{isFolder(content) &&
Object.entries(content)
if (isFolder(content)) {
return (
<FolderNode
key={currentPath}
label={labelContent}
isHidden={isHiddenFile}
isActive={isActive}
depth={depth}
onClick={() => onSelect(currentPath)}
onDelete={onDelete && (() => onDelete(currentPath))}
onRename={onRename && (() => onRename(currentPath))}
>
{Object.entries(content)
.sort(compareFileTreeEntries)
.map(([filename, child]) =>
buildTreeItems(filename, filename, child, currentPath),
buildTreeItems(filename, filename, child, currentPath, depth + 1),
)}
</TreeItem>
</FolderNode>
);
}
const Icon = getTemplateFileIcon(filename);
return (
<FileNode
key={currentPath}
label={labelContent}
icon={<Icon className="size-3 shrink-0 text-current" />}
isHidden={isHiddenFile}
isActive={isActive}
depth={depth}
onClick={() => onSelect(currentPath)}
onDelete={onDelete && (() => onDelete(currentPath))}
onRename={onRename && (() => onRename(currentPath))}
/>
);
};
return (
<SimpleTreeView
slots={{ collapseIcon: ChevronDownIcon, expandIcon: ChevronRightIcon }}
aria-label="Files"
defaultExpandedItems={activePath ? expandablePaths(activePath) : []}
defaultSelectedItems={activePath}
>
<div>
{Object.entries(fileTree)
.sort(compareFileTreeEntries)
.map(([filename, child]) => buildTreeItems(filename, filename, child))}
<Menu
onClose={() => 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",
}}
>
<MenuItem
onClick={() => {
if (!contextMenu) {
return;
}
onRename?.(contextMenu.path);
setContextMenu(undefined);
}}
>
Rename
</MenuItem>
<MenuItem
onClick={() => {
if (!contextMenu) {
return;
}
onDelete?.(contextMenu.path);
setContextMenu(undefined);
}}
>
Delete
</MenuItem>
</Menu>
</SimpleTreeView>
</div>
);
};
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<TreeNodeProps> = ({
label,
icon,
isHidden,
isActive,
depth,
onClick,
onDelete,
onRename,
}) => {
return (
<div
className={cn(
"group/tree-item flex flex-row items-center justify-between",
"hover:bg-surface-secondary",
isActive && "bg-surface-sky",
)}
>
<button
type="button"
className={cn(
nodeClasses,
isHidden ? "text-content-secondary" : "text-content-primary",
isActive && "text-content-link",
)}
style={{ paddingLeft: `${(depth + 1) * 8 + 8}px` }}
onClick={onClick}
>
{icon}
<span className="truncate">{label}</span>
</button>
<MoreMenu onRename={onRename} onDelete={onDelete} />
</div>
);
};
interface FolderNodeProps extends Omit<TreeNodeProps, "icon"> {
children: React.ReactNode;
}
const FolderNode: FC<FolderNodeProps> = ({
label,
isHidden,
isActive,
depth,
onClick,
onDelete,
onRename,
children,
}) => {
const [open, setOpen] = useState(true);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div
className={cn(
"group/tree-item flex flex-row items-center justify-between",
"hover:bg-surface-secondary",
isActive && "bg-surface-sky",
)}
>
<CollapsibleTrigger asChild>
<button
type="button"
className={cn(
nodeClasses,
isHidden ? "text-content-secondary" : "text-content-primary",
isActive && "text-content-link",
)}
aria-expanded={open}
style={{ paddingLeft: `${(depth + 1) * 8 + 8}px` }}
onClick={onClick}
>
{open ? (
<FolderOpenIcon className="size-3 shrink-0 text-current" />
) : (
<FolderIcon className="size-3 shrink-0 text-current" />
)}
<span className="truncate">{label}</span>
</button>
</CollapsibleTrigger>
<MoreMenu onRename={onRename} onDelete={onDelete} />
</div>
<CollapsibleContent>{children}</CollapsibleContent>
</Collapsible>
);
};
interface MoreMenuProps {
onRename?: () => void;
onDelete?: () => void;
}
const MoreMenu: FC<MoreMenuProps> = ({ onRename, onDelete }) => {
if (!onRename && !onDelete) {
return null;
}
return result;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className={cn(
"size-6 shrink-0",
"opacity-0 transition-opacity",
"group-hover/tree-item:opacity-100",
"focus-visible:opacity-100",
"data-[state=open]:opacity-100",
)}
onClick={(e) => e.stopPropagation()}
>
<EllipsisIcon className="size-4" />
<span className="sr-only">File actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onRename && (
<DropdownMenuItem onClick={onRename}>
<PencilIcon />
Rename
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={onDelete}
>
<Trash2Icon />
Delete&hellip;
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -93,7 +93,7 @@ export const TemplateFiles: FC<TemplateFilesProps> = ({
{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 (