Files
coder/site/src/modules/templates/TemplateFiles/TemplateFileTree.tsx
T

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;
};