mirror of
https://github.com/coder/coder.git
synced 2026-11-15 01:01:14 +08:00
296 lines
7.6 KiB
TypeScript
296 lines
7.6 KiB
TypeScript
import { css } from "@emotion/react";
|
|
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|
import FormatAlignLeftOutlined from "@mui/icons-material/FormatAlignLeftOutlined";
|
|
import Menu from "@mui/material/Menu";
|
|
import MenuItem from "@mui/material/MenuItem";
|
|
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
|
|
import { DockerIcon } from "components/Icons/DockerIcon";
|
|
import { type CSSProperties, type ElementType, type FC, useState } from "react";
|
|
import type { FileTree } from "utils/filetree";
|
|
|
|
const isFolder = (content?: FileTree | string): content is FileTree =>
|
|
typeof content === "object";
|
|
|
|
type FileTreeEntry = [key: string, content: FileTree | string];
|
|
function compareFileTreeEntries(
|
|
[keyA, contentA]: FileTreeEntry,
|
|
[keyB, contentB]: FileTreeEntry,
|
|
) {
|
|
// A and B are either both files or both folders, so they should be sorted
|
|
// lexically.
|
|
if (isFolder(contentA) === isFolder(contentB)) {
|
|
return keyA.localeCompare(keyB);
|
|
}
|
|
// Either A or B is a folder, and the other is a file. Put whichever one is a
|
|
// folder first.
|
|
return isFolder(contentA) ? -1 : 1;
|
|
}
|
|
|
|
type ContextMenu = {
|
|
path: string;
|
|
clientX: number;
|
|
clientY: number;
|
|
};
|
|
|
|
interface TemplateFilesTreeProps {
|
|
onSelect: (path: string) => void;
|
|
onDelete?: (path: string) => void;
|
|
onRename?: (path: string) => void;
|
|
fileTree: FileTree;
|
|
activePath?: string;
|
|
Label?: FC<{
|
|
path: string;
|
|
filename: string;
|
|
label: string;
|
|
isFolder: boolean;
|
|
}>;
|
|
}
|
|
|
|
export const TemplateFileTree: FC<TemplateFilesTreeProps> = ({
|
|
fileTree,
|
|
activePath,
|
|
onDelete,
|
|
onRename,
|
|
onSelect,
|
|
Label,
|
|
}) => {
|
|
const [contextMenu, setContextMenu] = useState<ContextMenu | undefined>();
|
|
|
|
const buildTreeItems = (
|
|
label: string,
|
|
filename: string,
|
|
content?: FileTree | string,
|
|
parentPath?: string,
|
|
): JSX.Element => {
|
|
const currentPath = parentPath ? `${parentPath}/${filename}` : filename;
|
|
// Used to group empty folders in one single label like VSCode does
|
|
const shouldGroupFolder =
|
|
isFolder(content) &&
|
|
Object.keys(content).length === 1 &&
|
|
isFolder(Object.values(content)[0]);
|
|
const isHiddenFile = currentPath.startsWith(".");
|
|
|
|
if (shouldGroupFolder) {
|
|
const firstChildFileName = Object.keys(content)[0];
|
|
const child = content[firstChildFileName];
|
|
return buildTreeItems(
|
|
`${label} / ${firstChildFileName}`,
|
|
firstChildFileName,
|
|
child,
|
|
currentPath,
|
|
);
|
|
}
|
|
|
|
let icon: ElementType | undefined;
|
|
if (isFolder(content)) {
|
|
icon = FormatAlignLeftOutlined;
|
|
} else if (filename.endsWith(".tf")) {
|
|
icon = FileTypeTerraform;
|
|
} else if (filename.endsWith(".md")) {
|
|
icon = FileTypeMarkdown;
|
|
} else if (filename.endsWith("Dockerfile")) {
|
|
icon = DockerIcon;
|
|
}
|
|
|
|
return (
|
|
<TreeItem
|
|
slots={{ icon }}
|
|
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;
|
|
|
|
& > .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)
|
|
.sort(compareFileTreeEntries)
|
|
.map(([filename, child]) =>
|
|
buildTreeItems(filename, filename, child, currentPath),
|
|
)}
|
|
</TreeItem>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<SimpleTreeView
|
|
slots={{ collapseIcon: ExpandMoreIcon, expandIcon: ChevronRightIcon }}
|
|
aria-label="Files"
|
|
defaultExpandedItems={activePath ? expandablePaths(activePath) : []}
|
|
defaultSelectedItems={activePath}
|
|
>
|
|
{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>
|
|
);
|
|
};
|
|
|
|
const FileTypeTerraform: FC = () => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="#813cf3">
|
|
<title>file_type_terraform</title>
|
|
<polygon points="12.042 6.858 20.071 11.448 20.071 20.462 12.042 15.868 12.042 6.858 12.042 6.858" />
|
|
<polygon points="20.5 20.415 28.459 15.84 28.459 6.887 20.5 11.429 20.5 20.415 20.5 20.415" />
|
|
<polygon points="3.541 11.01 11.571 15.599 11.571 6.59 3.541 2 3.541 11.01 3.541 11.01" />
|
|
<polygon points="12.042 25.41 20.071 30 20.071 20.957 12.042 16.368 12.042 25.41 12.042 25.41" />
|
|
</svg>
|
|
);
|
|
|
|
const FileTypeMarkdown: FC = () => (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 32 32"
|
|
fill="#755838"
|
|
role="img"
|
|
aria-label="Markdown icon"
|
|
>
|
|
<rect
|
|
x="2.5"
|
|
y="7.955"
|
|
width="27"
|
|
height="16.091"
|
|
style={{
|
|
fill: "none",
|
|
stroke: "#755838",
|
|
}}
|
|
/>
|
|
<polygon points="5.909 20.636 5.909 11.364 8.636 11.364 11.364 14.773 14.091 11.364 16.818 11.364 16.818 20.636 14.091 20.636 14.091 15.318 11.364 18.727 8.636 15.318 8.636 20.636 5.909 20.636" />
|
|
<polygon points="22.955 20.636 18.864 16.136 21.591 16.136 21.591 11.364 24.318 11.364 24.318 16.136 27.045 16.136 22.955 20.636" />
|
|
</svg>
|
|
);
|
|
|
|
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("/"));
|
|
}
|
|
return result;
|
|
};
|