Fix: persist project folder hierarchy

This commit is contained in:
MangoPig
2026-06-22 14:14:13 +01:00
parent 2ff7fbd9e7
commit 618e3e84be
7 changed files with 780 additions and 70 deletions
@@ -1,9 +1,10 @@
// 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 } from "../../../lib/icons";
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 {
createProjectFolderTarget,
createProjectSurfaceTarget,
@@ -36,6 +37,20 @@ type ProjectLeafNode = {
type ProjectTreeNode = ProjectFolderNode | ProjectLeafNode;
type PersistedProjectFolderRecord = {
id: string;
label: string;
children: PersistedProjectFolderRecord[];
};
type ProjectFoldersResponse = {
data?: {
folders?: PersistedProjectFolderRecord[];
};
error?: string;
message?: string;
};
type PendingProjectFolderDraft = {
parentId: string | null;
depth: number;
@@ -61,17 +76,46 @@ type ProjectNodeLocation = {
const LONG_PRESS_MS = 320;
const createProjectFolderId = (): string => `project-folder-${Math.random().toString(36).slice(2, 10)}`;
const getProjectTreeNodeId = (node: ProjectTreeNode): string =>
node.kind === "folder" ? node.id : node.item.id;
const buildProjectTree = (items: readonly ProjectItem[]): ProjectTreeNode[] =>
items.map((item) => ({
kind: "project",
item,
const buildPersistedFolderNodes = (folders: readonly PersistedProjectFolderRecord[] = []): ProjectTreeNode[] =>
folders.map((folder) => ({
kind: "folder",
id: folder.id,
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 collectProjectFolderIds = (nodes: readonly ProjectTreeNode[]): string[] => {
const ids: string[] = [];
for (const node of nodes) {
if (node.kind !== "folder") {
continue;
}
ids.push(node.id);
ids.push(...collectProjectFolderIds(node.children));
}
return ids;
};
const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] =>
Array.isArray(body.data?.folders) ? body.data.folders : [];
const cloneProjectTreeNode = (node: ProjectTreeNode): ProjectTreeNode => {
if (node.kind === "project") {
return {
@@ -89,34 +133,6 @@ const cloneProjectTreeNode = (node: ProjectTreeNode): ProjectTreeNode => {
};
};
const insertProjectFolderNode = (
nodes: readonly ProjectTreeNode[],
parentId: string | null,
folder: ProjectFolderNode,
): ProjectTreeNode[] => {
if (parentId === null) {
return [...nodes, folder];
}
return nodes.map((node) => {
if (node.kind !== "folder") {
return node;
}
if (node.id === parentId) {
return {
...node,
children: [...node.children, folder],
};
}
return {
...node,
children: insertProjectFolderNode(node.children, parentId, folder),
};
});
};
const findProjectNodeLocation = (
nodes: readonly ProjectTreeNode[],
nodeId: string,
@@ -336,6 +352,7 @@ const ProjectFolderBranch = (props: {
onSubmitPendingFolder: () => void;
onCancelPendingFolder: () => 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}>
@@ -368,18 +385,18 @@ const ProjectFolderBranch = (props: {
[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 || suppressNextTreeClick()) {
return;
}
[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);
}}
props.onToggleFolder(node.id);
}}
onContextMenu={(event): void => props.onOpenFolderMenu(event, node)}
onPointerDown={(event): void => props.onNodePointerDown(event, node.id)}
onPointerMove={(event): void =>
@@ -404,7 +421,7 @@ const ProjectFolderBranch = (props: {
</Show>
</button>
<Show when={!isCollapsed() && (node.children.length > 0 || props.pendingFolderDraft?.parentId === node.id)}>
<Show when={!isCollapsed() && ((node.children?.length ?? 0) > 0 || props.pendingFolderDraft?.parentId === node.id)}>
<ProjectFolderBranch
nodes={node.children}
depth={props.depth + 1}
@@ -423,6 +440,7 @@ const ProjectFolderBranch = (props: {
onSubmitPendingFolder={props.onSubmitPendingFolder}
onCancelPendingFolder={props.onCancelPendingFolder}
dragState={props.dragState}
isTreeClickSuppressed={props.isTreeClickSuppressed}
/>
</Show>
</li>
@@ -442,7 +460,7 @@ const ProjectFolderBranch = (props: {
}}
style={{ "--tree-depth": String(props.depth) }}
onClick={(): void => {
if (props.dragState || suppressNextTreeClick()) {
if (props.dragState || props.isTreeClickSuppressed()) {
return;
}
@@ -485,8 +503,9 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
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()),
buildProjectTree(appShellData.projectItems(), persistedFolders()),
);
const [pendingFolderDraft, setPendingFolderDraft] = createSignal<PendingProjectFolderDraft | null>(null);
const [pendingFolderName, setPendingFolderName] = createSignal("");
@@ -497,6 +516,8 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
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 => {
@@ -527,16 +548,111 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
);
};
const syncProjectTree = (): void => {
const nextTree = buildProjectTree(appShellData.projectItems(), persistedFolders());
const availableFolderIds = new Set(collectProjectFolderIds(nextTree));
setProjectTreeNodes(nextTree);
setCollapsedFolderIds((current) => current.filter((folderId) => availableFolderIds.has(folderId)));
};
const resetProjectTreeInteractionState = (): void => {
setCollapsedFolderIds([]);
setPendingFolderDraft(null);
setPendingFolderName("");
setDragState(null);
};
const folderIds = (): string[] => collectProjectFolderIds(projectTreeNodes());
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;
}
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(() => {
setProjectTreeNodes(buildProjectTree(appShellData.projectItems()));
setCollapsedFolderIds([]);
setPendingFolderDraft(null);
setPendingFolderName("");
setDragState(null);
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(() => {
@@ -668,7 +784,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
setPendingFolderDraft({ parentId, depth });
};
const submitPendingFolder = (): void => {
const submitPendingFolder = async (): Promise<void> => {
const name = pendingFolderName().trim();
const draft = pendingFolderDraft();
@@ -682,16 +798,31 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
return;
}
setProjectTreeNodes((current) =>
insertProjectFolderNode(current, draft.parentId, {
kind: "folder",
id: createProjectFolderId(),
label: name,
children: [],
}),
);
setPendingFolderDraft(null);
setPendingFolderName("");
try {
const response = await fetch(`${resolveAPIBase()}/projects/${selectedProject().id}/folders`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
parentFolderId: draft.parentId,
}),
});
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 cancelPendingFolder = (): void => {
@@ -724,6 +855,9 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
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;
@@ -752,7 +886,12 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
return;
}
const bounds = event.currentTarget.getBoundingClientRect();
const currentTarget = event.currentTarget;
if (!(currentTarget instanceof HTMLElement)) {
return;
}
const bounds = currentTarget.getBoundingClientRect();
const relativeY = bounds.height <= 0 ? 0.5 : (event.clientY - bounds.top) / bounds.height;
let nextTarget: ProjectDragTarget;
@@ -861,9 +1000,29 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
onContextMenu={handleSurfaceContextMenu}
>
<div class={styles.drawerBody}>
<Show when={!props.compact}>
<div class={styles.treeSectionLabel}>Projects</div>
</Show>
<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()}
@@ -889,6 +1048,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
onSubmitPendingFolder={submitPendingFolder}
onCancelPendingFolder={cancelPendingFolder}
dragState={dragState()}
isTreeClickSuppressed={suppressNextTreeClick}
/>
</div>
</div>