1126 lines
32 KiB
TypeScript
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>
|
|
);
|
|
};
|