Files
Work/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
T
2026-06-24 17:59:43 +01:00

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