mirror of
https://github.com/coder/coder.git
synced 2026-10-28 02:42:54 +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/material/useMediaQuery": "Use Tailwind responsive classes or custom hook instead.",
|
||||||
// "@mui/system": "Use Tailwind CSS instead.",
|
// "@mui/system": "Use Tailwind CSS instead.",
|
||||||
// "@mui/utils": "Use native alternatives or utility libraries 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/css": "Use Tailwind CSS instead.",
|
||||||
// "@emotion/react": "Use Tailwind CSS instead.",
|
// "@emotion/react": "Use Tailwind CSS instead.",
|
||||||
"@emotion/styled": "Use Tailwind CSS instead.",
|
"@emotion/styled": "Use Tailwind CSS instead.",
|
||||||
|
|||||||
@@ -59,7 +59,6 @@
|
|||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
"@mui/material": "5.18.0",
|
"@mui/material": "5.18.0",
|
||||||
"@mui/system": "5.18.0",
|
"@mui/system": "5.18.0",
|
||||||
"@mui/x-tree-view": "7.29.10",
|
|
||||||
"@novnc/novnc": "^1.5.0",
|
"@novnc/novnc": "^1.5.0",
|
||||||
"@pierre/diffs": "1.1.0-beta.19",
|
"@pierre/diffs": "1.1.0-beta.19",
|
||||||
"@tanstack/react-query-devtools": "5.77.0",
|
"@tanstack/react-query-devtools": "5.77.0",
|
||||||
|
|||||||
Generated
-52
@@ -79,9 +79,6 @@ importers:
|
|||||||
'@mui/system':
|
'@mui/system':
|
||||||
specifier: 5.18.0
|
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)
|
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':
|
'@novnc/novnc':
|
||||||
specifier: ^1.5.0
|
specifier: ^1.5.0
|
||||||
version: 1.5.0
|
version: 1.5.0
|
||||||
@@ -1255,28 +1252,6 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
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':
|
'@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}
|
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:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.7
|
'@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':
|
'@napi-rs/wasm-runtime@1.0.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.7.1
|
'@emnapi/core': 1.7.1
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { BracesIcon, FileCodeIcon, FileIcon, TerminalIcon } from "lucide-react";
|
||||||
BracesIcon,
|
|
||||||
FileCodeIcon,
|
|
||||||
FileIcon,
|
|
||||||
FolderIcon,
|
|
||||||
TerminalIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { ComponentProps, ElementType, FC } from "react";
|
import type { ComponentProps, ElementType, FC } from "react";
|
||||||
import { DockerIcon } from "#/components/Icons/DockerIcon";
|
import { DockerIcon } from "#/components/Icons/DockerIcon";
|
||||||
|
|
||||||
@@ -47,13 +41,7 @@ const FileTypeMarkdown: FC<ComponentProps<"svg">> = (props) => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getTemplateFileIcon = (
|
export const getTemplateFileIcon = (filename: string): ElementType => {
|
||||||
filename: string,
|
|
||||||
isFolder: boolean,
|
|
||||||
): ElementType => {
|
|
||||||
if (isFolder) {
|
|
||||||
return FolderIcon;
|
|
||||||
}
|
|
||||||
if (filename.endsWith(".tf")) {
|
if (filename.endsWith(".tf")) {
|
||||||
return FileTypeTerraform;
|
return FileTypeTerraform;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,34 @@
|
|||||||
import { useTheme } from "@emotion/react";
|
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { action } from "storybook/actions";
|
||||||
import { chromatic } from "#/testHelpers/chromatic";
|
import { chromatic } from "#/testHelpers/chromatic";
|
||||||
import type { FileTree } from "#/utils/filetree";
|
import type { FileTree } from "#/utils/filetree";
|
||||||
import { TemplateFileTree } from "./TemplateFileTree";
|
import { TemplateFileTree } from "./TemplateFileTree";
|
||||||
|
|
||||||
const fileTree: FileTree = {
|
const fileTree: FileTree = {
|
||||||
"main.tf": "resource aws_instance my_instance {}",
|
"boundary-config.yaml": "- secure: yup",
|
||||||
"variables.tf": "variable my_var {}",
|
"configure-chrome-flags.sh": "#!/bin/bash",
|
||||||
"outputs.tf": "output my_output {}",
|
Dockerfile: "FROM ubuntu:26.04",
|
||||||
"README.md": "# Example\n\nThis is an example.",
|
files: {
|
||||||
"install.sh": "#!/bin/bash\necho 'Installing...'",
|
etc: {
|
||||||
"config.json": '{"name": "example"}',
|
apt: {
|
||||||
"docker-compose.yml": "version: '3'",
|
"sources.list.d": {
|
||||||
Dockerfile: "FROM ubuntu:latest",
|
"ppa.list": "wow you found my secret hiding spot",
|
||||||
"app.py": "print('Hello')",
|
},
|
||||||
folder: {
|
},
|
||||||
"nested.tf": "resource aws_instance my_instance {}",
|
},
|
||||||
"data.csv": "col1,col2\n1,2",
|
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> = {
|
const meta: Meta<typeof TemplateFileTree> = {
|
||||||
@@ -27,18 +38,13 @@ const meta: Meta<typeof TemplateFileTree> = {
|
|||||||
args: {
|
args: {
|
||||||
fileTree,
|
fileTree,
|
||||||
activePath: "main.tf",
|
activePath: "main.tf",
|
||||||
|
onDelete: action("delete"),
|
||||||
|
onRename: action("rename"),
|
||||||
},
|
},
|
||||||
decorators: [
|
decorators: [
|
||||||
(Story) => {
|
(Story) => {
|
||||||
const theme = useTheme();
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="max-w-[260px] rounded-lg border border-solid border-border-default">
|
||||||
css={{
|
|
||||||
maxWidth: 260,
|
|
||||||
borderRadius: 8,
|
|
||||||
border: `1px solid ${theme.palette.divider}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Story />
|
<Story />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import { css } from "@emotion/react";
|
import {
|
||||||
import Menu from "@mui/material/Menu";
|
EllipsisIcon,
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
FolderIcon,
|
||||||
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
|
FolderOpenIcon,
|
||||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
|
PencilIcon,
|
||||||
import { type CSSProperties, type FC, type JSX, useState } from "react";
|
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 type { FileTree } from "#/utils/filetree";
|
||||||
import { getTemplateFileIcon } from "./TemplateFileIcon";
|
import { getTemplateFileIcon } from "./TemplateFileIcon";
|
||||||
|
|
||||||
@@ -25,12 +40,6 @@ function compareFileTreeEntries(
|
|||||||
return isFolder(contentA) ? -1 : 1;
|
return isFolder(contentA) ? -1 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContextMenu = {
|
|
||||||
path: string;
|
|
||||||
clientX: number;
|
|
||||||
clientY: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TemplateFilesTreeProps {
|
interface TemplateFilesTreeProps {
|
||||||
onSelect: (path: string) => void;
|
onSelect: (path: string) => void;
|
||||||
onDelete?: (path: string) => void;
|
onDelete?: (path: string) => void;
|
||||||
@@ -53,16 +62,15 @@ export const TemplateFileTree: FC<TemplateFilesTreeProps> = ({
|
|||||||
onSelect,
|
onSelect,
|
||||||
Label,
|
Label,
|
||||||
}) => {
|
}) => {
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenu | undefined>();
|
|
||||||
|
|
||||||
const buildTreeItems = (
|
const buildTreeItems = (
|
||||||
label: string,
|
label: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
content?: FileTree | string,
|
content?: FileTree | string,
|
||||||
parentPath?: string,
|
parentPath?: string,
|
||||||
|
depth = 0,
|
||||||
): JSX.Element => {
|
): JSX.Element => {
|
||||||
const currentPath = parentPath ? `${parentPath}/${filename}` : filename;
|
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 =
|
const shouldGroupFolder =
|
||||||
isFolder(content) &&
|
isFolder(content) &&
|
||||||
Object.keys(content).length === 1 &&
|
Object.keys(content).length === 1 &&
|
||||||
@@ -77,175 +85,220 @@ export const TemplateFileTree: FC<TemplateFilesTreeProps> = ({
|
|||||||
firstChildFileName,
|
firstChildFileName,
|
||||||
child,
|
child,
|
||||||
currentPath,
|
currentPath,
|
||||||
|
depth,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const templateFileIcon = getTemplateFileIcon(filename, isFolder(content));
|
const isActive = currentPath === activePath;
|
||||||
|
|
||||||
return (
|
const labelContent = Label ? (
|
||||||
<TreeItem
|
<Label
|
||||||
slots={{ icon: templateFileIcon }}
|
path={currentPath}
|
||||||
itemId={currentPath}
|
label={label}
|
||||||
key={currentPath}
|
filename={filename}
|
||||||
label={
|
isFolder={isFolder(content)}
|
||||||
Label ? (
|
/>
|
||||||
<Label
|
) : (
|
||||||
path={currentPath}
|
label
|
||||||
label={label}
|
);
|
||||||
filename={filename}
|
|
||||||
isFolder={isFolder(content)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
label
|
|
||||||
)
|
|
||||||
}
|
|
||||||
css={(theme) => css`
|
|
||||||
overflow: hidden;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
& > .MuiTreeItem-content {
|
if (isFolder(content)) {
|
||||||
border-radius: 0;
|
return (
|
||||||
padding: 2px 16px;
|
<FolderNode
|
||||||
color: ${
|
key={currentPath}
|
||||||
isHiddenFile
|
label={labelContent}
|
||||||
? theme.palette.text.disabled
|
isHidden={isHiddenFile}
|
||||||
: theme.palette.text.secondary
|
isActive={isActive}
|
||||||
};
|
depth={depth}
|
||||||
height: 32px;
|
onClick={() => onSelect(currentPath)}
|
||||||
|
onDelete={onDelete && (() => onDelete(currentPath))}
|
||||||
& svg {
|
onRename={onRename && (() => onRename(currentPath))}
|
||||||
width: 12px;
|
>
|
||||||
height: 12px;
|
{Object.entries(content)
|
||||||
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)
|
|
||||||
.sort(compareFileTreeEntries)
|
.sort(compareFileTreeEntries)
|
||||||
.map(([filename, child]) =>
|
.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 (
|
return (
|
||||||
<SimpleTreeView
|
<div>
|
||||||
slots={{ collapseIcon: ChevronDownIcon, expandIcon: ChevronRightIcon }}
|
|
||||||
aria-label="Files"
|
|
||||||
defaultExpandedItems={activePath ? expandablePaths(activePath) : []}
|
|
||||||
defaultSelectedItems={activePath}
|
|
||||||
>
|
|
||||||
{Object.entries(fileTree)
|
{Object.entries(fileTree)
|
||||||
.sort(compareFileTreeEntries)
|
.sort(compareFileTreeEntries)
|
||||||
.map(([filename, child]) => buildTreeItems(filename, filename, child))}
|
.map(([filename, child]) => buildTreeItems(filename, filename, child))}
|
||||||
|
</div>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const expandablePaths = (path: string) => {
|
interface TreeNodeProps {
|
||||||
const paths = path.split("/");
|
label: React.ReactNode;
|
||||||
const result = [];
|
icon: React.ReactNode;
|
||||||
for (let i = 1; i < paths.length; i++) {
|
isHidden: boolean;
|
||||||
result.push(paths.slice(0, i).join("/"));
|
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)
|
{Object.keys(currentFiles)
|
||||||
.sort((a, b) => a.localeCompare(b))
|
.sort((a, b) => a.localeCompare(b))
|
||||||
.map((filename) => {
|
.map((filename) => {
|
||||||
const TemplateFileIcon = getTemplateFileIcon(filename, false);
|
const TemplateFileIcon = getTemplateFileIcon(filename);
|
||||||
const info = fileInfo(filename);
|
const info = fileInfo(filename);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user