1021 lines
31 KiB
TypeScript
1021 lines
31 KiB
TypeScript
// Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
|
|
|
|
import { For, Show, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js";
|
|
import { resolveAPIBase } from "../../../lib/api";
|
|
import { ChevronLeft, ChevronRight, Folder, ListCollapse, UnfoldVertical } from "../../../lib/icons";
|
|
import { useAppShellData } from "../data/app-shell.context";
|
|
import { ProjectSelector } from "../ProjectSelector/ProjectSelector";
|
|
import {
|
|
collectBranchNodeIds,
|
|
findTreeNodeDepth,
|
|
findTreeNodeLocation,
|
|
getPointerRelativeY,
|
|
isUuidString,
|
|
moveTreeNode,
|
|
resolveTreeDropTarget,
|
|
type NavTreeAdapter,
|
|
type NavTreeDropTarget,
|
|
} from "../shared/navTreeDnd";
|
|
import {
|
|
createWorkspaceStaticTarget,
|
|
createWorkspaceSurfaceTarget,
|
|
createWorkspaceTreeTarget,
|
|
getWorkspaceNodeIcon,
|
|
workspaceSidebarHeaderActions,
|
|
workspaceStaticItems,
|
|
type WorkspaceContextMenuAction,
|
|
type WorkspaceContextMenuTarget,
|
|
type WorkspaceStaticItem,
|
|
type WorkspaceTreeNode,
|
|
} from "../data/shell.data";
|
|
import { WorkspaceContextMenu } from "../WorkspaceContextMenu/WorkspaceContextMenu";
|
|
import { createWorkspaceContextMenuController } from "../WorkspaceContextMenu/createWorkspaceContextMenuController";
|
|
import styles from "./WorkspaceSidebar.module.scss";
|
|
|
|
type WorkspaceSidebarProps = {
|
|
collapsed: boolean;
|
|
railCollapsed: boolean;
|
|
onToggleRailCollapse: () => void;
|
|
};
|
|
|
|
type PendingWorkspaceFolderDraft = {
|
|
parentId: string | null;
|
|
depth: number;
|
|
};
|
|
|
|
type WorkspaceDragTarget = NavTreeDropTarget;
|
|
|
|
type WorkspaceDragState = {
|
|
draggedNodeId: string;
|
|
dropTarget: WorkspaceDragTarget | null;
|
|
};
|
|
|
|
type PersistedWorkspaceFolderRecord = {
|
|
id: string;
|
|
label: string;
|
|
children?: PersistedWorkspaceFolderRecord[];
|
|
};
|
|
|
|
type WorkspaceFoldersResponse = {
|
|
data?: {
|
|
folders?: PersistedWorkspaceFolderRecord[];
|
|
renamedFolder?: PersistedWorkspaceFolderRecord;
|
|
movedFolder?: PersistedWorkspaceFolderRecord;
|
|
previousFolderId?: string;
|
|
};
|
|
error?: string;
|
|
message?: string;
|
|
};
|
|
|
|
type PendingWorkspaceFolderRename = {
|
|
folderId: string;
|
|
depth: number;
|
|
};
|
|
|
|
const LONG_PRESS_MS = 320;
|
|
|
|
const getWorkspaceTreeNodeId = (node: WorkspaceTreeNode): string => node.id;
|
|
|
|
const buildPersistedWorkspaceFolderNodes = (
|
|
folders: readonly PersistedWorkspaceFolderRecord[],
|
|
): WorkspaceTreeNode[] =>
|
|
folders.map((folder) => ({
|
|
id: folder.id,
|
|
label: folder.label,
|
|
kind: "folder",
|
|
icon: Folder,
|
|
children: buildPersistedWorkspaceFolderNodes(folder.children ?? []),
|
|
}));
|
|
|
|
const readPersistedWorkspaceFolders = (body: WorkspaceFoldersResponse): PersistedWorkspaceFolderRecord[] =>
|
|
Array.isArray(body.data?.folders) ? body.data.folders : [];
|
|
|
|
const workspaceTreeAdapter: NavTreeAdapter<WorkspaceTreeNode> = {
|
|
getNodeId: getWorkspaceTreeNodeId,
|
|
isBranchNode: (node) => node.kind === "folder",
|
|
getChildren: (node) => (node.kind === "folder" ? (node.children ?? []) : []),
|
|
withChildren: (node, children) =>
|
|
node.kind === "folder"
|
|
? {
|
|
...node,
|
|
children: [...children],
|
|
}
|
|
: node,
|
|
};
|
|
|
|
const FolderDraftRow = (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 isContextMenuKeyboardTrigger = (event: KeyboardEvent): boolean => event.key === "ContextMenu" || (event.shiftKey && event.key === "F10");
|
|
|
|
const WorkspaceHomeEntry = (props: {
|
|
item: WorkspaceStaticItem;
|
|
onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void;
|
|
onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void;
|
|
}): JSX.Element => {
|
|
const Icon = props.item.icon;
|
|
const target = createWorkspaceStaticTarget(props.item);
|
|
|
|
return (
|
|
<li>
|
|
<button
|
|
type="button"
|
|
classList={{
|
|
[styles.navItem]: true,
|
|
[styles.navItemActive]: !!props.item.active,
|
|
}}
|
|
aria-current={props.item.active ? "page" : undefined}
|
|
aria-label={props.item.label}
|
|
title={props.item.label}
|
|
data-slot="workspace-static-item"
|
|
data-target-kind={target.kind}
|
|
data-active={props.item.active ? "true" : "false"}
|
|
onContextMenu={(event): void => {
|
|
event.stopPropagation();
|
|
props.onOpenContextMenu(event, target);
|
|
}}
|
|
onKeyDown={(event): void => {
|
|
if (!isContextMenuKeyboardTrigger(event)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
props.onOpenContextMenuFromKeyboard(event.currentTarget, target);
|
|
}}
|
|
>
|
|
<Icon class={styles.icon} size={18} strokeWidth={2} />
|
|
<span class={styles.label}>{props.item.label}</span>
|
|
<Show when={props.item.meta}>
|
|
<span class={styles.itemMeta}>{props.item.meta}</span>
|
|
</Show>
|
|
</button>
|
|
</li>
|
|
);
|
|
};
|
|
|
|
const WorkspaceTreeBranch = (props: {
|
|
nodes: readonly WorkspaceTreeNode[];
|
|
parentId?: string | null;
|
|
depth?: number;
|
|
isFolderCollapsed: (folderId: string) => boolean;
|
|
onToggleFolder: (folderId: string) => void;
|
|
pendingFolderDraft: PendingWorkspaceFolderDraft | null;
|
|
pendingFolderName: string;
|
|
onPendingFolderNameChange: (value: string) => void;
|
|
onSubmitPendingFolder: () => void;
|
|
onCancelPendingFolder: () => void;
|
|
pendingFolderRename: PendingWorkspaceFolderRename | null;
|
|
pendingFolderRenameName: string;
|
|
onPendingFolderRenameChange: (value: string) => void;
|
|
onSubmitPendingFolderRename: () => void;
|
|
onCancelPendingFolderRename: () => void;
|
|
onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void;
|
|
onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void;
|
|
onNodePointerDown: (event: PointerEvent, nodeId: string) => void;
|
|
onNodePointerMove: (event: PointerEvent, parentId: string | null, index: number, node: WorkspaceTreeNode) => void;
|
|
dragState: WorkspaceDragState | null;
|
|
isTreeClickSuppressed: () => boolean;
|
|
}): JSX.Element => {
|
|
const depth = () => props.depth ?? 0;
|
|
const parentId = () => props.parentId ?? null;
|
|
|
|
return (
|
|
<ul class={styles.treeList} role="list">
|
|
<Show when={props.nodes.length === 0 && props.pendingFolderDraft?.parentId !== parentId()}>
|
|
<li>
|
|
<div class={styles.treeEmptySlot} style={{ "--tree-depth": String(depth()) }} />
|
|
</li>
|
|
</Show>
|
|
<For each={props.nodes}>
|
|
{(node, indexAccessor): JSX.Element => {
|
|
const Icon = getWorkspaceNodeIcon(node);
|
|
const target = createWorkspaceTreeTarget(node);
|
|
const isCollapsed = (): boolean => (node.kind === "folder" ? props.isFolderCollapsed(node.id) : false);
|
|
const isRenaming = (): boolean => props.pendingFolderRename?.folderId === node.id;
|
|
const isDraggedNode = (): boolean => props.dragState?.draggedNodeId === node.id;
|
|
const dropIntent = (): WorkspaceDragTarget["intent"] | null => {
|
|
if (props.dragState?.dropTarget?.targetNodeId !== node.id) {
|
|
return null;
|
|
}
|
|
|
|
return props.dragState.dropTarget.intent;
|
|
};
|
|
|
|
return (
|
|
<li>
|
|
<Show
|
|
when={node.kind === "folder" && isRenaming()}
|
|
fallback={
|
|
<button
|
|
type="button"
|
|
classList={{
|
|
[styles.treeItem]: true,
|
|
[styles.treeItemActive]: !!node.active,
|
|
[styles.treeItemFolder]: node.kind === "folder",
|
|
[styles.treeItemDragging]: isDraggedNode(),
|
|
[styles.treeItemDropBefore]: dropIntent() === "before",
|
|
[styles.treeItemDropAfter]: dropIntent() === "after",
|
|
[styles.treeItemDropInside]: dropIntent() === "inside",
|
|
}}
|
|
style={{ "--tree-depth": String(depth()) }}
|
|
aria-expanded={node.kind === "folder" ? !isCollapsed() : undefined}
|
|
aria-current={node.active ? "page" : undefined}
|
|
aria-label={node.label}
|
|
title={node.label}
|
|
data-slot="workspace-tree-item"
|
|
data-kind={node.kind}
|
|
data-item-type={node.kind === "item" ? node.itemType : undefined}
|
|
data-active={node.active ? "true" : "false"}
|
|
onClick={(): void => {
|
|
if (props.dragState || props.isTreeClickSuppressed()) {
|
|
return;
|
|
}
|
|
|
|
if (node.kind !== "folder") {
|
|
return;
|
|
}
|
|
|
|
props.onToggleFolder(node.id);
|
|
}}
|
|
onContextMenu={(event): void => {
|
|
event.stopPropagation();
|
|
props.onOpenContextMenu(event, target);
|
|
}}
|
|
onPointerDown={(event): void => props.onNodePointerDown(event, node.id)}
|
|
onPointerMove={(event): void =>
|
|
props.onNodePointerMove(event, parentId(), indexAccessor(), node)
|
|
}
|
|
onPointerEnter={(event): void =>
|
|
props.onNodePointerMove(event, parentId(), indexAccessor(), node)
|
|
}
|
|
onKeyDown={(event): void => {
|
|
if (!isContextMenuKeyboardTrigger(event)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
props.onOpenContextMenuFromKeyboard(event.currentTarget, target);
|
|
}}
|
|
>
|
|
<Show when={node.kind === "folder"}>
|
|
<ChevronRight
|
|
classList={{
|
|
[styles.folderChevron]: true,
|
|
[styles.folderChevronOpen]: !isCollapsed(),
|
|
}}
|
|
size={16}
|
|
strokeWidth={2}
|
|
/>
|
|
</Show>
|
|
<Icon 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>
|
|
}
|
|
>
|
|
<FolderDraftRow
|
|
depth={props.pendingFolderRename?.depth ?? depth()}
|
|
value={props.pendingFolderRenameName}
|
|
onInput={props.onPendingFolderRenameChange}
|
|
onSubmit={props.onSubmitPendingFolderRename}
|
|
onCancel={props.onCancelPendingFolderRename}
|
|
/>
|
|
</Show>
|
|
|
|
<Show when={node.kind === "folder" && !isCollapsed() && (((node.children?.length ?? 0) > 0) || props.pendingFolderDraft?.parentId === node.id)}>
|
|
<WorkspaceTreeBranch
|
|
nodes={node.children ?? []}
|
|
parentId={node.id}
|
|
depth={depth() + 1}
|
|
isFolderCollapsed={props.isFolderCollapsed}
|
|
onToggleFolder={props.onToggleFolder}
|
|
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}
|
|
onOpenContextMenu={props.onOpenContextMenu}
|
|
onOpenContextMenuFromKeyboard={props.onOpenContextMenuFromKeyboard}
|
|
onNodePointerDown={props.onNodePointerDown}
|
|
onNodePointerMove={props.onNodePointerMove}
|
|
dragState={props.dragState}
|
|
isTreeClickSuppressed={props.isTreeClickSuppressed}
|
|
/>
|
|
</Show>
|
|
</li>
|
|
);
|
|
}}
|
|
</For>
|
|
|
|
<Show when={props.pendingFolderDraft && props.pendingFolderDraft.parentId === parentId()}>
|
|
<FolderDraftRow
|
|
depth={props.pendingFolderDraft?.depth ?? depth()}
|
|
value={props.pendingFolderName}
|
|
onInput={props.onPendingFolderNameChange}
|
|
onSubmit={props.onSubmitPendingFolder}
|
|
onCancel={props.onCancelPendingFolder}
|
|
/>
|
|
</Show>
|
|
</ul>
|
|
);
|
|
};
|
|
|
|
export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
|
|
const appShellData = useAppShellData();
|
|
const activeProject = () => appShellData.activeProject();
|
|
const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false);
|
|
const [workspaceTreeNodes, setWorkspaceTreeNodes] = createSignal<readonly WorkspaceTreeNode[]>(appShellData.workspaceTree());
|
|
const [persistedFolders, setPersistedFolders] = createSignal<readonly PersistedWorkspaceFolderRecord[]>([]);
|
|
const [collapsedFolderIds, setCollapsedFolderIds] = createSignal<readonly string[]>([]);
|
|
const [pendingFolderDraft, setPendingFolderDraft] = createSignal<PendingWorkspaceFolderDraft | null>(null);
|
|
const [pendingFolderName, setPendingFolderName] = createSignal("");
|
|
const [pendingFolderRename, setPendingFolderRename] = createSignal<PendingWorkspaceFolderRename | null>(null);
|
|
const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal("");
|
|
const [dragState, setDragState] = createSignal<WorkspaceDragState | null>(null);
|
|
const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false);
|
|
let lastSelectedProjectId: string | null = null;
|
|
let latestPersistedFoldersRequest = 0;
|
|
const contextMenu = createWorkspaceContextMenuController();
|
|
let longPressTimer: number | undefined;
|
|
let suppressClickTimer: number | undefined;
|
|
const railToggleLabel = (): string => (props.railCollapsed ? "Expand server rail" : "Collapse server rail");
|
|
const sidebarContextMenuTarget = createWorkspaceSurfaceTarget(appShellData.activeProject());
|
|
const isFolderCollapsed = (folderId: string): boolean => collapsedFolderIds().includes(folderId);
|
|
const folderIds = (): string[] => collectBranchNodeIds(workspaceTreeNodes(), workspaceTreeAdapter);
|
|
const totalFolderCount = (): number => folderIds().length;
|
|
const areAllFoldersCollapsed = (): boolean => {
|
|
const count = totalFolderCount();
|
|
return count > 0 && collapsedFolderIds().length >= count;
|
|
};
|
|
const workspaceFolderToggleLabel = (): string =>
|
|
areAllFoldersCollapsed() ? "Expand all folders" : "Collapse all folders";
|
|
const toggleFolder = (folderId: string): void => {
|
|
setCollapsedFolderIds((current) =>
|
|
current.includes(folderId) ? current.filter((id) => id !== folderId) : [...current, folderId],
|
|
);
|
|
};
|
|
const expandAllFolders = (): void => {
|
|
setCollapsedFolderIds([]);
|
|
};
|
|
const collapseAllFolders = (): void => {
|
|
setCollapsedFolderIds(folderIds());
|
|
};
|
|
const toggleAllFolders = (): void => {
|
|
if (areAllFoldersCollapsed()) {
|
|
expandAllFolders();
|
|
return;
|
|
}
|
|
|
|
collapseAllFolders();
|
|
};
|
|
const resetWorkspaceTreeInteractionState = (): void => {
|
|
setCollapsedFolderIds([]);
|
|
setPendingFolderDraft(null);
|
|
setPendingFolderName("");
|
|
setPendingFolderRename(null);
|
|
setPendingFolderRenameName("");
|
|
setDragState(null);
|
|
};
|
|
const syncWorkspaceTree = (): void => {
|
|
const nextTree = activeProject()?.id
|
|
? buildPersistedWorkspaceFolderNodes(persistedFolders())
|
|
: appShellData.workspaceTree();
|
|
const availableFolderIds = new Set(collectBranchNodeIds(nextTree, workspaceTreeAdapter));
|
|
|
|
setWorkspaceTreeNodes(nextTree);
|
|
setCollapsedFolderIds((current) => current.filter((id) => availableFolderIds.has(id)));
|
|
};
|
|
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}/tree/folders`, {
|
|
headers: {
|
|
Accept: "application/json",
|
|
},
|
|
});
|
|
const body = (await response.json()) as WorkspaceFoldersResponse;
|
|
|
|
if (requestId !== latestPersistedFoldersRequest) {
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error(body.message || "Failed to load project tree folders.");
|
|
}
|
|
|
|
setPersistedFolders(readPersistedWorkspaceFolders(body));
|
|
} catch (error) {
|
|
if (requestId !== latestPersistedFoldersRequest) {
|
|
return;
|
|
}
|
|
|
|
console.error(error);
|
|
setPersistedFolders([]);
|
|
}
|
|
};
|
|
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);
|
|
};
|
|
|
|
createEffect(() => {
|
|
syncWorkspaceTree();
|
|
});
|
|
|
|
createEffect(() => {
|
|
const projectId = activeProject()?.id ?? null;
|
|
|
|
if (lastSelectedProjectId === null) {
|
|
lastSelectedProjectId = projectId;
|
|
return;
|
|
}
|
|
|
|
if (projectId === lastSelectedProjectId) {
|
|
return;
|
|
}
|
|
|
|
lastSelectedProjectId = projectId;
|
|
resetWorkspaceTreeInteractionState();
|
|
});
|
|
|
|
createEffect(() => {
|
|
void loadPersistedFolders(activeProject()?.id ?? "");
|
|
});
|
|
|
|
onMount(() => {
|
|
const handlePointerUp = (): void => {
|
|
clearLongPressTimer();
|
|
|
|
const nextDragState = dragState();
|
|
|
|
if (!nextDragState?.dropTarget) {
|
|
if (nextDragState) {
|
|
suppressTreeClickTemporarily();
|
|
}
|
|
setDragState(null);
|
|
return;
|
|
}
|
|
|
|
suppressTreeClickTemporarily();
|
|
|
|
const currentNodes = workspaceTreeNodes();
|
|
const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, workspaceTreeAdapter);
|
|
const persistedParentId = nextDragState.dropTarget.parentId;
|
|
const canPersistMove = isUuidString(activeProject()?.id ?? "");
|
|
const persistedParentLocation = persistedParentId
|
|
? findTreeNodeLocation(currentNodes, persistedParentId, workspaceTreeAdapter)
|
|
: null;
|
|
|
|
if (
|
|
canPersistMove &&
|
|
draggedLocation?.node.kind === "folder" &&
|
|
(persistedParentId === null || persistedParentLocation?.node.kind === "folder")
|
|
) {
|
|
void movePersistedFolder(draggedLocation.node.id, persistedParentId);
|
|
} else {
|
|
setWorkspaceTreeNodes((current) =>
|
|
moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter),
|
|
);
|
|
}
|
|
|
|
setDragState(null);
|
|
};
|
|
|
|
const handleEscape = (event: KeyboardEvent): void => {
|
|
if (event.key !== "Escape") {
|
|
return;
|
|
}
|
|
|
|
clearLongPressTimer();
|
|
|
|
if (dragState()) {
|
|
setDragState(null);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("pointerup", handlePointerUp);
|
|
window.addEventListener("pointercancel", handlePointerUp);
|
|
window.addEventListener("keydown", handleEscape);
|
|
|
|
onCleanup(() => {
|
|
clearLongPressTimer();
|
|
if (suppressClickTimer !== undefined) {
|
|
window.clearTimeout(suppressClickTimer);
|
|
}
|
|
window.removeEventListener("pointerup", handlePointerUp);
|
|
window.removeEventListener("pointercancel", handlePointerUp);
|
|
window.removeEventListener("keydown", handleEscape);
|
|
});
|
|
});
|
|
|
|
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 submitPendingFolder = async (): Promise<void> => {
|
|
const name = pendingFolderName().trim();
|
|
const draft = pendingFolderDraft();
|
|
const projectId = activeProject()?.id ?? "";
|
|
|
|
if (!draft) {
|
|
return;
|
|
}
|
|
|
|
if (!name) {
|
|
setPendingFolderDraft(null);
|
|
setPendingFolderName("");
|
|
return;
|
|
}
|
|
|
|
if (!projectId || !isUuidString(projectId)) {
|
|
cancelPendingFolder();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders`, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
name,
|
|
parentFolderId: draft.parentId,
|
|
}),
|
|
});
|
|
|
|
const body = (await response.json()) as WorkspaceFoldersResponse;
|
|
|
|
if (!response.ok) {
|
|
throw new Error(body.message || "Failed to create project tree folder.");
|
|
}
|
|
|
|
setPersistedFolders(readPersistedWorkspaceFolders(body));
|
|
setPendingFolderDraft(null);
|
|
setPendingFolderName("");
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
};
|
|
|
|
const deletePersistedFolder = async (folderId: string): Promise<void> => {
|
|
const projectId = activeProject()?.id ?? "";
|
|
if (!folderId || !projectId || !isUuidString(projectId)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`${resolveAPIBase()}/projects/${projectId}/tree/folders?folderId=${encodeURIComponent(folderId)}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: {
|
|
Accept: "application/json",
|
|
},
|
|
},
|
|
);
|
|
|
|
const body = (await response.json()) as WorkspaceFoldersResponse;
|
|
|
|
if (!response.ok) {
|
|
throw new Error(body.message || "Failed to delete project tree folder.");
|
|
}
|
|
|
|
setPersistedFolders(readPersistedWorkspaceFolders(body));
|
|
setCollapsedFolderIds((current) => current.filter((id) => id !== folderId));
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
};
|
|
|
|
const movePersistedFolder = async (folderId: string, parentFolderId: string | null): Promise<void> => {
|
|
const projectId = activeProject()?.id ?? "";
|
|
if (!folderId || !projectId || !isUuidString(projectId)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders/move`, {
|
|
method: "PATCH",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
folderId,
|
|
parentFolderId,
|
|
}),
|
|
});
|
|
|
|
const body = (await response.json()) as WorkspaceFoldersResponse;
|
|
|
|
if (!response.ok) {
|
|
throw new Error(body.message || "Failed to move project tree folder.");
|
|
}
|
|
|
|
setPersistedFolders(readPersistedWorkspaceFolders(body));
|
|
|
|
const previousFolderId = body.data?.previousFolderId;
|
|
const movedFolderId = body.data?.movedFolder?.id;
|
|
if (previousFolderId && movedFolderId && previousFolderId !== movedFolderId) {
|
|
setCollapsedFolderIds((current) =>
|
|
current.map((id) => (id === previousFolderId ? movedFolderId : id)),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
};
|
|
|
|
const submitPendingFolderRename = async (): Promise<void> => {
|
|
const draft = pendingFolderRename();
|
|
const name = pendingFolderRenameName().trim();
|
|
const projectId = activeProject()?.id ?? "";
|
|
|
|
if (!draft) {
|
|
return;
|
|
}
|
|
|
|
if (!name) {
|
|
setPendingFolderRename(null);
|
|
setPendingFolderRenameName("");
|
|
return;
|
|
}
|
|
|
|
if (!projectId || !isUuidString(projectId)) {
|
|
cancelPendingFolderRename();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders`, {
|
|
method: "PATCH",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
folderId: draft.folderId,
|
|
name,
|
|
}),
|
|
});
|
|
|
|
const body = (await response.json()) as WorkspaceFoldersResponse;
|
|
|
|
if (!response.ok) {
|
|
throw new Error(body.message || "Failed to rename project tree folder.");
|
|
}
|
|
|
|
setPersistedFolders(readPersistedWorkspaceFolders(body));
|
|
setPendingFolderRename(null);
|
|
setPendingFolderRenameName("");
|
|
|
|
const previousFolderId = body.data?.previousFolderId;
|
|
const renamedFolderId = body.data?.renamedFolder?.id;
|
|
if (previousFolderId && renamedFolderId && previousFolderId !== renamedFolderId) {
|
|
setCollapsedFolderIds((current) =>
|
|
current.map((id) => (id === previousFolderId ? renamedFolderId : id)),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
};
|
|
|
|
const cancelPendingFolder = (): void => {
|
|
setPendingFolderDraft(null);
|
|
setPendingFolderName("");
|
|
};
|
|
|
|
const cancelPendingFolderRename = (): void => {
|
|
setPendingFolderRename(null);
|
|
setPendingFolderRenameName("");
|
|
};
|
|
|
|
const handleHeaderActionClick = (actionId: string): void => {
|
|
switch (actionId) {
|
|
case "toggle-workspace-folders":
|
|
toggleAllFolders();
|
|
return;
|
|
case "search-workspace":
|
|
default:
|
|
return;
|
|
}
|
|
};
|
|
|
|
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: WorkspaceTreeNode,
|
|
): void => {
|
|
const nextDragState = dragState();
|
|
|
|
if (!nextDragState || nextDragState.draggedNodeId === getWorkspaceTreeNodeId(node)) {
|
|
return;
|
|
}
|
|
|
|
const relativeY = getPointerRelativeY(event);
|
|
if (relativeY === null) {
|
|
return;
|
|
}
|
|
|
|
setDragState({
|
|
...nextDragState,
|
|
dropTarget: resolveTreeDropTarget({
|
|
parentId,
|
|
index,
|
|
node,
|
|
relativeY,
|
|
adapter: workspaceTreeAdapter,
|
|
}),
|
|
});
|
|
};
|
|
|
|
const handleContextActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
|
|
switch (action.id) {
|
|
case "new-folder":
|
|
switch (target.kind) {
|
|
case "workspace":
|
|
case "home":
|
|
beginFolderDraft(null, 0);
|
|
return;
|
|
case "folder":
|
|
beginFolderDraft(target.id, (findTreeNodeDepth(workspaceTreeNodes(), target.id, workspaceTreeAdapter) ?? 0) + 1);
|
|
return;
|
|
case "settings":
|
|
case "item":
|
|
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(workspaceTreeNodes(), target.id, workspaceTreeAdapter) ?? 0);
|
|
}
|
|
return;
|
|
default:
|
|
return;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<aside
|
|
classList={{
|
|
[styles.sidebar]: true,
|
|
[styles.sidebarCollapsed]: props.collapsed,
|
|
[styles.sidebarDragMode]: !!dragState(),
|
|
}}
|
|
aria-label="Left workspace sidebar"
|
|
data-ui="workspace-sidebar"
|
|
data-collapsed={props.collapsed ? "true" : "false"}
|
|
onContextMenu={(event): void => {
|
|
contextMenu.openMenu(event, sidebarContextMenuTarget);
|
|
}}
|
|
>
|
|
<div
|
|
classList={{
|
|
[styles.header]: true,
|
|
[styles.headerDrawerOpen]: isProjectDrawerOpen(),
|
|
}}
|
|
data-slot="workspace-sidebar-header"
|
|
data-drawer-open={isProjectDrawerOpen() ? "true" : "false"}
|
|
>
|
|
<div class={styles.headerActions} data-slot="workspace-sidebar-header-actions">
|
|
<button
|
|
type="button"
|
|
classList={{
|
|
[styles.headerActionButton]: true,
|
|
[styles.headerCollapseButton]: true,
|
|
}}
|
|
aria-label={railToggleLabel()}
|
|
title={railToggleLabel()}
|
|
data-slot="workspace-sidebar-rail-toggle"
|
|
onClick={props.onToggleRailCollapse}
|
|
>
|
|
{props.railCollapsed ? <ChevronRight size={16} strokeWidth={2} /> : <ChevronLeft size={16} strokeWidth={2} />}
|
|
</button>
|
|
|
|
<For each={workspaceSidebarHeaderActions}>
|
|
{(action): JSX.Element => {
|
|
const label =
|
|
action.id === "toggle-workspace-folders"
|
|
? workspaceFolderToggleLabel()
|
|
: action.label;
|
|
const Icon =
|
|
action.id === "toggle-workspace-folders"
|
|
? areAllFoldersCollapsed()
|
|
? UnfoldVertical
|
|
: ListCollapse
|
|
: action.icon;
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
class={styles.headerActionButton}
|
|
aria-label={label}
|
|
title={label}
|
|
data-slot="workspace-sidebar-header-action"
|
|
data-action-id={action.id}
|
|
disabled={action.id === "toggle-workspace-folders" && totalFolderCount() === 0}
|
|
onClick={(): void => handleHeaderActionClick(action.id)}
|
|
>
|
|
<Icon size={16} strokeWidth={2} />
|
|
</button>
|
|
);
|
|
}}
|
|
</For>
|
|
</div>
|
|
|
|
<div class={styles.headerControls} data-slot="workspace-sidebar-header-controls">
|
|
<ProjectSelector
|
|
compact={props.collapsed}
|
|
isOpen={isProjectDrawerOpen()}
|
|
onToggle={(): void => {
|
|
setIsProjectDrawerOpen(true);
|
|
}}
|
|
onClose={(): void => {
|
|
setIsProjectDrawerOpen(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
classList={{
|
|
[styles.section]: true,
|
|
[styles.sectionHidden]: isProjectDrawerOpen(),
|
|
}}
|
|
data-slot="workspace-sidebar-section"
|
|
>
|
|
<Show when={!props.collapsed}>
|
|
<span class={styles.sectionLabel}>Workspace</span>
|
|
</Show>
|
|
<div class={styles.navScroller} data-slot="workspace-sidebar-nav-scroller">
|
|
<ul class={styles.navList} role="list" data-slot="workspace-static-list">
|
|
<For each={workspaceStaticItems}>
|
|
{(item): JSX.Element => (
|
|
<WorkspaceHomeEntry
|
|
item={item}
|
|
onOpenContextMenu={contextMenu.openMenu}
|
|
onOpenContextMenuFromKeyboard={contextMenu.openMenuFromElement}
|
|
/>
|
|
)}
|
|
</For>
|
|
</ul>
|
|
|
|
<Show when={!props.collapsed}>
|
|
<div class={styles.treeSectionLabel}>Items</div>
|
|
</Show>
|
|
|
|
<div data-slot="workspace-tree-root">
|
|
<WorkspaceTreeBranch
|
|
nodes={workspaceTreeNodes()}
|
|
parentId={null}
|
|
isFolderCollapsed={isFolderCollapsed}
|
|
onToggleFolder={toggleFolder}
|
|
pendingFolderDraft={pendingFolderDraft()}
|
|
pendingFolderName={pendingFolderName()}
|
|
onPendingFolderNameChange={setPendingFolderName}
|
|
onSubmitPendingFolder={submitPendingFolder}
|
|
onCancelPendingFolder={cancelPendingFolder}
|
|
pendingFolderRename={pendingFolderRename()}
|
|
pendingFolderRenameName={pendingFolderRenameName()}
|
|
onPendingFolderRenameChange={setPendingFolderRenameName}
|
|
onSubmitPendingFolderRename={submitPendingFolderRename}
|
|
onCancelPendingFolderRename={cancelPendingFolderRename}
|
|
onOpenContextMenu={contextMenu.openMenu}
|
|
onOpenContextMenuFromKeyboard={contextMenu.openMenuFromElement}
|
|
onNodePointerDown={handleNodePointerDown}
|
|
onNodePointerMove={handleNodePointerMove}
|
|
dragState={dragState()}
|
|
isTreeClickSuppressed={suppressNextTreeClick}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<WorkspaceContextMenu
|
|
target={contextMenu.menuState()?.target ?? null}
|
|
position={(() => {
|
|
const state = contextMenu.menuState();
|
|
return state
|
|
? {
|
|
x: state.x,
|
|
y: state.y,
|
|
}
|
|
: null;
|
|
})()}
|
|
menuRef={contextMenu.setMenuRef}
|
|
onClose={contextMenu.closeMenu}
|
|
onSelect={handleContextActionSelect}
|
|
/>
|
|
</>
|
|
);
|
|
};
|