mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
refactor: replace @mui/x-tree-view with simple tree components (#24266)
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
-52
@@ -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…
|
||||
</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 (
|
||||
|
||||
Reference in New Issue
Block a user