Files
Work/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx
T
2026-06-25 21:53:29 +01:00

1126 lines
32 KiB
TypeScript

// Path: Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx
import { For, Show, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { ChevronDown, ChevronRight, Folder, LayoutGrid, ListCollapse, UnfoldVertical } from "../../../lib/icons";
import { ProjectContextMenu } from "../ProjectContextMenu/ProjectContextMenu";
import { useAppShellData } from "../data/app-shell.context";
import { resolveAPIBase } from "../../../lib/api";
import {
collectBranchNodeIds,
findTreeNodeDepth,
findTreeNodeLocation,
getPointerRelativeY,
isUuidString,
moveTreeNode,
resolveTreeDropTarget,
type NavTreeAdapter,
type NavTreeDropTarget,
} from "../shared/navTreeDnd";
import {
createProjectFolderTarget,
createProjectSurfaceTarget,
createProjectTarget,
type ProjectItem,
type ProjectMenuTarget,
} from "../data/shell.data";
import { createProjectContextMenuController } from "../ProjectContextMenu/createProjectContextMenuController";
import styles from "./ProjectSelector.module.scss";
type ProjectSelectorProps = {
compact?: boolean;
isOpen: boolean;
onToggle: () => void;
onClose: () => void;
};
type ProjectFolderNode = {
kind: "folder";
id: string;
path: string;
label: string;
meta?: string;
children: ProjectTreeNode[];
};
type ProjectLeafNode = {
kind: "project";
item: ProjectItem;
};
type ProjectTreeNode = ProjectFolderNode | ProjectLeafNode;
type PersistedProjectFolderRecord = {
id: string;
path: string;
label: string;
children: PersistedProjectFolderRecord[];
};
type ProjectFoldersResponse = {
data?: {
folders?: PersistedProjectFolderRecord[];
renamedFolder?: PersistedProjectFolderRecord;
movedFolder?: PersistedProjectFolderRecord;
previousFolderId?: string;
previousFolderPath?: string;
};
error?: string;
message?: string;
};
type PendingProjectFolderDraft = {
parentId: string | null;
depth: number;
};
type PendingProjectFolderRename = {
folderId: string;
depth: number;
};
type ProjectDragTarget = NavTreeDropTarget;
type ProjectDragState = {
draggedNodeId: string;
dropTarget: ProjectDragTarget | null;
};
const LONG_PRESS_MS = 320;
const getProjectTreeNodeId = (node: ProjectTreeNode): string =>
node.kind === "folder" ? node.id : node.item.id;
const buildPersistedFolderNodes = (folders: readonly PersistedProjectFolderRecord[] = []): ProjectTreeNode[] =>
folders.map((folder) => ({
kind: "folder",
id: folder.id,
path: folder.path,
label: folder.label,
children: buildPersistedFolderNodes(folder.children ?? []),
}));
const buildProjectTree = (
items: readonly ProjectItem[],
folders: readonly PersistedProjectFolderRecord[] = [],
): ProjectTreeNode[] => [
...items.map((item) => ({
kind: "project" as const,
item,
})),
...buildPersistedFolderNodes(folders),
];
const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] =>
Array.isArray(body.data?.folders) ? body.data.folders : [];
const projectTreeAdapter: NavTreeAdapter<ProjectTreeNode> = {
getNodeId: getProjectTreeNodeId,
isBranchNode: (node) => node.kind === "folder",
getChildren: (node) => (node.kind === "folder" ? node.children : []),
withChildren: (node, children) =>
node.kind === "folder"
? {
...node,
children: [...children],
}
: node,
};
const ProjectFolderDraftRow = (props: {
depth: number;
value: string;
onInput: (value: string) => void;
onSubmit: () => void;
onCancel: () => void;
}): JSX.Element => {
let inputRef: HTMLInputElement | undefined;
queueMicrotask(() => inputRef?.focus());
return (
<li>
<div class={styles.treeInputRow} style={{ "--tree-depth": String(props.depth) }}>
<Folder class={styles.icon} size={18} strokeWidth={2} />
<input
ref={inputRef}
type="text"
class={styles.treeInput}
value={props.value}
placeholder="Folder name"
onInput={(event): void => props.onInput(event.currentTarget.value)}
onBlur={props.onSubmit}
onKeyDown={(event): void => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
return;
}
if (event.key === "Escape") {
event.preventDefault();
props.onCancel();
event.currentTarget.blur();
}
}}
/>
</div>
</li>
);
};
const ProjectFolderBranch = (props: {
nodes: readonly ProjectTreeNode[];
depth: number;
parentId: string | null;
selectedProjectId: string;
isFolderCollapsed: (folderId: string) => boolean;
onToggleFolder: (folderId: string) => void;
onSelectProject: (projectId: string) => void;
onOpenFolderMenu: (event: MouseEvent, folder: ProjectFolderNode) => void;
onOpenProjectMenu: (event: MouseEvent, item: ProjectItem) => void;
onNodePointerDown: (event: PointerEvent, nodeId: string) => void;
onNodePointerMove: (event: PointerEvent, parentId: string | null, index: number, node: ProjectTreeNode) => void;
pendingFolderDraft: PendingProjectFolderDraft | null;
pendingFolderName: string;
onPendingFolderNameChange: (value: string) => void;
onSubmitPendingFolder: () => void;
onCancelPendingFolder: () => void;
pendingFolderRename: PendingProjectFolderRename | null;
pendingFolderRenameName: string;
onPendingFolderRenameChange: (value: string) => void;
onSubmitPendingFolderRename: () => void;
onCancelPendingFolderRename: () => void;
dragState: ProjectDragState | null;
isTreeClickSuppressed: () => boolean;
}): JSX.Element => (
<ul class={styles.treeList} role="list">
<Show when={props.nodes.length === 0 && props.pendingFolderDraft?.parentId !== props.parentId}>
<li>
<div class={styles.treeEmptySlot} style={{ "--tree-depth": String(props.depth) }} />
</li>
</Show>
<For each={props.nodes}>
{(node, indexAccessor): JSX.Element => {
const nodeId = (): string => getProjectTreeNodeId(node);
const isDraggedNode = (): boolean => props.dragState?.draggedNodeId === nodeId();
const dropIntent = (): ProjectDragTarget["intent"] | null => {
if (props.dragState?.dropTarget?.targetNodeId !== nodeId()) {
return null;
}
return props.dragState.dropTarget.intent;
};
if (node.kind === "folder") {
const isCollapsed = (): boolean => props.isFolderCollapsed(node.id);
const isRenaming = (): boolean => props.pendingFolderRename?.folderId === node.id;
return (
<li>
<Show
when={isRenaming()}
fallback={
<button
type="button"
classList={{
[styles.treeItem]: true,
[styles.treeItemFolder]: true,
[styles.treeItemDragging]: isDraggedNode(),
[styles.treeItemDropBefore]: dropIntent() === "before",
[styles.treeItemDropAfter]: dropIntent() === "after",
[styles.treeItemDropInside]: dropIntent() === "inside",
}}
style={{ "--tree-depth": String(props.depth) }}
aria-expanded={!isCollapsed()}
onClick={() => {
if (props.dragState || props.isTreeClickSuppressed()) {
return;
}
props.onToggleFolder(node.id);
}}
onContextMenu={(event): void => props.onOpenFolderMenu(event, node)}
onPointerDown={(event): void => props.onNodePointerDown(event, node.id)}
onPointerMove={(event): void =>
props.onNodePointerMove(event, props.parentId, indexAccessor(), node)
}
onPointerEnter={(event): void =>
props.onNodePointerMove(event, props.parentId, indexAccessor(), node)
}
>
<ChevronRight
classList={{
[styles.folderChevron]: true,
[styles.folderChevronOpen]: !isCollapsed(),
}}
size={16}
strokeWidth={2}
/>
<Folder class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{node.label}</span>
<Show when={node.meta}>
<span class={styles.itemMeta}>{node.meta}</span>
</Show>
</button>
}
>
<ProjectFolderDraftRow
depth={props.pendingFolderRename?.depth ?? props.depth}
value={props.pendingFolderRenameName}
onInput={props.onPendingFolderRenameChange}
onSubmit={props.onSubmitPendingFolderRename}
onCancel={props.onCancelPendingFolderRename}
/>
</Show>
<Show when={!isCollapsed() && ((node.children?.length ?? 0) > 0 || props.pendingFolderDraft?.parentId === node.id)}>
<ProjectFolderBranch
nodes={node.children}
depth={props.depth + 1}
parentId={node.id}
selectedProjectId={props.selectedProjectId}
isFolderCollapsed={props.isFolderCollapsed}
onToggleFolder={props.onToggleFolder}
onSelectProject={props.onSelectProject}
onOpenFolderMenu={props.onOpenFolderMenu}
onOpenProjectMenu={props.onOpenProjectMenu}
onNodePointerDown={props.onNodePointerDown}
onNodePointerMove={props.onNodePointerMove}
pendingFolderDraft={props.pendingFolderDraft}
pendingFolderName={props.pendingFolderName}
onPendingFolderNameChange={props.onPendingFolderNameChange}
onSubmitPendingFolder={props.onSubmitPendingFolder}
onCancelPendingFolder={props.onCancelPendingFolder}
pendingFolderRename={props.pendingFolderRename}
pendingFolderRenameName={props.pendingFolderRenameName}
onPendingFolderRenameChange={props.onPendingFolderRenameChange}
onSubmitPendingFolderRename={props.onSubmitPendingFolderRename}
onCancelPendingFolderRename={props.onCancelPendingFolderRename}
dragState={props.dragState}
isTreeClickSuppressed={props.isTreeClickSuppressed}
/>
</Show>
</li>
);
}
return (
<li>
<button
type="button"
classList={{
[styles.treeItem]: true,
[styles.treeItemActive]: props.selectedProjectId === node.item.id,
[styles.treeItemDragging]: isDraggedNode(),
[styles.treeItemDropBefore]: dropIntent() === "before",
[styles.treeItemDropAfter]: dropIntent() === "after",
}}
style={{ "--tree-depth": String(props.depth) }}
onClick={(): void => {
if (props.dragState || props.isTreeClickSuppressed()) {
return;
}
props.onSelectProject(node.item.id);
}}
onContextMenu={(event): void => props.onOpenProjectMenu(event, node.item)}
onPointerDown={(event): void => props.onNodePointerDown(event, node.item.id)}
onPointerMove={(event): void =>
props.onNodePointerMove(event, props.parentId, indexAccessor(), node)
}
onPointerEnter={(event): void =>
props.onNodePointerMove(event, props.parentId, indexAccessor(), node)
}
>
<LayoutGrid class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{node.item.name}</span>
<Show when={node.item.meta}>
<span class={styles.itemMeta}>{node.item.meta}</span>
</Show>
</button>
</li>
);
}}
</For>
<Show when={props.pendingFolderDraft?.parentId === props.parentId}>
<ProjectFolderDraftRow
depth={props.pendingFolderDraft?.depth ?? props.depth}
value={props.pendingFolderName}
onInput={props.onPendingFolderNameChange}
onSubmit={props.onSubmitPendingFolder}
onCancel={props.onCancelPendingFolder}
/>
</Show>
</ul>
);
export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
const appShellData = useAppShellData();
const [selectedProject, setSelectedProject] = createSignal(appShellData.activeProject());
const [drawerTop, setDrawerTop] = createSignal<number>(0);
const [collapsedFolderIds, setCollapsedFolderIds] = createSignal<readonly string[]>([]);
const [persistedFolders, setPersistedFolders] = createSignal<readonly PersistedProjectFolderRecord[]>([]);
const [projectTreeNodes, setProjectTreeNodes] = createSignal<ProjectTreeNode[]>(
buildProjectTree(appShellData.projectItems(), persistedFolders()),
);
const [pendingFolderDraft, setPendingFolderDraft] = createSignal<PendingProjectFolderDraft | null>(null);
const [pendingFolderName, setPendingFolderName] = createSignal("");
const [pendingFolderRename, setPendingFolderRename] = createSignal<PendingProjectFolderRename | null>(null);
const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal("");
const [dragState, setDragState] = createSignal<ProjectDragState | null>(null);
const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false);
let rootRef: HTMLDivElement | undefined;
let triggerRef: HTMLButtonElement | undefined;
let contextMenuRef: HTMLDivElement | undefined;
let longPressTimer: number | undefined;
let suppressClickTimer: number | undefined;
let lastSelectedProjectId: string | null = null;
let latestPersistedFoldersRequest = 0;
const contextMenu = createProjectContextMenuController();
const clearLongPressTimer = (): void => {
if (longPressTimer !== undefined) {
window.clearTimeout(longPressTimer);
longPressTimer = undefined;
}
};
const suppressTreeClickTemporarily = (): void => {
setSuppressNextTreeClick(true);
if (suppressClickTimer !== undefined) {
window.clearTimeout(suppressClickTimer);
}
suppressClickTimer = window.setTimeout(() => {
setSuppressNextTreeClick(false);
suppressClickTimer = undefined;
}, 80);
};
const isFolderCollapsed = (folderId: string): boolean => collapsedFolderIds().includes(folderId);
const toggleFolder = (folderId: string): void => {
setCollapsedFolderIds((current) =>
current.includes(folderId) ? current.filter((id) => id !== folderId) : [...current, folderId],
);
};
const syncProjectTree = (): void => {
const nextTree = buildProjectTree(appShellData.projectItems(), persistedFolders());
const availableFolderIds = new Set(collectBranchNodeIds(nextTree, projectTreeAdapter));
setProjectTreeNodes(nextTree);
setCollapsedFolderIds((current) => current.filter((folderId) => availableFolderIds.has(folderId)));
};
const resetProjectTreeInteractionState = (): void => {
setCollapsedFolderIds([]);
setPendingFolderDraft(null);
setPendingFolderName("");
setPendingFolderRename(null);
setPendingFolderRenameName("");
setDragState(null);
};
const folderIds = (): string[] => collectBranchNodeIds(projectTreeNodes(), projectTreeAdapter);
const expandAllFolders = (): void => {
setCollapsedFolderIds([]);
};
const collapseAllFolders = (): void => {
setCollapsedFolderIds(folderIds());
};
const totalFolderCount = (): number => folderIds().length;
const areAllFoldersCollapsed = (): boolean => {
const folderCount = totalFolderCount();
return folderCount > 0 && collapsedFolderIds().length >= folderCount;
};
const toggleAllFolders = (): void => {
if (areAllFoldersCollapsed()) {
expandAllFolders();
return;
}
collapseAllFolders();
};
const loadPersistedFolders = async (projectId: string): Promise<void> => {
const requestId = latestPersistedFoldersRequest + 1;
latestPersistedFoldersRequest = requestId;
if (!projectId) {
setPersistedFolders([]);
return;
}
if (!isUuidString(projectId)) {
setPersistedFolders([]);
return;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
headers: {
Accept: "application/json",
},
});
const body = (await response.json()) as ProjectFoldersResponse;
if (requestId !== latestPersistedFoldersRequest) {
return;
}
if (!response.ok) {
throw new Error(body.message || "Failed to load project folders.");
}
setPersistedFolders(readPersistedFolders(body));
} catch (error) {
if (requestId !== latestPersistedFoldersRequest) {
return;
}
console.error(error);
setPersistedFolders([]);
}
};
createEffect(() => {
setSelectedProject(appShellData.activeProject());
});
createEffect(() => {
syncProjectTree();
});
createEffect(() => {
const projectId = selectedProject().id;
if (lastSelectedProjectId === null) {
lastSelectedProjectId = projectId;
return;
}
if (projectId === lastSelectedProjectId) {
return;
}
lastSelectedProjectId = projectId;
resetProjectTreeInteractionState();
});
createEffect(() => {
const projectId = selectedProject().id;
void loadPersistedFolders(projectId);
});
onMount(() => {
if (triggerRef) {
const updateDrawerTop = (): void => {
if (!triggerRef) {
return;
}
setDrawerTop(triggerRef.offsetTop + triggerRef.offsetHeight + 8);
};
updateDrawerTop();
const observer = new ResizeObserver(() => {
updateDrawerTop();
});
observer.observe(triggerRef);
window.addEventListener("resize", updateDrawerTop);
onCleanup(() => {
observer.disconnect();
window.removeEventListener("resize", updateDrawerTop);
});
}
const handlePointerDown = (event: PointerEvent): void => {
if (!props.isOpen || !rootRef) {
return;
}
const target = event.target;
if (target instanceof Node && rootRef.contains(target)) {
return;
}
if (target instanceof Node && contextMenuRef?.contains(target)) {
return;
}
props.onClose();
};
const handlePointerUp = (): void => {
clearLongPressTimer();
const nextDragState = dragState();
if (!nextDragState?.dropTarget) {
if (nextDragState) {
suppressTreeClickTemporarily();
}
setDragState(null);
return;
}
suppressTreeClickTemporarily();
const currentNodes = projectTreeNodes();
const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, projectTreeAdapter);
const canPersistMove = isUuidString(selectedProject().id);
const draggedFolderPath = draggedLocation?.node.kind === "folder" ? draggedLocation.node.path : null;
const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget as ProjectDragTarget, projectTreeAdapter);
const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, projectTreeAdapter);
const persistedParentLocation = previewLocation?.parentId
? findTreeNodeLocation(previewNodes, previewLocation.parentId, projectTreeAdapter)
: null;
const persistedParentFolderPath =
persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.path : null;
const previewSiblings = previewLocation?.parentId
? persistedParentLocation?.node.kind === "folder"
? persistedParentLocation.node.children
: []
: previewNodes;
const targetIndex = previewLocation
? previewSiblings
.slice(0, previewLocation.index)
.filter((node) => node.kind === "folder").length
: 0;
if (
canPersistMove &&
draggedLocation?.node.kind === "folder" &&
draggedFolderPath &&
(!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder")
) {
void movePersistedFolder(
draggedFolderPath,
persistedParentFolderPath,
draggedLocation.node.id,
persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null,
targetIndex,
);
} else {
setProjectTreeNodes((current) =>
moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as ProjectDragTarget, projectTreeAdapter),
);
}
setDragState(null);
};
const handleEscape = (event: KeyboardEvent): void => {
if (event.key !== "Escape") {
return;
}
clearLongPressTimer();
if (dragState()) {
setDragState(null);
return;
}
if (!props.isOpen) {
return;
}
props.onClose();
triggerRef?.focus();
};
document.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
window.addEventListener("keydown", handleEscape);
onCleanup(() => {
clearLongPressTimer();
if (suppressClickTimer !== undefined) {
window.clearTimeout(suppressClickTimer);
}
document.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
window.removeEventListener("keydown", handleEscape);
});
});
const toggleOpen = (): void => {
if (!props.isOpen) {
props.onToggle();
return;
}
props.onClose();
};
const selectProject = (projectId: string): void => {
const location = findTreeNodeLocation(projectTreeNodes(), projectId, projectTreeAdapter);
if (!location || location.node.kind !== "project") {
return;
}
setSelectedProject({ id: location.node.item.id, name: location.node.item.name });
props.onClose();
};
const beginFolderDraft = (parentId: string | null, depth: number): void => {
if (parentId) {
setCollapsedFolderIds((current) => current.filter((id) => id !== parentId));
}
setPendingFolderRename(null);
setPendingFolderRenameName("");
setPendingFolderName("");
setPendingFolderDraft({ parentId, depth });
};
const beginFolderRename = (folderId: string, label: string, depth: number): void => {
setPendingFolderDraft(null);
setPendingFolderName("");
setPendingFolderRename({ folderId, depth });
setPendingFolderRenameName(label);
};
const resolveFolderPath = (folderId: string): string | null => {
const location = findTreeNodeLocation(projectTreeNodes(), folderId, projectTreeAdapter);
return location && location.node.kind === "folder" ? location.node.path : null;
};
const submitPendingFolder = async (): Promise<void> => {
const name = pendingFolderName().trim();
const draft = pendingFolderDraft();
const projectId = selectedProject().id;
if (!draft) {
return;
}
if (!name) {
setPendingFolderDraft(null);
setPendingFolderName("");
return;
}
if (!isUuidString(projectId)) {
cancelPendingFolder();
return;
}
const parentFolderPath = draft.parentId ? resolveFolderPath(draft.parentId) : null;
if (draft.parentId && !parentFolderPath) {
cancelPendingFolder();
return;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
parentFolderId: parentFolderPath,
}),
});
const body = (await response.json()) as ProjectFoldersResponse;
if (!response.ok) {
throw new Error(body.message || "Failed to create project folder.");
}
setPersistedFolders(readPersistedFolders(body));
setPendingFolderDraft(null);
setPendingFolderName("");
} catch (error) {
console.error(error);
}
};
const deletePersistedFolder = async (folderId: string): Promise<void> => {
const projectId = selectedProject().id;
if (!folderId || !isUuidString(projectId)) {
return;
}
const folderPath = resolveFolderPath(folderId);
if (!folderPath) {
return;
}
try {
const response = await fetch(
`${resolveAPIBase()}/projects/${projectId}/folders?folderId=${encodeURIComponent(folderPath)}`,
{
method: "DELETE",
headers: {
Accept: "application/json",
},
},
);
const body = (await response.json()) as ProjectFoldersResponse;
if (!response.ok) {
throw new Error(body.message || "Failed to delete project folder.");
}
setPersistedFolders(readPersistedFolders(body));
setCollapsedFolderIds((current) => current.filter((id) => id !== folderId));
} catch (error) {
console.error(error);
}
};
const movePersistedFolder = async (
folderPath: string,
parentFolderPath: string | null,
folderNodeId: string,
parentNodeId: string | null,
targetIndex: number,
): Promise<void> => {
const projectId = selectedProject().id;
if (!folderPath || !folderNodeId || !isUuidString(projectId)) {
return;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders/move`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
folderId: folderPath,
folderNodeId,
parentFolderId: parentFolderPath,
parentNodeId,
targetIndex,
}),
});
const body = (await response.json()) as ProjectFoldersResponse;
if (!response.ok) {
throw new Error(body.message || "Failed to move project folder.");
}
setPersistedFolders(readPersistedFolders(body));
} catch (error) {
console.error(error);
}
};
const submitPendingFolderRename = async (): Promise<void> => {
const draft = pendingFolderRename();
const name = pendingFolderRenameName().trim();
const projectId = selectedProject().id;
if (!draft) {
return;
}
if (!name) {
setPendingFolderRename(null);
setPendingFolderRenameName("");
return;
}
if (!isUuidString(projectId)) {
cancelPendingFolderRename();
return;
}
const folderPath = resolveFolderPath(draft.folderId);
if (!folderPath) {
cancelPendingFolderRename();
return;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
folderId: folderPath,
name,
}),
});
const body = (await response.json()) as ProjectFoldersResponse;
if (!response.ok) {
throw new Error(body.message || "Failed to rename project folder.");
}
setPersistedFolders(readPersistedFolders(body));
setPendingFolderRename(null);
setPendingFolderRenameName("");
} catch (error) {
console.error(error);
}
};
const cancelPendingFolder = (): void => {
setPendingFolderDraft(null);
setPendingFolderName("");
};
const cancelPendingFolderRename = (): void => {
setPendingFolderRename(null);
setPendingFolderRenameName("");
};
const handleContextActionSelect = (action: { id: string; label: string }, target: ProjectMenuTarget): void => {
switch (action.id) {
case "new-folder":
switch (target.kind) {
case "surface":
beginFolderDraft(null, 0);
return;
case "folder":
beginFolderDraft(target.id, (findTreeNodeDepth(projectTreeNodes(), target.id, projectTreeAdapter) ?? 0) + 1);
return;
case "project": {
const parentId = findTreeNodeLocation(projectTreeNodes(), target.id, projectTreeAdapter)?.parentId ?? null;
beginFolderDraft(parentId, parentId ? (findTreeNodeDepth(projectTreeNodes(), parentId, projectTreeAdapter) ?? 0) + 1 : 0);
return;
}
}
return;
case "delete-folder":
if (target.kind === "folder") {
void deletePersistedFolder(target.id);
}
return;
case "rename-folder":
if (target.kind === "folder") {
beginFolderRename(target.id, target.label, findTreeNodeDepth(projectTreeNodes(), target.id, projectTreeAdapter) ?? 0);
}
return;
default:
return;
}
};
const handleSurfaceContextMenu = (event: MouseEvent): void => {
event.stopPropagation();
contextMenu.openMenu(event, createProjectSurfaceTarget("Projects"));
};
const treeControlLabel = (): string =>
areAllFoldersCollapsed() ? "Expand all folders" : "Collapse all folders";
const handleNodePointerDown = (event: PointerEvent, nodeId: string): void => {
if (event.button !== 0 || pendingFolderDraft()) {
return;
}
clearLongPressTimer();
longPressTimer = window.setTimeout(() => {
suppressTreeClickTemporarily();
setDragState({ draggedNodeId: nodeId, dropTarget: null });
}, LONG_PRESS_MS);
};
const handleNodePointerMove = (
event: PointerEvent,
parentId: string | null,
index: number,
node: ProjectTreeNode,
): void => {
const nextDragState = dragState();
if (!nextDragState) {
return;
}
if (nextDragState.draggedNodeId === getProjectTreeNodeId(node)) {
return;
}
const relativeY = getPointerRelativeY(event);
if (relativeY === null) {
return;
}
setDragState({
...nextDragState,
dropTarget: resolveTreeDropTarget({
parentId,
index,
node,
relativeY,
adapter: projectTreeAdapter,
}),
});
};
return (
<div
ref={rootRef}
classList={{
[styles.root]: true,
[styles.rootCompact]: !!props.compact,
[styles.rootDragMode]: !!dragState(),
}}
style={{
"--project-drawer-top": `${drawerTop()}px`,
}}
>
<button
type="button"
ref={triggerRef}
classList={{
[styles.trigger]: true,
[styles.triggerCompact]: !!props.compact,
[styles.triggerOpen]: props.isOpen,
}}
aria-label={`Open project menu for ${selectedProject().name}`}
aria-expanded={props.isOpen}
aria-haspopup="menu"
title={selectedProject().name}
onClick={toggleOpen}
>
<span class={styles.triggerLead} aria-hidden="true">
<Folder size={18} strokeWidth={2} />
</span>
{!props.compact ? (
<span class={styles.triggerCopy}>
<span class={styles.eyebrow}>Projects</span>
<span class={styles.value}>{selectedProject().name}</span>
</span>
) : null}
<ChevronDown
classList={{
[styles.triggerIcon]: true,
[styles.triggerIconOpen]: props.isOpen,
}}
size={16}
strokeWidth={2}
/>
</button>
<Show when={props.isOpen}>
<>
<button
type="button"
classList={{
[styles.scrim]: true,
[styles.scrimOpen]: props.isOpen,
}}
aria-hidden={!props.isOpen}
tabIndex={props.isOpen ? 0 : -1}
onClick={props.onClose}
/>
<div
classList={{
[styles.drawer]: true,
[styles.drawerOpen]: props.isOpen,
}}
aria-hidden={!props.isOpen}
onContextMenu={handleSurfaceContextMenu}
>
<div class={styles.drawerBody}>
<div class={styles.treeSectionHeader}>
<Show when={!props.compact}>
<div class={styles.treeSectionLabel}>Projects</div>
</Show>
<div class={styles.treeControls}>
<button
type="button"
class={styles.treeControlButton}
onClick={toggleAllFolders}
aria-label={treeControlLabel()}
title={treeControlLabel()}
disabled={totalFolderCount() === 0}
>
<Show
when={areAllFoldersCollapsed()}
fallback={<ListCollapse size={16} strokeWidth={2} />}
>
<UnfoldVertical size={16} strokeWidth={2} />
</Show>
</button>
</div>
</div>
<ProjectFolderBranch
nodes={projectTreeNodes()}
depth={0}
parentId={null}
selectedProjectId={selectedProject().id}
isFolderCollapsed={isFolderCollapsed}
onToggleFolder={toggleFolder}
onSelectProject={selectProject}
onOpenFolderMenu={(event, folder): void => {
event.stopPropagation();
contextMenu.openMenu(event, createProjectFolderTarget(folder.id, folder.label));
}}
onOpenProjectMenu={(event, item): void => {
event.stopPropagation();
contextMenu.openMenu(event, createProjectTarget(item));
}}
onNodePointerDown={handleNodePointerDown}
onNodePointerMove={handleNodePointerMove}
pendingFolderDraft={pendingFolderDraft()}
pendingFolderName={pendingFolderName()}
onPendingFolderNameChange={setPendingFolderName}
onSubmitPendingFolder={submitPendingFolder}
onCancelPendingFolder={cancelPendingFolder}
pendingFolderRename={pendingFolderRename()}
pendingFolderRenameName={pendingFolderRenameName()}
onPendingFolderRenameChange={setPendingFolderRenameName}
onSubmitPendingFolderRename={submitPendingFolderRename}
onCancelPendingFolderRename={cancelPendingFolderRename}
dragState={dragState()}
isTreeClickSuppressed={suppressNextTreeClick}
/>
</div>
</div>
</>
</Show>
<ProjectContextMenu
target={contextMenu.menuState()?.target ?? null}
position={(() => {
const state = contextMenu.menuState();
return state ? { x: state.x, y: state.y } : null;
})()}
menuRef={(element) => {
contextMenuRef = element;
contextMenu.setMenuRef(element);
}}
onClose={contextMenu.closeMenu}
onSelect={handleContextActionSelect}
/>
</div>
);
};