diff --git a/Frontend/src/app.tsx b/Frontend/src/app.tsx
index 50b94e2..6a0d34c 100644
--- a/Frontend/src/app.tsx
+++ b/Frontend/src/app.tsx
@@ -1,7 +1,7 @@
// Path: Frontend/src/app.tsx
import type { JSX } from "solid-js";
-import { AppShell } from "./components/shell/AppShell/AppShell";
+import { AppShell } from "./components/app-shell/AppShell/AppShell";
import "./styles/main.scss";
import "./styles/user-overrides.scss";
diff --git a/Frontend/src/components/shell/AppShell/AppShell.module.scss b/Frontend/src/components/app-shell/AppShell/AppShell.module.scss
similarity index 100%
rename from Frontend/src/components/shell/AppShell/AppShell.module.scss
rename to Frontend/src/components/app-shell/AppShell/AppShell.module.scss
diff --git a/Frontend/src/components/shell/AppShell/AppShell.tsx b/Frontend/src/components/app-shell/AppShell/AppShell.tsx
similarity index 81%
rename from Frontend/src/components/shell/AppShell/AppShell.tsx
rename to Frontend/src/components/app-shell/AppShell/AppShell.tsx
index 9365125..2e5baa0 100644
--- a/Frontend/src/components/shell/AppShell/AppShell.tsx
+++ b/Frontend/src/components/app-shell/AppShell/AppShell.tsx
@@ -1,17 +1,19 @@
-// Path: Frontend/src/components/shell/AppShell/AppShell.tsx
+// Path: Frontend/src/components/app-shell/AppShell/AppShell.tsx
import { createSignal, onCleanup, onMount, Show, type JSX } from "solid-js";
-import { getDocumentTheme, setTheme, type Theme } from "../../../theme/runtime";
-import { WorkspaceHome } from "../../workspace-home/WorkspaceHome/WorkspaceHome";
+import { getDocumentTheme, setTheme, type Theme } from "../../../helper/themeRuntime";
+import { BootstrapWizard } from "../../bootstrap/BootstrapWizard/BootstrapWizard";
+import { WorkspaceHome } from "../../workspace-home/WorkspaceHome";
import { AppShellDataProvider, useAppShellData } from "../data/app-shell.context";
-import { LeftRail } from "../LeftRail/LeftRail";
+import { LeftRail } from "../../workspace-navigation/LeftRail/LeftRail";
import { MobileBottomNav } from "../MobileBottomNav/MobileBottomNav";
-import { MobileWorkspaceBrowser } from "../MobileWorkspaceBrowser/MobileWorkspaceBrowser";
+import { MobileWorkspaceBrowser } from "../../workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser";
import { ServerDock } from "../ServerDock/ServerDock";
-import { NotificationsMenu } from "../TopBar/NotificationsMenu";
-import { ProfileMenu } from "../TopBar/ProfileMenu";
-import { TopBar } from "../TopBar/TopBar";
-import { WorkspaceSidebar } from "../WorkspaceSidebar/WorkspaceSidebar";
+import { NotificationsMenu } from "../../top-bar/TopBar/NotificationsMenu";
+import { ProfileMenu } from "../../top-bar/TopBar/ProfileMenu";
+import { TopBar } from "../../top-bar/TopBar/TopBar";
+import { WorkspaceTopBar } from "../../workspace-navigation/WorkspaceTopBar/WorkspaceTopBar";
+import { WorkspaceSidebar } from "../../workspace-navigation/WorkspaceSidebar/WorkspaceSidebar";
import styles from "./AppShell.module.scss";
type MobileWorkspaceView = "notifications" | "profile" | null;
@@ -80,6 +82,8 @@ const AppShellContent = (): JSX.Element => {
setActiveMobileWorkspaceView(null);
};
+ const workspaceBreadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`;
+
return (
{
{
- setIsSidebarCollapsed((collapsed) => !collapsed);
- }}
- />
+ <>
+ {
+ setIsSidebarCollapsed((collapsed) => !collapsed);
+ }}
+ />
+
+ >
}
>
@@ -162,6 +170,7 @@ const AppShellContent = (): JSX.Element => {
setIsMobileWorkspaceBrowserOpen(false);
}}
/>
+
);
};
diff --git a/Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.module.scss b/Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.module.scss
similarity index 100%
rename from Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.module.scss
rename to Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.module.scss
diff --git a/Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.tsx b/Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.tsx
similarity index 95%
rename from Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.tsx
rename to Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.tsx
index cfb1711..84ef430 100644
--- a/Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.tsx
+++ b/Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.tsx
@@ -1,4 +1,4 @@
-// Path: Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.tsx
+// Path: Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.tsx
import { For, type JSX } from "solid-js";
import { useAppShellData } from "../data/app-shell.context";
diff --git a/Frontend/src/components/shell/ServerDock/ServerDock.module.scss b/Frontend/src/components/app-shell/ServerDock/ServerDock.module.scss
similarity index 100%
rename from Frontend/src/components/shell/ServerDock/ServerDock.module.scss
rename to Frontend/src/components/app-shell/ServerDock/ServerDock.module.scss
diff --git a/Frontend/src/components/shell/ServerDock/ServerDock.tsx b/Frontend/src/components/app-shell/ServerDock/ServerDock.tsx
similarity index 95%
rename from Frontend/src/components/shell/ServerDock/ServerDock.tsx
rename to Frontend/src/components/app-shell/ServerDock/ServerDock.tsx
index 580abc7..2cd568e 100644
--- a/Frontend/src/components/shell/ServerDock/ServerDock.tsx
+++ b/Frontend/src/components/app-shell/ServerDock/ServerDock.tsx
@@ -1,4 +1,4 @@
-// Path: Frontend/src/components/shell/ServerDock/ServerDock.tsx
+// Path: Frontend/src/components/app-shell/ServerDock/ServerDock.tsx
import { For, Show, type JSX } from "solid-js";
import { useAppShellData } from "../data/app-shell.context";
diff --git a/Frontend/src/components/app-shell/data/app-shell.builders.ts b/Frontend/src/components/app-shell/data/app-shell.builders.ts
new file mode 100644
index 0000000..f6ecd55
--- /dev/null
+++ b/Frontend/src/components/app-shell/data/app-shell.builders.ts
@@ -0,0 +1,169 @@
+// Path: Frontend/src/components/app-shell/data/app-shell.builders.ts
+
+import {
+ activeDepartment as fallbackActiveDepartment,
+ activeProject as fallbackActiveProject,
+ activeServer as fallbackActiveServer,
+ activeUserProfile as fallbackActiveUserProfile,
+ departmentItems as fallbackDepartmentItems,
+ organizationAdminDockActions,
+ personalDockActions,
+ projectItems as fallbackProjectItems,
+ railItems as fallbackRailItems,
+ workspaceTree as fallbackWorkspaceTree,
+ type ActiveDepartment,
+ type ActiveProject,
+ type ActiveServer,
+ type ActiveUserProfile,
+ type DepartmentItem,
+ type ProjectItem,
+ type RailItem,
+ type WorkspaceTreeNode,
+} from "./shell.data";
+import type { AppShellPayload } from "./app-shell.types";
+
+const buildAbbreviation = (name: string, fallback: string): string => {
+ const parts = name
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean);
+
+ if (parts.length === 0) {
+ return fallback;
+ }
+
+ const abbreviation = parts
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase() ?? "")
+ .join("");
+
+ return abbreviation || fallback;
+};
+
+export const buildRailItems = (payload: AppShellPayload | null): readonly RailItem[] => {
+ if (!payload?.installation || payload.organizations.length === 0) {
+ return fallbackRailItems;
+ }
+
+ const kind = payload.installation.mode === "personal" ? "personal" : "organization";
+ const serverName = payload.installation.name || payload.organizations[0]?.name || payload.installation.host;
+
+ return payload.organizations.map((organization, index) => ({
+ id: organization.id,
+ label: serverName || organization.name,
+ abbreviation: buildAbbreviation(serverName || organization.name, kind === "personal" ? "P" : "O"),
+ kind,
+ active: index === 0,
+ }));
+};
+
+export const buildActiveServer = (payload: AppShellPayload | null): ActiveServer => {
+ const installation = payload?.installation;
+ const organization = payload?.organizations[0];
+
+ if (!installation || !organization) {
+ return fallbackActiveServer;
+ }
+
+ const kind = installation.mode === "personal" ? "personal" : "organization";
+ const serverName = installation.name || organization.name || installation.host;
+
+ return {
+ id: installation.id,
+ name: serverName || fallbackActiveServer.name,
+ abbreviation: buildAbbreviation(serverName, kind === "personal" ? "P" : "O"),
+ kind,
+ connectedLabel: kind === "organization" ? `${payload?.teams.length ?? 0} connected` : undefined,
+ subtitle: kind === "personal" ? installation.host || payload?.admin?.homeTitle || "Personal home" : undefined,
+ dockActions: kind === "personal" ? personalDockActions : organizationAdminDockActions,
+ };
+};
+
+export const buildProjectItems = (payload: AppShellPayload | null): readonly ProjectItem[] => {
+ if (!payload?.projects.length) {
+ return fallbackProjectItems;
+ }
+
+ return payload.projects.map((project, index) => ({
+ id: project.id,
+ name: project.name,
+ description: project.slug || "Persisted project workspace",
+ groupLabel: payload.departments.find((department) => department.id === project.departmentId)?.name || "Projects",
+ parentLabel:
+ payload.teams.find((team) => team.id === project.teamId)?.name ||
+ payload.departments.find((department) => department.id === project.departmentId)?.name ||
+ "Shared project",
+ meta: (() => {
+ const workspaceCount = payload.workspaces.filter((workspace) => workspace.projectId === project.id).length;
+
+ return workspaceCount > 0 ? `${workspaceCount} workspace${workspaceCount === 1 ? "" : "s"}` : undefined;
+ })(),
+ active: index === 0,
+ }));
+};
+
+export const buildActiveProject = (payload: AppShellPayload | null): ActiveProject => {
+ const firstProject = payload?.projects[0];
+
+ if (!firstProject) {
+ return fallbackActiveProject;
+ }
+
+ return {
+ id: firstProject.id,
+ name: firstProject.name,
+ };
+};
+
+export const buildDepartmentItems = (payload: AppShellPayload | null): readonly DepartmentItem[] => {
+ if (!payload?.departments.length) {
+ return fallbackDepartmentItems;
+ }
+
+ return payload.departments.map((department, index) => ({
+ id: department.id,
+ name: department.name,
+ teams: payload.teams.filter((team) => team.departmentId === department.id).map((team) => team.name),
+ active: index === 0,
+ }));
+};
+
+export const buildActiveDepartment = (payload: AppShellPayload | null): ActiveDepartment => {
+ const firstDepartment = payload?.departments[0];
+
+ if (!firstDepartment) {
+ return fallbackActiveDepartment;
+ }
+
+ const firstTeamName = payload?.teams.find((team) => team.departmentId === firstDepartment.id)?.name ?? "";
+
+ return {
+ id: firstDepartment.id,
+ name: firstDepartment.name,
+ teamName: firstTeamName,
+ };
+};
+
+export const buildWorkspaceTree = (payload: AppShellPayload | null): readonly WorkspaceTreeNode[] => {
+ if (!payload?.projects.length) {
+ return fallbackWorkspaceTree;
+ }
+
+ return [];
+};
+
+export const buildActiveUserProfile = (payload: AppShellPayload | null): ActiveUserProfile => {
+ if (!payload?.admin) {
+ return fallbackActiveUserProfile;
+ }
+
+ const organizationName = payload.installation?.name || payload.organizations[0]?.name || fallbackActiveServer.name;
+ const departmentName = payload.departments[0]?.name;
+
+ return {
+ name: payload.admin.displayName,
+ email: payload.admin.email,
+ roleLabel: payload.admin.isInstanceAdmin ? "Instance admin" : "Member",
+ contextLabel: departmentName ? `${organizationName} • ${departmentName}` : organizationName,
+ };
+};
diff --git a/Frontend/src/components/app-shell/data/app-shell.context.tsx b/Frontend/src/components/app-shell/data/app-shell.context.tsx
new file mode 100644
index 0000000..75c1a6c
--- /dev/null
+++ b/Frontend/src/components/app-shell/data/app-shell.context.tsx
@@ -0,0 +1,97 @@
+// Path: Frontend/src/components/app-shell/data/app-shell.context.tsx
+
+import {
+ createContext,
+ createMemo,
+ createSignal,
+ onMount,
+ useContext,
+ type JSX,
+} from "solid-js";
+import { resolveAPIBase } from "../../../lib/api";
+import {
+ buildActiveDepartment,
+ buildActiveProject,
+ buildActiveServer,
+ buildActiveUserProfile,
+ buildDepartmentItems,
+ buildProjectItems,
+ buildRailItems,
+ buildWorkspaceTree,
+} from "./app-shell.builders";
+import type { AppShellContextValue, AppShellPayload } from "./app-shell.types";
+import { normalizeAppShellPayload } from "./app-shell.types";
+
+const AppShellContext = createContext();
+
+export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Element => {
+ const [status, setStatus] = createSignal<"idle" | "loading" | "success" | "error">("idle");
+ const [error, setError] = createSignal("");
+ const [payload, setPayload] = createSignal(null);
+
+ const load = async (): Promise => {
+ setStatus("loading");
+ setError("");
+
+ try {
+ const response = await fetch(`${resolveAPIBase()}/app-shell`, {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+
+ const body = (await response.json()) as {
+ data?: AppShellPayload;
+ error?: { message?: string } | string;
+ message?: string;
+ };
+ const errorMessage =
+ typeof body.message === "string"
+ ? body.message
+ : typeof body.error === "string"
+ ? body.error
+ : body.error?.message;
+
+ if (!response.ok || !body.data) {
+ throw new Error(errorMessage || "Failed to load app shell state.");
+ }
+
+ setPayload(normalizeAppShellPayload(body.data));
+ setStatus("success");
+ } catch (loadError) {
+ setStatus("error");
+ setError(loadError instanceof Error ? loadError.message : "Failed to load app shell state.");
+ }
+ };
+
+ onMount(() => {
+ void load();
+ });
+
+ const value: AppShellContextValue = {
+ status,
+ error,
+ installation: createMemo(() => payload()?.installation),
+ railItems: createMemo(() => buildRailItems(payload())),
+ activeServer: createMemo(() => buildActiveServer(payload())),
+ activeProject: createMemo(() => buildActiveProject(payload())),
+ activeDepartment: createMemo(() => buildActiveDepartment(payload())),
+ projectItems: createMemo(() => buildProjectItems(payload())),
+ departmentItems: createMemo(() => buildDepartmentItems(payload())),
+ workspaceTree: createMemo(() => buildWorkspaceTree(payload())),
+ activeUserProfile: createMemo(() => buildActiveUserProfile(payload())),
+ reload: load,
+ };
+
+ return {props.children};
+};
+
+export const useAppShellData = (): AppShellContextValue => {
+ const context = useContext(AppShellContext);
+
+ if (!context) {
+ throw new Error("useAppShellData must be used within AppShellDataProvider");
+ }
+
+ return context;
+};
diff --git a/Frontend/src/components/app-shell/data/app-shell.types.ts b/Frontend/src/components/app-shell/data/app-shell.types.ts
new file mode 100644
index 0000000..b4103e6
--- /dev/null
+++ b/Frontend/src/components/app-shell/data/app-shell.types.ts
@@ -0,0 +1,130 @@
+// Path: Frontend/src/components/app-shell/data/app-shell.types.ts
+
+import type { Accessor } from "solid-js";
+import type {
+ ActiveDepartment,
+ ActiveProject,
+ ActiveServer,
+ ActiveUserProfile,
+ DepartmentItem,
+ ProjectItem,
+ RailItem,
+ WorkspaceTreeNode,
+} from "./shell.data";
+
+export type AppShellInstallation = {
+ id: string;
+ name: string;
+ mode: "personal" | "organizational" | string;
+ access: string;
+ protocol: string;
+ host: string;
+ isBootstrapped: boolean;
+ materializationStatus: "not_started" | "pending" | "running" | "succeeded" | "failed" | string;
+ materializationError?: string;
+};
+
+export type AppShellAdmin = {
+ id: string;
+ email: string;
+ displayName: string;
+ isInstanceAdmin: boolean;
+ homeTitle: string;
+};
+
+export type AppShellOrganization = {
+ id: string;
+ name: string;
+ slug: string;
+};
+
+export type AppShellDepartment = {
+ id: string;
+ organizationId: string;
+ name: string;
+ slug: string;
+};
+
+export type AppShellTeam = {
+ id: string;
+ organizationId: string;
+ departmentId?: string;
+ name: string;
+ slug: string;
+};
+
+export type AppShellProject = {
+ id: string;
+ organizationId: string;
+ departmentId?: string;
+ teamId?: string;
+ name: string;
+ slug: string;
+};
+
+export type AppShellWorkspace = {
+ id: string;
+ organizationId: string;
+ name: string;
+ slug: string;
+ kind: "organization" | "department" | "team" | "project" | string;
+ departmentId?: string;
+ teamId?: string;
+ projectId?: string;
+};
+
+export type AppShellPayload = {
+ installation?: AppShellInstallation;
+ admin?: AppShellAdmin;
+ organizations: AppShellOrganization[];
+ departments: AppShellDepartment[];
+ teams: AppShellTeam[];
+ projects: AppShellProject[];
+ workspaces: AppShellWorkspace[];
+};
+
+export type AppShellContextValue = {
+ status: Accessor<"idle" | "loading" | "success" | "error">;
+ error: Accessor;
+ installation: Accessor;
+ railItems: Accessor;
+ activeServer: Accessor;
+ activeProject: Accessor;
+ activeDepartment: Accessor;
+ projectItems: Accessor;
+ departmentItems: Accessor;
+ workspaceTree: Accessor;
+ activeUserProfile: Accessor;
+ reload: () => Promise;
+};
+
+export const normalizeInstallation = (
+ installation: AppShellInstallation | null | undefined,
+): AppShellInstallation | undefined => {
+ if (!installation) {
+ return undefined;
+ }
+
+ const materializationStatus = installation.materializationStatus?.trim()
+ ? installation.materializationStatus
+ : installation.isBootstrapped
+ ? "succeeded"
+ : "not_started";
+ const materializationError = installation.materializationError?.trim() || undefined;
+
+ return {
+ ...installation,
+ materializationStatus,
+ materializationError,
+ };
+};
+
+export const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({
+ installation: normalizeInstallation(payload?.installation),
+ admin: payload?.admin,
+ organizations: Array.isArray(payload?.organizations) ? payload.organizations : [],
+ departments: Array.isArray(payload?.departments) ? payload.departments : [],
+ teams: Array.isArray(payload?.teams) ? payload.teams : [],
+ projects: Array.isArray(payload?.projects) ? payload.projects : [],
+ workspaces: Array.isArray(payload?.workspaces) ? payload.workspaces : [],
+});
diff --git a/Frontend/src/components/app-shell/data/shell.context-menu.ts b/Frontend/src/components/app-shell/data/shell.context-menu.ts
new file mode 100644
index 0000000..48e2c83
--- /dev/null
+++ b/Frontend/src/components/app-shell/data/shell.context-menu.ts
@@ -0,0 +1,174 @@
+// Path: Frontend/src/components/app-shell/data/shell.context-menu.ts
+
+import { getWorkspaceCreateActions, getWorkspaceItemTypeDefinition } from "./shell.registry";
+import type {
+ ActiveProject,
+ ProjectContextMenuAction,
+ ProjectContextMenuSection,
+ ProjectItem,
+ ProjectMenuTarget,
+ WorkspaceContextMenuSection,
+ WorkspaceContextMenuTarget,
+ WorkspaceStaticItem,
+ WorkspaceTreeNode,
+} from "./shell.types";
+
+export { getWorkspaceContextMenuEyebrow } from "./shell.registry";
+
+export const createWorkspaceSurfaceTarget = (workspace: ActiveProject): WorkspaceContextMenuTarget => ({
+ id: `workspace-${workspace.id}`,
+ label: workspace.name,
+ kind: "workspace",
+});
+
+export const createWorkspaceStaticTarget = (item: WorkspaceStaticItem): WorkspaceContextMenuTarget => ({
+ id: item.id,
+ label: item.label,
+ kind: item.contextKind,
+});
+
+export const createWorkspaceTreeTarget = (node: WorkspaceTreeNode): WorkspaceContextMenuTarget => ({
+ id: node.id,
+ label: node.label,
+ ...(node.kind === "folder"
+ ? { kind: "folder" as const }
+ : {
+ kind: "item" as const,
+ itemType: node.itemType,
+ }),
+});
+
+export const getWorkspaceContextMenuSections = (
+ target: WorkspaceContextMenuTarget,
+): readonly WorkspaceContextMenuSection[] => {
+ const createActions = getWorkspaceCreateActions();
+ const createSubmenuAction = {
+ id: "create",
+ label: "Create",
+ children: createActions,
+ } as const;
+
+ switch (target.kind) {
+ case "workspace":
+ return [
+ { id: "create", label: undefined, items: [createSubmenuAction] },
+ {
+ id: "workspace",
+ label: undefined,
+ items: [
+ { id: "rename-workspace", label: "Rename workspace", shortcut: { key: "enter" } },
+ { id: "copy-workspace-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
+ ],
+ },
+ ] as const;
+ case "home":
+ return [
+ { id: "create", label: undefined, items: [createSubmenuAction] },
+ { id: "workspace", label: undefined, items: [{ id: "open-home", label: "Open home", shortcut: { key: "enter" } }] },
+ ] as const;
+ case "settings":
+ return [
+ {
+ id: "settings",
+ label: undefined,
+ items: [
+ { id: "open-settings", label: "Open settings", shortcut: { key: "enter" } },
+ { id: "copy-settings-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
+ ],
+ },
+ ] as const;
+ case "folder":
+ return [
+ { id: "open", items: [{ id: "open-folder", label: "Open folder", shortcut: { key: "enter" } }, { id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } }] },
+ { id: "create", label: undefined, items: [createSubmenuAction] },
+ {
+ id: "organize",
+ label: undefined,
+ items: [
+ { id: "move-folder", label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } },
+ { id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
+ ],
+ },
+ ] as const;
+ case "item": {
+ const definition = getWorkspaceItemTypeDefinition(target.itemType);
+ const actionPrefix = definition.actionPrefix;
+ const nounLabel = definition.noun;
+
+ return [
+ {
+ id: `${actionPrefix}-primary`,
+ items: [
+ { id: `open-${actionPrefix}`, label: `Open ${nounLabel}`, shortcut: { key: "enter" } },
+ { id: `rename-${actionPrefix}`, label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
+ ],
+ },
+ {
+ id: "organize",
+ label: undefined,
+ items: [
+ { id: `move-${actionPrefix}`, label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } },
+ { id: `delete-${actionPrefix}`, label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
+ ],
+ },
+ ] as const;
+ }
+ }
+};
+
+const getProjectCreateActions = (): readonly ProjectContextMenuAction[] =>
+ [
+ { id: "new-project", label: "New project" },
+ { id: "new-folder", label: "New folder" },
+ ] as const;
+
+const getProjectFolderDangerActions = (): readonly ProjectContextMenuAction[] =>
+ [
+ { id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
+ { id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
+ ] as const;
+
+export const createProjectSurfaceTarget = (label = "Projects"): ProjectMenuTarget => ({
+ id: "project-surface",
+ label,
+ kind: "surface",
+});
+
+export const createProjectFolderTarget = (id: string, label: string): ProjectMenuTarget => ({
+ id,
+ label,
+ kind: "folder",
+});
+
+export const createProjectTarget = (project: ProjectItem): ProjectMenuTarget => ({
+ id: project.id,
+ label: project.name,
+ kind: "project",
+});
+
+export const getProjectContextMenuEyebrow = (target: ProjectMenuTarget): string => {
+ switch (target.kind) {
+ case "surface":
+ return "Projects";
+ case "folder":
+ return "Folder";
+ case "project":
+ return "Project";
+ }
+};
+
+export const getProjectContextMenuSections = (target: ProjectMenuTarget): readonly ProjectContextMenuSection[] => {
+ const createActions = getProjectCreateActions();
+
+ switch (target.kind) {
+ case "surface":
+ return [{ id: "create", items: createActions }] as const;
+ case "folder":
+ return [
+ { id: "create", items: createActions },
+ { id: "organize", items: getProjectFolderDangerActions() },
+ ] as const;
+ case "project":
+ return [{ id: "create", items: createActions }] as const;
+ }
+};
diff --git a/Frontend/src/components/app-shell/data/shell.data.ts b/Frontend/src/components/app-shell/data/shell.data.ts
new file mode 100644
index 0000000..7dbbfdb
--- /dev/null
+++ b/Frontend/src/components/app-shell/data/shell.data.ts
@@ -0,0 +1,6 @@
+// Path: Frontend/src/components/app-shell/data/shell.data.ts
+
+export * from "./shell.types";
+export * from "./shell.registry";
+export * from "./shell.scaffold";
+export * from "./shell.context-menu";
diff --git a/Frontend/src/components/app-shell/data/shell.registry.ts b/Frontend/src/components/app-shell/data/shell.registry.ts
new file mode 100644
index 0000000..83d3e4a
--- /dev/null
+++ b/Frontend/src/components/app-shell/data/shell.registry.ts
@@ -0,0 +1,100 @@
+// Path: Frontend/src/components/app-shell/data/shell.registry.ts
+
+import { FileText, LayoutGrid } from "../../../lib/icons";
+import type {
+ ShellIcon,
+ WorkspaceContextMenuAction,
+ WorkspaceContextMenuTarget,
+ WorkspaceItemTypeDefinition,
+ WorkspaceItemTypeId,
+ WorkspaceTreeNode,
+} from "./shell.types";
+
+export const firstPartyWorkspaceItemTypes: readonly WorkspaceItemTypeDefinition[] = [
+ {
+ id: "core.doc",
+ label: "Doc",
+ shortLabel: "Doc",
+ icon: FileText,
+ noun: "doc",
+ actionPrefix: "doc",
+ defaultCreateLabel: "New doc",
+ includeInWorkspaceCreate: true,
+ description: "Rich text documents and notes.",
+ },
+ {
+ id: "core.board.kanban",
+ label: "Kanban board",
+ shortLabel: "Board",
+ icon: LayoutGrid,
+ noun: "board",
+ actionPrefix: "board",
+ defaultCreateLabel: "New board",
+ includeInWorkspaceCreate: true,
+ description: "Default board-style workspace item.",
+ },
+ {
+ id: "core.board.list",
+ label: "List board",
+ shortLabel: "Board",
+ icon: LayoutGrid,
+ noun: "board",
+ actionPrefix: "list-board",
+ defaultCreateLabel: "New list board",
+ description: "Alternate first-party board view prepared for the future registry.",
+ },
+] as const;
+
+const workspaceItemTypeMap = new Map(
+ firstPartyWorkspaceItemTypes.map((definition) => [definition.id, definition]),
+);
+
+const createUnknownWorkspaceItemTypeDefinition = (
+ itemType: WorkspaceItemTypeId,
+): WorkspaceItemTypeDefinition => ({
+ id: itemType,
+ label: "Item",
+ shortLabel: "Item",
+ icon: FileText,
+ noun: "item",
+ actionPrefix: "item",
+ defaultCreateLabel: "New item",
+ description: "Fallback definition for unknown or future workspace item types.",
+});
+
+export const getWorkspaceItemTypeDefinition = (itemType: WorkspaceItemTypeId): WorkspaceItemTypeDefinition => {
+ return workspaceItemTypeMap.get(itemType) ?? createUnknownWorkspaceItemTypeDefinition(itemType);
+};
+
+export const getWorkspaceNodeIcon = (node: WorkspaceTreeNode): ShellIcon =>
+ node.kind === "folder" ? node.icon : getWorkspaceItemTypeDefinition(node.itemType).icon;
+
+export const getWorkspaceCreateActions = (): readonly WorkspaceContextMenuAction[] => [
+ { id: "new-folder", label: "New folder", shortcut: { modifiers: ["alt"], key: "f" } },
+ ...firstPartyWorkspaceItemTypes
+ .filter((definition) => definition.includeInWorkspaceCreate)
+ .map((definition) => ({
+ id: `create-${definition.actionPrefix}`,
+ label: definition.defaultCreateLabel,
+ shortcut:
+ definition.id === "core.board.kanban"
+ ? ({ modifiers: ["alt"], key: "b" } as const)
+ : definition.id === "core.doc"
+ ? ({ modifiers: ["alt"], key: "d" } as const)
+ : undefined,
+ })),
+];
+
+export const getWorkspaceContextMenuEyebrow = (target: WorkspaceContextMenuTarget): string => {
+ switch (target.kind) {
+ case "workspace":
+ case "home":
+ return "Workspace";
+ case "settings":
+ return "Configuration";
+ case "folder":
+ return "Folder";
+ case "item":
+ return getWorkspaceItemTypeDefinition(target.itemType).shortLabel;
+ }
+};
diff --git a/Frontend/src/components/app-shell/data/shell.scaffold.ts b/Frontend/src/components/app-shell/data/shell.scaffold.ts
new file mode 100644
index 0000000..7025cde
--- /dev/null
+++ b/Frontend/src/components/app-shell/data/shell.scaffold.ts
@@ -0,0 +1,222 @@
+// Path: Frontend/src/components/app-shell/data/shell.scaffold.ts
+
+import {
+ Bell,
+ CircleHelp,
+ FileText,
+ Folder,
+ Home,
+ Keyboard,
+ LayoutGrid,
+ ListCollapse,
+ LogOut,
+ Repeat,
+ Search,
+ Settings,
+ Shield,
+ User,
+} from "../../../lib/icons";
+import type {
+ ActiveDepartment,
+ ActiveProject,
+ ActiveServer,
+ ActiveUserProfile,
+ DepartmentItem,
+ MobileBottomNavItem,
+ NotificationItem,
+ ProfileMenuSection,
+ ProjectItem,
+ RailItem,
+ ServerDockAction,
+ SidebarHeaderAction,
+ TopBarAction,
+ WorkspaceStaticItem,
+ WorkspaceTreeNode,
+} from "./shell.types";
+
+export const personalDockActions: readonly ServerDockAction[] = [
+ { id: "account", label: "Account", icon: User },
+ { id: "settings", label: "Settings", icon: Settings },
+] as const;
+
+export const organizationAdminDockActions: readonly ServerDockAction[] = [
+ { id: "members", label: "Members", icon: User },
+ { id: "server", label: "Server", icon: Settings },
+] as const;
+
+export const railItems: readonly RailItem[] = [
+ { id: "personal-server", label: "Personal Server Name", abbreviation: "P", kind: "personal" },
+ { id: "organization-server", label: "Organization Name", abbreviation: "O", kind: "organization", active: true },
+ { id: "design-review", label: "Design Review", abbreviation: "D", kind: "organization" },
+] as const;
+
+export const activeServer: ActiveServer = {
+ id: "organization-server",
+ name: "Organization Name",
+ abbreviation: "O",
+ kind: "organization",
+ connectedLabel: "12 connected",
+ dockActions: organizationAdminDockActions,
+};
+
+export const activeProject: ActiveProject = {
+ id: "general",
+ name: "General",
+};
+
+export const activeDepartment: ActiveDepartment = {
+ id: "product",
+ name: "Product",
+ teamName: "Design Systems",
+};
+
+export const projectItems: readonly ProjectItem[] = [
+ {
+ id: "general",
+ name: "General",
+ description: "Default shared project",
+ groupLabel: "Shared space",
+ parentLabel: "Workspace home",
+ meta: "1 workspace",
+ active: true,
+ },
+ {
+ id: "operations",
+ name: "Operations",
+ description: "Cross-team planning and delivery",
+ groupLabel: "Team folders",
+ parentLabel: "Shared Services",
+ meta: "2 workspaces",
+ },
+ {
+ id: "hiring",
+ name: "Hiring",
+ description: "Candidate pipeline and interview loops",
+ groupLabel: "Team folders",
+ parentLabel: "People Ops",
+ meta: "1 workspace",
+ },
+] as const;
+
+export const departmentItems: readonly DepartmentItem[] = [
+ { id: "product", name: "Product", teams: ["Design Systems", "Research Ops"], active: true },
+ { id: "engineering", name: "Engineering", teams: ["Platform", "Realtime Collaboration"] },
+ { id: "operations", name: "Operations", teams: ["Shared Services", "People Ops"] },
+] as const;
+
+export const workspaceStaticItems: readonly WorkspaceStaticItem[] = [
+ { id: "home", label: "Home", icon: Home, active: true, contextKind: "home" },
+ { id: "workspace-settings", label: "Settings", icon: Settings, contextKind: "settings" },
+] as const;
+
+export const workspaceTree: readonly WorkspaceTreeNode[] = [
+ {
+ id: "product-workspace",
+ label: "Product",
+ kind: "folder",
+ icon: Folder,
+ children: [
+ { id: "roadmap-board", label: "Roadmap", kind: "item", itemType: "core.board.kanban", active: true },
+ { id: "launch-brief", label: "Launch Brief", kind: "item", itemType: "core.doc" },
+ {
+ id: "research-folder",
+ label: "Research",
+ kind: "folder",
+ icon: Folder,
+ children: [
+ { id: "interviews-doc", label: "Interviews", kind: "item", itemType: "core.doc" },
+ { id: "signals-board", label: "Signals", kind: "item", itemType: "core.board.kanban", meta: "2" },
+ ],
+ },
+ ],
+ },
+ {
+ id: "design-folder",
+ label: "Design",
+ kind: "folder",
+ icon: Folder,
+ children: [
+ { id: "system-doc", label: "Design System", kind: "item", itemType: "core.doc" },
+ { id: "review-board", label: "Review Queue", kind: "item", itemType: "core.board.kanban" },
+ ],
+ },
+ { id: "general-notes", label: "General Notes", kind: "item", itemType: "core.doc" },
+] as const;
+
+export const workspaceSidebarHeaderActions: readonly SidebarHeaderAction[] = [
+ { id: "search-workspace", label: "Search workspace", icon: Search },
+ { id: "toggle-workspace-folders", label: "Collapse all folders", icon: ListCollapse },
+] as const;
+
+export const mobileBottomNavItems: readonly MobileBottomNavItem[] = [
+ { id: "home", label: "Home", icon: Home, active: true },
+ { id: "search", label: "Search", icon: Search },
+ { id: "browse", label: "Browse", icon: Folder },
+] as const;
+
+export const topBarActions: readonly TopBarAction[] = [{ id: "search", label: "Search", icon: Search }] as const;
+
+export const notificationItems: readonly NotificationItem[] = [
+ {
+ id: "comment-design-systems",
+ title: "New comment on Design Systems",
+ contextLabel: "Product • Review thread updated",
+ timeLabel: "2m ago",
+ unread: true,
+ },
+ {
+ id: "sprint-platform",
+ title: "Sprint updated in Platform",
+ contextLabel: "Engineering • Scope changed",
+ timeLabel: "15m ago",
+ unread: true,
+ },
+ {
+ id: "member-joined",
+ title: "New member joined Operations",
+ contextLabel: "Organization Name • Access granted",
+ timeLabel: "1h ago",
+ },
+ {
+ id: "daily-summary",
+ title: "Daily summary is ready",
+ contextLabel: "General • 8 updates across boards",
+ timeLabel: "Today, 8:00 AM",
+ },
+] as const;
+
+export const unreadNotificationCount = notificationItems.filter((item) => item.unread).length;
+
+export const activeUserProfile: ActiveUserProfile = {
+ name: "Demo Account",
+ email: "demo@moku.work",
+ roleLabel: "Founder · Product",
+ contextLabel: "Organization Name • Design Systems",
+};
+
+export const profileMenuSections: readonly ProfileMenuSection[] = [
+ {
+ id: "account",
+ items: [
+ { id: "profile", label: "Profile", icon: User },
+ { id: "account-settings", label: "Account Settings", icon: Settings },
+ { id: "notifications", label: "Notifications", icon: Bell },
+ { id: "security", label: "Security", icon: Shield },
+ ],
+ },
+ {
+ id: "preferences",
+ items: [
+ { id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard },
+ { id: "theme-preferences", label: "Theme Preferences", icon: Settings },
+ { id: "help-support", label: "Help & Support", icon: CircleHelp },
+ ],
+ },
+ {
+ id: "session",
+ items: [
+ { id: "switch-account", label: "Switch Account", icon: Repeat },
+ { id: "sign-out", label: "Sign Out", icon: LogOut, tone: "danger" },
+ ],
+ },
+] as const;
diff --git a/Frontend/src/components/app-shell/data/shell.types.ts b/Frontend/src/components/app-shell/data/shell.types.ts
new file mode 100644
index 0000000..2fc993e
--- /dev/null
+++ b/Frontend/src/components/app-shell/data/shell.types.ts
@@ -0,0 +1,233 @@
+// Path: Frontend/src/components/app-shell/data/shell.types.ts
+
+import type { Component } from "solid-js";
+
+export type ShellIconProps = {
+ class?: string;
+ size?: number;
+ strokeWidth?: number;
+};
+
+export type ShellIcon = Component;
+
+export type RailItem = {
+ id: string;
+ label: string;
+ abbreviation: string;
+ kind: "personal" | "organization";
+ active?: boolean;
+};
+
+export type ServerDockAction = {
+ id: string;
+ label: string;
+ icon: ShellIcon;
+};
+
+export type ActiveServer = {
+ id: string;
+ name: string;
+ abbreviation: string;
+ kind: "personal" | "organization";
+ connectedLabel?: string;
+ subtitle?: string;
+ dockActions: readonly ServerDockAction[];
+};
+
+export type ActiveProject = {
+ id: string;
+ name: string;
+};
+
+export type ActiveDepartment = {
+ id: string;
+ name: string;
+ teamName: string;
+};
+
+export type DepartmentItem = {
+ id: string;
+ name: string;
+ teams: readonly string[];
+ active?: boolean;
+};
+
+export type ProjectItem = {
+ id: string;
+ name: string;
+ description: string;
+ groupLabel?: string;
+ parentLabel?: string;
+ meta?: string;
+ active?: boolean;
+};
+
+export type ProjectMenuTarget =
+ | {
+ id: string;
+ label: string;
+ kind: "surface";
+ }
+ | {
+ id: string;
+ label: string;
+ kind: "folder";
+ }
+ | {
+ id: string;
+ label: string;
+ kind: "project";
+ };
+
+export type ProjectContextMenuAction = {
+ id: string;
+ label: string;
+ tone?: "default" | "danger";
+ shortcut?: WorkspaceContextMenuShortcut;
+ children?: readonly ProjectContextMenuAction[];
+};
+
+export type ProjectContextMenuSection = {
+ id: string;
+ label?: string;
+ items: readonly ProjectContextMenuAction[];
+};
+
+export type SidebarItem = {
+ id: string;
+ label: string;
+ icon: ShellIcon;
+ active?: boolean;
+ meta?: string;
+};
+
+export type WorkspaceStaticKind = "workspace" | "home" | "settings";
+
+export type WorkspaceItemTypeId = string;
+
+export type WorkspaceStaticItem = SidebarItem & {
+ contextKind: WorkspaceStaticKind;
+};
+
+export type WorkspaceFolderNode = {
+ id: string;
+ path?: string;
+ label: string;
+ kind: "folder";
+ icon: ShellIcon;
+ active?: boolean;
+ meta?: string;
+ children?: readonly WorkspaceTreeNode[];
+};
+
+export type WorkspaceItemNode = {
+ id: string;
+ path?: string;
+ label: string;
+ kind: "item";
+ itemType: WorkspaceItemTypeId;
+ active?: boolean;
+ meta?: string;
+ children?: undefined;
+};
+
+export type WorkspaceTreeNode = WorkspaceFolderNode | WorkspaceItemNode;
+
+export type WorkspaceItemTypeDefinition = {
+ id: WorkspaceItemTypeId;
+ label: string;
+ shortLabel: string;
+ icon: ShellIcon;
+ noun: string;
+ actionPrefix: string;
+ defaultCreateLabel: string;
+ includeInWorkspaceCreate?: boolean;
+ description?: string;
+};
+
+export type SidebarHeaderAction = {
+ id: string;
+ label: string;
+ icon: ShellIcon;
+};
+
+export type TopBarAction = {
+ id: string;
+ label: string;
+ icon: ShellIcon;
+};
+
+export type MobileBottomNavItem = {
+ id: string;
+ label: string;
+ icon: ShellIcon;
+ active?: boolean;
+};
+
+export type WorkspaceContextMenuTarget =
+ | {
+ id: string;
+ label: string;
+ kind: WorkspaceStaticKind;
+ }
+ | {
+ id: string;
+ label: string;
+ kind: "folder";
+ }
+ | {
+ id: string;
+ label: string;
+ kind: "item";
+ itemType: WorkspaceItemTypeId;
+ };
+
+export type WorkspaceContextMenuAction = {
+ id: string;
+ label: string;
+ tone?: "default" | "danger";
+ shortcut?: WorkspaceContextMenuShortcut;
+ children?: readonly WorkspaceContextMenuAction[];
+};
+
+export type WorkspaceContextMenuShortcutModifier = "meta" | "alt" | "shift";
+
+export type WorkspaceContextMenuShortcutKey = "b" | "c" | "d" | "delete" | "enter" | "f" | "m" | "r";
+
+export type WorkspaceContextMenuShortcut = {
+ modifiers?: readonly WorkspaceContextMenuShortcutModifier[];
+ key: WorkspaceContextMenuShortcutKey;
+};
+
+export type WorkspaceContextMenuSection = {
+ id: string;
+ label?: string;
+ items: readonly WorkspaceContextMenuAction[];
+};
+
+export type NotificationItem = {
+ id: string;
+ title: string;
+ contextLabel: string;
+ timeLabel: string;
+ unread?: boolean;
+};
+
+export type ProfileMenuAction = {
+ id: string;
+ label: string;
+ icon: ShellIcon;
+ tone?: "default" | "danger";
+};
+
+export type ProfileMenuSection = {
+ id: string;
+ items: readonly ProfileMenuAction[];
+};
+
+export type ActiveUserProfile = {
+ name: string;
+ email: string;
+ roleLabel: string;
+ contextLabel: string;
+};
diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.data.ts b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.data.ts
similarity index 98%
rename from Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.data.ts
rename to Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.data.ts
index 80b831f..d2eac7b 100644
--- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.data.ts
+++ b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.data.ts
@@ -1,4 +1,4 @@
-// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.data.ts
+// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.data.ts
export type BootstrapStepKey = "persona" | "instance" | "mode" | "admin" | "structure";
diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.hook.ts b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.hook.ts
similarity index 87%
rename from Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.hook.ts
rename to Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.hook.ts
index e847214..eddfef0 100644
--- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.hook.ts
+++ b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.hook.ts
@@ -1,8 +1,7 @@
-// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.hook.ts
+// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.hook.ts
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { createStore } from "solid-js/store";
-import { resolveAPIBase } from "../../../lib/api";
import {
bootstrapPersonaDefinitions,
bootstrapStepDefinitions,
@@ -20,7 +19,8 @@ import {
type InstanceForm,
type ModeForm,
type StructureForm,
-} from "./WorkspaceHome.data";
+} from "./BootstrapWizard.data";
+import { submitBootstrapStepRequest } from "./bootstrapWizard.api";
type AppShellBootstrapAdapter = {
installation: () => { isBootstrapped?: boolean; materializationStatus?: string; materializationError?: string } | undefined;
@@ -49,67 +49,7 @@ const initialSubmissionState = (): BootstrapSubmissionState => ({
const materializationPollIntervalMs = 2000;
-const readResponseBody = async (response: Response): Promise => {
- const raw = await response.text();
-
- if (!raw.trim()) {
- return null;
- }
-
- try {
- return JSON.parse(raw);
- } catch {
- return raw;
- }
-};
-
-const readResponseError = (step: BootstrapStepKey, data: unknown): string => {
- const fallback = `Bootstrap ${step} request failed.`;
-
- if (typeof data === "string") {
- const message = data.trim();
- return message || fallback;
- }
-
- if (!data || typeof data !== "object") {
- return fallback;
- }
-
- const record = data as {
- error?: string;
- message?: string;
- requestId?: string;
- };
- const message = typeof record.message === "string" ? record.message.trim() : "";
- const errorCode = typeof record.error === "string" ? record.error.trim() : "";
- const requestId = typeof record.requestId === "string" ? record.requestId.trim() : "";
-
- if (!message && !errorCode && !requestId) {
- return fallback;
- }
-
- const details: string[] = [];
-
- if (errorCode) {
- details.push(`code: ${errorCode}`);
- }
-
- if (requestId) {
- details.push(`request: ${requestId}`);
- }
-
- if (message && details.length > 0) {
- return `${message} (${details.join(", ")})`;
- }
-
- if (message) {
- return message;
- }
-
- return `${fallback} (${details.join(", ")})`;
-};
-
-export const useWorkspaceHomeWizard = (appShellData: AppShellBootstrapAdapter) => {
+export const useBootstrapWizard = (appShellData: AppShellBootstrapAdapter) => {
const [instanceForm, setInstanceForm] = createStore({ ...defaultInstanceForm });
const [modeForm, setModeForm] = createStore({ ...defaultModeForm });
const [adminForm, setAdminForm] = createStore({ ...defaultAdminForm });
@@ -303,7 +243,6 @@ export const useWorkspaceHomeWizard = (appShellData: AppShellBootstrapAdapter) =
});
});
- const apiBase = (): string => resolveAPIBase();
const bootstrapNamePlaceholder = (): string => personaDefinition().defaults.namePlaceholder;
const bootstrapStepCount = createMemo(() => activeWizardSteps().length);
const currentStep = createMemo(() => activeBootstrapSteps()[currentStepIndex()] ?? activeBootstrapSteps()[0] ?? bootstrapStepDefinitions[0]!);
@@ -342,19 +281,7 @@ export const useWorkspaceHomeWizard = (appShellData: AppShellBootstrapAdapter) =
setStepState(step, { status: "submitting", error: "" });
try {
- const response = await fetch(`${apiBase()}/bootstrap/steps/${step}`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Accept: "application/json",
- },
- body: JSON.stringify(payload),
- });
- const data = await readResponseBody(response);
-
- if (!response.ok) {
- throw new Error(readResponseError(step, data));
- }
+ await submitBootstrapStepRequest(step, payload);
setStepState(step, {
status: "success",
diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.module.scss
similarity index 99%
rename from Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss
rename to Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.module.scss
index 1624c99..756611c 100644
--- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss
+++ b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.module.scss
@@ -1,4 +1,4 @@
-/* Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss */
+/* Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.module.scss */
.viewport,
.wizardLayer {
diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.parts.tsx b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.parts.tsx
similarity index 98%
rename from Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.parts.tsx
rename to Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.parts.tsx
index 74f5128..cc79a0e 100644
--- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.parts.tsx
+++ b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.parts.tsx
@@ -1,3 +1,5 @@
+// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.parts.tsx
+
import { For, Show, type JSX } from "solid-js";
import { CircleHelp } from "../../../lib/icons";
import {
@@ -11,8 +13,8 @@ import {
type InstanceForm,
type ModeForm,
type StructureForm,
-} from "./WorkspaceHome.data";
-import styles from "./WorkspaceHome.module.scss";
+} from "./BootstrapWizard.data";
+import styles from "./BootstrapWizard.module.scss";
type BootstrapSubmissionState = {
status: "idle" | "submitting" | "success" | "error";
diff --git a/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.tsx b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.tsx
new file mode 100644
index 0000000..813dfe1
--- /dev/null
+++ b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.tsx
@@ -0,0 +1,107 @@
+import { type JSX } from "solid-js";
+import { useAppShellData } from "../../app-shell/data/app-shell.context";
+import { useBootstrapWizard } from "./BootstrapWizard.hook";
+import { BootstrapWizardDialog } from "./BootstrapWizardDialog";
+
+export const BootstrapWizard = (): JSX.Element => {
+ const appShellData = useAppShellData();
+ const {
+ instanceForm,
+ setInstanceForm,
+ modeForm,
+ setModeForm,
+ adminForm,
+ setAdminForm,
+ structureForm,
+ setStructureForm,
+ selectedPersona,
+ hasChosenPersona,
+ stepState,
+ isBootstrapStateResolved,
+ isWizardOpen,
+ setIsWizardOpen,
+ setIsFinishingBootstrapFlow,
+ fieldTooltip,
+ materializationState,
+ isMaterializationInFlight,
+ hasMaterializationFailed,
+ showBootstrapFinishingState,
+ materializationStatusLabel,
+ materializationMessage,
+ selectedPersonaIsAvailable,
+ usesCondensedBootstrapFlow,
+ activeWizardSteps,
+ bootstrapNamePlaceholder,
+ bootstrapStepCount,
+ currentStep,
+ currentWizardStepIndex,
+ wizardProgressFillWidth,
+ currentStepState,
+ isFirstStep,
+ canDismissWizard,
+ handleCurrentStepSubmit,
+ applyPersonaSelection,
+ statusLabel,
+ showFieldTooltip,
+ hideFieldTooltip,
+ stepStatusLabel,
+ navigateBack,
+ navigateToVisibleStep,
+ } = useBootstrapWizard(appShellData);
+
+ return (
+ {
+ setIsWizardOpen(false);
+ }}
+ onCloseFinishingState={(): void => {
+ setIsFinishingBootstrapFlow(false);
+ setIsWizardOpen(false);
+ }}
+ onSubmit={handleCurrentStepSubmit}
+ onSelectPersona={applyPersonaSelection}
+ onProtocolChange={(value): void => setInstanceForm("protocol", value)}
+ onAccessChange={(value): void => setInstanceForm("access", value)}
+ onHostChange={(value): void => setInstanceForm("host", value)}
+ onModeNameChange={(value): void => setModeForm("name", value)}
+ onProjectNameChange={(value): void => setStructureForm("projectName", value)}
+ onTeamNameChange={(value): void => setStructureForm("teamName", value)}
+ onAdminDisplayNameChange={(value): void => setAdminForm("displayName", value)}
+ onAdminEmailChange={(value): void => setAdminForm("email", value)}
+ onAdminPasswordChange={(value): void => setAdminForm("password", value)}
+ onDepartmentNameChange={(value): void => setStructureForm("departmentName", value)}
+ onShowTooltip={showFieldTooltip}
+ onHideTooltip={hideFieldTooltip}
+ statusLabel={statusLabel}
+ stepStatusLabel={stepStatusLabel}
+ onNavigateBack={navigateBack}
+ onSelectStep={navigateToVisibleStep}
+ />
+ );
+};
diff --git a/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizardDialog.tsx b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizardDialog.tsx
new file mode 100644
index 0000000..a94fd8c
--- /dev/null
+++ b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizardDialog.tsx
@@ -0,0 +1,234 @@
+// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizardDialog.tsx
+
+import { Show, type JSX } from "solid-js";
+import { Portal } from "solid-js/web";
+import {
+ type AdminForm,
+ type BootstrapPersona,
+ bootstrapPersonaDefinitions,
+ type BootstrapStepDefinition,
+ type BootstrapStepKey,
+ type InstanceForm,
+ type ModeForm,
+ type StructureForm,
+} from "./BootstrapWizard.data";
+import { type BootstrapSubmissionState, type FieldTooltipState, type MaterializationState } from "./BootstrapWizard.hook";
+import {
+ BootstrapAdminStep,
+ BootstrapFinishingState,
+ BootstrapInstanceStep,
+ BootstrapModeStep,
+ BootstrapPersonaStep,
+ BootstrapStructureStep,
+ BootstrapWizardProgress,
+} from "./BootstrapWizard.parts";
+import styles from "./BootstrapWizard.module.scss";
+
+export type BootstrapWizardDialogProps = {
+ isOpen: boolean;
+ currentStep: BootstrapStepDefinition;
+ currentStepState: BootstrapSubmissionState;
+ activeWizardSteps: readonly BootstrapStepDefinition[];
+ currentWizardStepIndex: number;
+ stepState: Record;
+ bootstrapStepCount: number;
+ wizardProgressFillWidth: string;
+ showBootstrapFinishingState: boolean;
+ materializationState: MaterializationState;
+ materializationStatusLabel: string;
+ materializationMessage: string;
+ isMaterializationInFlight: boolean;
+ hasMaterializationFailed: boolean;
+ instanceForm: InstanceForm;
+ modeForm: ModeForm;
+ adminForm: AdminForm;
+ structureForm: StructureForm;
+ selectedPersona: BootstrapPersona;
+ hasChosenPersona: boolean;
+ selectedPersonaIsAvailable: boolean;
+ usesCondensedBootstrapFlow: boolean;
+ bootstrapNamePlaceholder: string;
+ isFirstStep: boolean;
+ canDismissWizard: boolean;
+ fieldTooltip: FieldTooltipState | null;
+ onClose: () => void;
+ onCloseFinishingState: () => void;
+ onSubmit: JSX.EventHandlerUnion;
+ onSelectPersona: (persona: BootstrapPersona) => void;
+ onProtocolChange: (value: InstanceForm["protocol"]) => void;
+ onAccessChange: (value: InstanceForm["access"]) => void;
+ onHostChange: (value: string) => void;
+ onModeNameChange: (value: string) => void;
+ onProjectNameChange: (value: string) => void;
+ onTeamNameChange: (value: string) => void;
+ onAdminDisplayNameChange: (value: string) => void;
+ onAdminEmailChange: (value: string) => void;
+ onAdminPasswordChange: (value: string) => void;
+ onDepartmentNameChange: (value: string) => void;
+ onShowTooltip: (target: HTMLElement, text: string) => void;
+ onHideTooltip: () => void;
+ statusLabel: (state: BootstrapSubmissionState) => string;
+ stepStatusLabel: (step: BootstrapStepDefinition) => string;
+ onNavigateBack: () => void;
+ onSelectStep: (index: number) => void;
+};
+
+export const BootstrapWizardDialog = (props: BootstrapWizardDialogProps): JSX.Element => (
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {props.currentStepState.error}
+
+
+
+
+
+
+
+ {(tooltip): JSX.Element => (
+
+ )}
+
+
+
+
+);
diff --git a/Frontend/src/components/bootstrap/BootstrapWizard/bootstrapWizard.api.ts b/Frontend/src/components/bootstrap/BootstrapWizard/bootstrapWizard.api.ts
new file mode 100644
index 0000000..2ee6d05
--- /dev/null
+++ b/Frontend/src/components/bootstrap/BootstrapWizard/bootstrapWizard.api.ts
@@ -0,0 +1,81 @@
+import { resolveAPIBase } from "../../../lib/api";
+import type { BootstrapStepKey } from "./BootstrapWizard.data";
+
+const readBootstrapResponseBody = async (response: Response): Promise => {
+ const raw = await response.text();
+
+ if (!raw.trim()) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return raw;
+ }
+};
+
+const readBootstrapResponseError = (step: BootstrapStepKey, data: unknown): string => {
+ const fallback = `Bootstrap ${step} request failed.`;
+
+ if (typeof data === "string") {
+ const message = data.trim();
+ return message || fallback;
+ }
+
+ if (!data || typeof data !== "object") {
+ return fallback;
+ }
+
+ const record = data as {
+ error?: string;
+ message?: string;
+ requestId?: string;
+ };
+ const message = typeof record.message === "string" ? record.message.trim() : "";
+ const errorCode = typeof record.error === "string" ? record.error.trim() : "";
+ const requestId = typeof record.requestId === "string" ? record.requestId.trim() : "";
+
+ if (!message && !errorCode && !requestId) {
+ return fallback;
+ }
+
+ const details: string[] = [];
+
+ if (errorCode) {
+ details.push(`code: ${errorCode}`);
+ }
+
+ if (requestId) {
+ details.push(`request: ${requestId}`);
+ }
+
+ if (message && details.length > 0) {
+ return `${message} (${details.join(", ")})`;
+ }
+
+ if (message) {
+ return message;
+ }
+
+ return `${fallback} (${details.join(", ")})`;
+};
+
+export const submitBootstrapStepRequest = async (
+ step: BootstrapStepKey,
+ payload: unknown,
+): Promise => {
+ const response = await fetch(`${resolveAPIBase()}/bootstrap/steps/${step}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+ const data = await readBootstrapResponseBody(response);
+
+ if (!response.ok) {
+ throw new Error(readBootstrapResponseError(step, data));
+ }
+};
diff --git a/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx b/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx
deleted file mode 100644
index 79efa1b..0000000
--- a/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx
+++ /dev/null
@@ -1,638 +0,0 @@
-import { For, Show, createSignal, type JSX } from "solid-js";
-import { Portal } from "solid-js/web";
-import { ChevronRight, Plus, X } from "../../../lib/icons";
-import { useAppShellData } from "../data/app-shell.context";
-import { createLongPressGesture } from "../createLongPressGesture";
-import {
- createWorkspaceStaticTarget,
- createWorkspaceSurfaceTarget,
- createWorkspaceTreeTarget,
- getWorkspaceItemTypeDefinition,
- getWorkspaceNodeIcon,
- workspaceStaticItems,
- type SidebarItem,
- type WorkspaceContextMenuAction,
- type WorkspaceContextMenuTarget,
- type WorkspaceItemTypeId,
- type WorkspaceStaticItem,
- type WorkspaceTreeNode,
-} from "../data/shell.data";
-import { useWorkspaceTreeData } from "../shared/useWorkspaceTreeData";
-import { WorkspaceMobileActionSheet } from "../WorkspaceMobileActionSheet/WorkspaceMobileActionSheet";
-import styles from "./MobileWorkspaceBrowser.module.scss";
-
-type MobileWorkspaceBrowserProps = {
- open: boolean;
- onClose: VoidFunction;
-};
-
-type MobileWorkspaceDialogState =
- | {
- kind: "text";
- title: string;
- message: string;
- confirmLabel: string;
- initialValue: string;
- onConfirm: (value: string) => void;
- }
- | {
- kind: "confirm";
- title: string;
- message: string;
- confirmLabel: string;
- tone?: "danger";
- onConfirm: () => void;
- };
-
-type MobileMoveTargetState = {
- kind: "folder" | "item";
- id: string;
- label: string;
-};
-
-type MobileMoveDestination = {
- id: string | null;
- label: string;
- depth: number;
- meta?: string;
-};
-
-const collectMoveDestinations = (
- nodes: readonly WorkspaceTreeNode[],
- movingTarget: MobileMoveTargetState,
- depth = 0,
- ancestorBlocked = false,
-): MobileMoveDestination[] => {
- const destinations: MobileMoveDestination[] = [];
-
- for (const node of nodes) {
- if (node.kind !== "folder") {
- continue;
- }
-
- const isBlockedFolder = movingTarget.kind === "folder" && node.id === movingTarget.id;
- if (!ancestorBlocked && !isBlockedFolder) {
- destinations.push({
- id: node.id,
- label: node.label,
- depth,
- meta: "Folder",
- });
- }
-
- destinations.push(
- ...collectMoveDestinations(node.children ?? [], movingTarget, depth + 1, ancestorBlocked || isBlockedFolder),
- );
- }
-
- return destinations;
-};
-
-const findTreeNodeById = (nodes: readonly WorkspaceTreeNode[], nodeId: string): WorkspaceTreeNode | null => {
- for (const node of nodes) {
- if (node.id === nodeId) {
- return node;
- }
-
- if (node.kind !== "folder") {
- continue;
- }
-
- const nestedMatch = findTreeNodeById(node.children ?? [], nodeId);
- if (nestedMatch) {
- return nestedMatch;
- }
- }
-
- return null;
-};
-
-const isDangerDialogState = (state: MobileWorkspaceDialogState): boolean => state.kind === "confirm" && state.tone === "danger";
-
-const TreeRow = (props: { node: WorkspaceTreeNode; depth?: number }): JSX.Element => {
- const depth = props.depth ?? 0;
- const Icon = getWorkspaceNodeIcon(props.node);
- const hasChildren = (props.node.children?.length ?? 0) > 0;
-
- return (
-
- );
-};
-
-const StaticRow = (props: { item: SidebarItem }): JSX.Element => {
- const Icon = props.item.icon;
-
- return (
-
- );
-};
-
-const WorkspaceStaticRow = (props: {
- item: WorkspaceStaticItem;
- onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
-}): JSX.Element => {
- const target = createWorkspaceStaticTarget(props.item);
- const longPress = createLongPressGesture({
- onLongPress: () => {
- props.onOpenActionSheet(target);
- },
- });
-
- return (
- {
- event.preventDefault();
- props.onOpenActionSheet(target);
- }}
- {...longPress}
- >
-
-
- );
-};
-
-const WorkspaceTreeBranch = (props: {
- nodes: readonly WorkspaceTreeNode[];
- depth?: number;
- onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
-}): JSX.Element => {
- const depth = props.depth ?? 0;
-
- return (
-
- {(node): JSX.Element => {
- const target = createWorkspaceTreeTarget(node);
- const longPress = createLongPressGesture({
- onLongPress: () => {
- props.onOpenActionSheet(target);
- },
- });
-
- return (
- {
- event.preventDefault();
- props.onOpenActionSheet(target);
- }}
- {...longPress}
- >
-
-
-
-
-
-
- );
- }}
-
- );
-};
-
-export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.Element => {
- const appShellData = useAppShellData();
- const [actionSheetTarget, setActionSheetTarget] = createSignal(null);
- const [dialogState, setDialogState] = createSignal(null);
- const [dialogValue, setDialogValue] = createSignal("");
- const [moveTarget, setMoveTarget] = createSignal(null);
- const { workspaceTreeNodes, createFolder, renameFolder, deleteFolder, moveFolder, createItem, deleteItem, moveItem } = useWorkspaceTreeData({
- activeProjectId: () => appShellData.activeProject().id,
- fallbackWorkspaceTree: () => appShellData.workspaceTree(),
- });
- const workspaceTarget = () => createWorkspaceSurfaceTarget(appShellData.activeProject());
-
- const moveDestinations = () => {
- const target = moveTarget();
- if (!target) {
- return [] as MobileMoveDestination[];
- }
-
- return [
- { id: null, label: "Items root", depth: 0, meta: "Root" },
- ...collectMoveDestinations(workspaceTreeNodes(), target),
- ];
- };
-
- const resolveCreateItemType = (actionId: string): WorkspaceItemTypeId | null => {
- switch (actionId) {
- case "create-doc":
- return "core.doc";
- case "create-board":
- return "core.board.kanban";
- case "create-list-board":
- return "core.board.list";
- default:
- return null;
- }
- };
-
- const createPersistedItem = (itemType: WorkspaceItemTypeId, parentId: string | null): void => {
- const definition = getWorkspaceItemTypeDefinition(itemType);
- void createItem(definition.defaultCreateLabel, itemType, parentId);
- };
-
- const openActionSheet = (target: WorkspaceContextMenuTarget): void => {
- setActionSheetTarget(target);
- };
-
- const closeActionSheet = (): void => {
- setActionSheetTarget(null);
- };
-
- const closeMoveSheet = (): void => {
- setMoveTarget(null);
- };
-
- const openWorkspaceActionSheet = (): void => {
- openActionSheet(workspaceTarget());
- };
-
- const closeDialog = (): void => {
- setDialogState(null);
- setDialogValue("");
- };
-
- const openMoveSheet = (target: MobileMoveTargetState): void => {
- setMoveTarget(target);
- };
-
- const openTextDialog = (config: Omit, "kind">): void => {
- setDialogValue(config.initialValue);
- setDialogState({ kind: "text", ...config });
- };
-
- const openConfirmDialog = (config: Omit, "kind">): void => {
- setDialogValue("");
- setDialogState({ kind: "confirm", ...config });
- };
-
- const submitDialog = (): void => {
- const state = dialogState();
- if (!state) {
- return;
- }
-
- if (state.kind === "text") {
- const value = dialogValue().trim();
- if (!value) {
- return;
- }
-
- closeDialog();
- state.onConfirm(value);
- return;
- }
-
- closeDialog();
- state.onConfirm();
- };
-
- const handleMoveDestinationSelect = (destinationId: string | null): void => {
- const target = moveTarget();
- if (!target) {
- return;
- }
-
- const destinationNode = destinationId ? findTreeNodeById(workspaceTreeNodes(), destinationId) : null;
- const targetIndex = destinationNode?.kind === "folder"
- ? destinationNode.children?.length ?? 0
- : destinationId
- ? 0
- : workspaceTreeNodes().length;
-
- closeMoveSheet();
-
- if (target.kind === "folder") {
- void moveFolder(target.id, destinationId, targetIndex);
- return;
- }
-
- void moveItem(target.id, destinationId, targetIndex);
- };
-
- const handleActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
- const createItemType = resolveCreateItemType(action.id);
- if (createItemType) {
- switch (target.kind) {
- case "workspace":
- case "home":
- createPersistedItem(createItemType, null);
- return;
- case "folder":
- createPersistedItem(createItemType, target.id);
- return;
- case "settings":
- case "item":
- return;
- }
- }
-
- switch (action.id) {
- case "new-folder": {
- if (target.kind === "settings" || target.kind === "item") {
- return;
- }
-
- openTextDialog({
- title: "New folder",
- message: target.kind === "folder" ? `Create a folder inside "${target.label}".` : "Create a folder at the root of Items.",
- confirmLabel: "Create",
- initialValue: "Untitled folder",
- onConfirm: (name) => {
- void createFolder(name, target.kind === "folder" ? target.id : null);
- },
- });
- return;
- }
- case "rename-folder": {
- if (target.kind !== "folder") {
- return;
- }
-
- openTextDialog({
- title: "Rename folder",
- message: `Update the name for "${target.label}".`,
- confirmLabel: "Save",
- initialValue: target.label,
- onConfirm: (name) => {
- if (name === target.label) {
- return;
- }
- void renameFolder(target.id, name);
- },
- });
- return;
- }
- case "move-folder": {
- if (target.kind !== "folder") {
- return;
- }
-
- openMoveSheet({
- kind: "folder",
- id: target.id,
- label: target.label,
- });
- return;
- }
- case "delete-folder": {
- if (target.kind !== "folder") {
- return;
- }
-
- openConfirmDialog({
- title: "Delete folder?",
- message: `"${target.label}" and everything inside it will be removed.`,
- confirmLabel: "Delete",
- tone: "danger",
- onConfirm: () => {
- void deleteFolder(target.id);
- },
- });
- return;
- }
- case "move-doc":
- case "move-board":
- case "move-list-board": {
- if (target.kind !== "item") {
- return;
- }
-
- openMoveSheet({
- kind: "item",
- id: target.id,
- label: target.label,
- });
- return;
- }
- case "delete-doc":
- case "delete-board":
- case "delete-list-board": {
- if (target.kind !== "item") {
- return;
- }
-
- openConfirmDialog({
- title: "Delete item?",
- message: `"${target.label}" will be removed from the project tree.`,
- confirmLabel: "Delete",
- tone: "danger",
- onConfirm: () => {
- void deleteItem(target.id);
- },
- });
- return;
- }
- default:
- return;
- }
- };
-
- const workspaceLongPress = createLongPressGesture({
- onLongPress: openWorkspaceActionSheet,
- });
-
- return (
-
-
-
-
-
-
-
- Workspace
-
-
- {(item): JSX.Element => }
-
-
-
-
-
-
-
-
-
-
-
- {(state): JSX.Element => (
-
-
-
-
-
-
-
-
-
Destination
-
-
- {(destination): JSX.Element => (
-
- )}
-
-
-
-
-
-
-
-
- )}
-
-
-
- {(state): JSX.Element => (
-
-
-
-
-
-
{state().title}
-
{state().message}
-
-
-
- {
- setDialogValue(event.currentTarget.value);
- }}
- onKeyDown={(event): void => {
- if (event.key === "Enter") {
- event.preventDefault();
- submitDialog();
- }
- }}
- autofocus
- />
-
-
-
-
-
-
-
-
-
- )}
-
-
-
- );
-};
diff --git a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx
deleted file mode 100644
index 7d5c7ac..0000000
--- a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx
+++ /dev/null
@@ -1,1216 +0,0 @@
-// Path: Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx
-
-import { For, Show, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js";
-import { Portal } from "solid-js/web";
-import { ChevronDown, ChevronRight, Folder, LayoutGrid, ListCollapse, Plus, 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;
-};
-
-type DragGhostPosition = {
- x: number;
- y: number;
-};
-
-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[] => [
- // The selector still presents scaffold project leaves beside persisted folders.
- // Keep that mixed root shape explicit here so the drag/drop logic can account
- // for folder-only ordering when we persist sibling positions.
- ...items.map((item) => ({
- kind: "project" as const,
- item,
- })),
- ...buildPersistedFolderNodes(folders),
-];
-
-const countProjectFolderSiblingsBeforeIndex = (
- siblings: readonly ProjectTreeNode[],
- index: number,
-): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length;
-
-const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] =>
- Array.isArray(body.data?.folders) ? body.data.folders : [];
-
-const projectTreeAdapter: NavTreeAdapter = {
- 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 (
-
-
-
- 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();
- }
- }}
- />
-
-
- );
-};
-
-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 => (
-
-
- -
-
-
-
-
-
- {(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 (
- -
- {
- 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)
- }
- >
-
-
- {node.label}
-
- {node.meta}
-
-
- }
- >
-
-
-
- 0 || props.pendingFolderDraft?.parentId === node.id)}>
-
-
-
- );
- }
-
- return (
- -
-
-
- );
- }}
-
-
-
-
-
-
-);
-
-export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
- const appShellData = useAppShellData();
- const [selectedProject, setSelectedProject] = createSignal(appShellData.activeProject());
- const [drawerTop, setDrawerTop] = createSignal(0);
- const [collapsedFolderIds, setCollapsedFolderIds] = createSignal([]);
- const [persistedFolders, setPersistedFolders] = createSignal([]);
- const [projectTreeNodes, setProjectTreeNodes] = createSignal(
- buildProjectTree(appShellData.projectItems(), persistedFolders()),
- );
- const [pendingFolderDraft, setPendingFolderDraft] = createSignal(null);
- const [pendingFolderName, setPendingFolderName] = createSignal("");
- const [pendingFolderRename, setPendingFolderRename] = createSignal(null);
- const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal("");
- const [dragState, setDragState] = createSignal(null);
- const [dragGhostPosition, setDragGhostPosition] = createSignal({ x: 0, y: 0 });
- 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 updateDragGhostPosition = (x: number, y: number): void => {
- setDragGhostPosition({ x: x + 18, y: y + 18 });
- };
-
- const draggedNode = (): ProjectTreeNode | null => {
- const currentDragState = dragState();
- if (!currentDragState) {
- return null;
- }
-
- return findTreeNodeLocation(projectTreeNodes(), currentDragState.draggedNodeId, projectTreeAdapter)?.node ?? null;
- };
-
- const draggedNodeMeta = (): string => {
- const node = draggedNode();
- if (!node) {
- return "";
- }
-
- return node.kind === "folder" ? "Folder" : "Project";
- };
-
- 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 => {
- 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;
- // The preview tree includes project leaves and folders, but the backend only
- // stores sibling order for folders. Persist a folder-only index so the server
- // can reapply the same position against the authoritative ordered tree.
- const targetIndex = previewLocation
- ? countProjectFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index)
- : 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();
- };
-
- const handlePointerMove = (event: PointerEvent): void => {
- if (!dragState()) {
- return;
- }
-
- updateDragGhostPosition(event.clientX, event.clientY);
- };
-
- document.addEventListener("pointerdown", handlePointerDown);
- window.addEventListener("pointerup", handlePointerUp);
- window.addEventListener("pointercancel", handlePointerUp);
- window.addEventListener("pointermove", handlePointerMove);
- 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("pointermove", handlePointerMove);
- 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 => {
- 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 => {
- 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,
- folderStableId: string,
- parentStableId: string | null,
- targetIndex: number,
- ): Promise => {
- const projectId = selectedProject().id;
- if (!folderPath || !folderStableId || !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: folderStableId,
- parentFolderId: parentFolderPath,
- parentNodeId: parentStableId,
- 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 => {
- 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 openRootCreateMenu = (element: HTMLElement): void => {
- contextMenu.openMenuFromElement(element, 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;
- }
-
- updateDragGhostPosition(event.clientX, event.clientY);
- 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 (
-
-
-
-
- <>
-
-
-
-
-
-
-
{
- 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}
- />
-
-
- >
-
-
-
{
- 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}
- />
-
-
- {(node): JSX.Element => {
- const GhostIcon = node.kind === "folder" ? Folder : LayoutGrid;
-
- return (
-
-
-
-
-
-
{node.kind === "folder" ? node.label : node.item.name}
-
{draggedNodeMeta()}
-
-
-
-
- );
- }}
-
-
- );
-};
diff --git a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
deleted file mode 100644
index 3e92f5b..0000000
--- a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
+++ /dev/null
@@ -1,1017 +0,0 @@
-// Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
-
-import { For, Show, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js";
-import { Portal } from "solid-js/web";
-import { ChevronLeft, ChevronRight, Folder, ListCollapse, Plus, 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,
- getWorkspaceItemTypeDefinition,
- workspaceSidebarHeaderActions,
- workspaceStaticItems,
- type WorkspaceContextMenuAction,
- type WorkspaceContextMenuTarget,
- type WorkspaceItemTypeId,
- type WorkspaceStaticItem,
- type WorkspaceTreeNode,
-} from "../data/shell.data";
-import { useWorkspaceTreeData } from "../shared/useWorkspaceTreeData";
-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 DragGhostPosition = {
- x: number;
- y: number;
-};
-
-type PendingWorkspaceFolderRename = {
- folderId: string;
- depth: number;
-};
-
-const LONG_PRESS_MS = 320;
-
-const getWorkspaceTreeNodeId = (node: WorkspaceTreeNode): string => node.id;
-
-const countWorkspaceFolderSiblingsBeforeIndex = (
- siblings: readonly WorkspaceTreeNode[],
- index: number,
-): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length;
-
-const workspaceTreeAdapter: NavTreeAdapter = {
- 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 (
-
-
-
- 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();
- }
- }}
- />
-
-
- );
-};
-
-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 (
-
-
-
- );
-};
-
-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 (
-
-
- -
-
-
-
-
- {(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 (
- -
- {
- 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);
- }}
- >
-
-
-
-
- {node.label}
-
- {node.meta}
-
-
- }
- >
-
-
-
- 0) || props.pendingFolderDraft?.parentId === node.id)}>
-
-
-
- );
- }}
-
-
-
-
-
-
- );
-};
-
-export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
- const appShellData = useAppShellData();
- const activeProject = () => appShellData.activeProject();
- const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false);
- const [collapsedFolderIds, setCollapsedFolderIds] = createSignal([]);
- const [pendingFolderDraft, setPendingFolderDraft] = createSignal(null);
- const [pendingFolderName, setPendingFolderName] = createSignal("");
- const [pendingFolderRename, setPendingFolderRename] = createSignal(null);
- const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal("");
- const [dragState, setDragState] = createSignal(null);
- const [dragGhostPosition, setDragGhostPosition] = createSignal({ x: 0, y: 0 });
- const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false);
- let lastSelectedProjectId: string | null = null;
- const {
- workspaceTreeNodes,
- setWorkspaceTreeNodes,
- resolveFolderPath,
- resolveItemPath,
- createFolder,
- renameFolder,
- deleteFolder,
- moveFolder,
- createItem,
- deleteItem,
- moveItem,
- } = useWorkspaceTreeData({
- activeProjectId: () => activeProject()?.id ?? "",
- fallbackWorkspaceTree: () => appShellData.workspaceTree(),
- });
- 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 syncCollapsedFolderIds = (): void => {
- const availableFolderIds = new Set(collectBranchNodeIds(workspaceTreeNodes(), workspaceTreeAdapter));
- setCollapsedFolderIds((current) => current.filter((id) => availableFolderIds.has(id)));
- };
- const updateDragGhostPosition = (x: number, y: number): void => {
- setDragGhostPosition({ x: x + 18, y: y + 18 });
- };
- const draggedNode = (): WorkspaceTreeNode | null => {
- const currentDragState = dragState();
- if (!currentDragState) {
- return null;
- }
-
- return findTreeNodeLocation(workspaceTreeNodes(), currentDragState.draggedNodeId, workspaceTreeAdapter)?.node ?? null;
- };
- const draggedNodeMeta = (): string => {
- const node = draggedNode();
- if (!node) {
- return "";
- }
-
- if (node.kind === "folder") {
- return "Folder";
- }
-
- return getWorkspaceItemTypeDefinition(node.itemType).label;
- };
- 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(() => {
- syncCollapsedFolderIds();
- });
-
- createEffect(() => {
- const projectId = activeProject()?.id ?? null;
-
- if (lastSelectedProjectId === null) {
- lastSelectedProjectId = projectId;
- return;
- }
-
- if (projectId === lastSelectedProjectId) {
- return;
- }
-
- lastSelectedProjectId = projectId;
- resetWorkspaceTreeInteractionState();
- });
-
- onMount(() => {
- const handlePointerMove = (event: PointerEvent): void => {
- if (!dragState()) {
- return;
- }
-
- updateDragGhostPosition(event.clientX, event.clientY);
- };
-
- 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 canPersistMove = isUuidString(activeProject()?.id ?? "");
- const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter);
- const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, workspaceTreeAdapter);
- const persistedParentLocation = previewLocation?.parentId
- ? findTreeNodeLocation(previewNodes, previewLocation.parentId, workspaceTreeAdapter)
- : null;
- const previewSiblings = previewLocation?.parentId
- ? persistedParentLocation?.node.kind === "folder"
- ? persistedParentLocation.node.children ?? []
- : []
- : previewNodes;
- // The tree preview can include static/workspace items, but persisted ordering
- // only applies to folder siblings. Convert the preview position into a folder-
- // only index before sending it to the backend move endpoint.
- const targetIndex = previewLocation
- ? countWorkspaceFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index)
- : 0;
-
- if (
- canPersistMove &&
- draggedLocation?.node.kind === "folder" &&
- (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder")
- ) {
- void moveFolder(
- draggedLocation.node.id,
- persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null,
- targetIndex,
- );
- } else if (
- canPersistMove &&
- draggedLocation?.node.kind === "item" &&
- resolveItemPath(draggedLocation.node.id) &&
- (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder")
- ) {
- void moveItem(
- draggedLocation.node.id,
- persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null,
- previewLocation?.index ?? 0,
- );
- } 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("pointermove", handlePointerMove);
- window.addEventListener("keydown", handleEscape);
-
- onCleanup(() => {
- clearLongPressTimer();
- if (suppressClickTimer !== undefined) {
- window.clearTimeout(suppressClickTimer);
- }
- window.removeEventListener("pointerup", handlePointerUp);
- window.removeEventListener("pointercancel", handlePointerUp);
- window.removeEventListener("pointermove", handlePointerMove);
- 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 => {
- 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;
- }
-
- const parentFolderPath = draft.parentId ? resolveFolderPath(draft.parentId) : null;
- if (draft.parentId && !parentFolderPath) {
- cancelPendingFolder();
- return;
- }
-
- const created = await createFolder(name, draft.parentId);
- if (created) {
- setPendingFolderDraft(null);
- setPendingFolderName("");
- }
- };
-
- const deletePersistedFolder = async (folderId: string): Promise => {
- const deleted = await deleteFolder(folderId);
- if (deleted) {
- setCollapsedFolderIds((current) => current.filter((id) => id !== folderId));
- }
- };
-
- const submitPendingFolderRename = async (): Promise => {
- 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;
- }
-
- const folderPath = resolveFolderPath(draft.folderId);
- if (!folderPath) {
- cancelPendingFolderRename();
- return;
- }
-
- const renamed = await renameFolder(draft.folderId, name);
- if (renamed) {
- setPendingFolderRename(null);
- setPendingFolderRenameName("");
- }
- };
-
- 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 openWorkspaceCreateMenu = (element: HTMLElement): void => {
- contextMenu.openMenuFromElement(element, sidebarContextMenuTarget);
- };
-
- const resolveCreateItemType = (actionId: string): WorkspaceItemTypeId | null => {
- switch (actionId) {
- case "create-doc":
- return "core.doc";
- case "create-board":
- return "core.board.kanban";
- case "create-list-board":
- return "core.board.list";
- default:
- return null;
- }
- };
-
- const createPersistedItem = async (itemType: WorkspaceItemTypeId, parentId: string | null): Promise => {
- const definition = getWorkspaceItemTypeDefinition(itemType);
- await createItem(definition.defaultCreateLabel, itemType, parentId);
- };
-
- const handleNodePointerDown = (event: PointerEvent, nodeId: string): void => {
- if (event.button !== 0 || pendingFolderDraft()) {
- return;
- }
-
- updateDragGhostPosition(event.clientX, event.clientY);
- 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 => {
- const createItemType = resolveCreateItemType(action.id);
- if (createItemType) {
- switch (target.kind) {
- case "workspace":
- case "home":
- void createPersistedItem(createItemType, null);
- return;
- case "folder":
- void createPersistedItem(createItemType, target.id);
- return;
- case "settings":
- case "item":
- return;
- }
- }
-
- 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;
- case "delete-doc":
- case "delete-board":
- case "delete-list-board":
- if (target.kind === "item") {
- void deleteItem(target.id);
- }
- return;
- default:
- return;
- }
- };
-
- return (
- <>
-
-
- {
- const state = contextMenu.menuState();
- return state
- ? {
- x: state.x,
- y: state.y,
- }
- : null;
- })()}
- menuRef={contextMenu.setMenuRef}
- onClose={contextMenu.closeMenu}
- onSelect={handleContextActionSelect}
- />
-
-
- {(node): JSX.Element => {
- const GhostIcon = getWorkspaceNodeIcon(node);
-
- return (
-
-
-
-
-
-
{node.label}
-
{draggedNodeMeta()}
-
-
-
-
- );
- }}
-
- >
- );
-};
diff --git a/Frontend/src/components/shell/data/app-shell.context.tsx b/Frontend/src/components/shell/data/app-shell.context.tsx
deleted file mode 100644
index 66198e7..0000000
--- a/Frontend/src/components/shell/data/app-shell.context.tsx
+++ /dev/null
@@ -1,375 +0,0 @@
-// Path: Frontend/src/components/shell/data/app-shell.context.tsx
-
-import {
- createContext,
- createMemo,
- createSignal,
- onMount,
- useContext,
- type Accessor,
- type JSX,
-} from "solid-js";
-import { Folder } from "../../../lib/icons";
-import { resolveAPIBase } from "../../../lib/api";
-import {
- activeDepartment as fallbackActiveDepartment,
- activeProject as fallbackActiveProject,
- activeServer as fallbackActiveServer,
- activeUserProfile as fallbackActiveUserProfile,
- departmentItems as fallbackDepartmentItems,
- organizationAdminDockActions,
- personalDockActions,
- projectItems as fallbackProjectItems,
- railItems as fallbackRailItems,
- workspaceTree as fallbackWorkspaceTree,
- type ActiveDepartment,
- type ActiveProject,
- type ActiveServer,
- type ActiveUserProfile,
- type DepartmentItem,
- type ProjectItem,
- type RailItem,
- type WorkspaceTreeNode,
-} from "./shell.data";
-
-type AppShellInstallation = {
- id: string;
- name: string;
- mode: "personal" | "organizational" | string;
- access: string;
- protocol: string;
- host: string;
- isBootstrapped: boolean;
- materializationStatus: "not_started" | "pending" | "running" | "succeeded" | "failed" | string;
- materializationError?: string;
-};
-
-type AppShellAdmin = {
- id: string;
- email: string;
- displayName: string;
- isInstanceAdmin: boolean;
- homeTitle: string;
-};
-
-type AppShellOrganization = {
- id: string;
- name: string;
- slug: string;
-};
-
-type AppShellDepartment = {
- id: string;
- organizationId: string;
- name: string;
- slug: string;
-};
-
-type AppShellTeam = {
- id: string;
- organizationId: string;
- departmentId?: string;
- name: string;
- slug: string;
-};
-
-type AppShellProject = {
- id: string;
- organizationId: string;
- departmentId?: string;
- teamId?: string;
- name: string;
- slug: string;
-};
-
-type AppShellWorkspace = {
- id: string;
- organizationId: string;
- name: string;
- slug: string;
- kind: "organization" | "department" | "team" | "project" | string;
- departmentId?: string;
- teamId?: string;
- projectId?: string;
-};
-
-type AppShellPayload = {
- installation?: AppShellInstallation;
- admin?: AppShellAdmin;
- organizations: AppShellOrganization[];
- departments: AppShellDepartment[];
- teams: AppShellTeam[];
- projects: AppShellProject[];
- workspaces: AppShellWorkspace[];
-};
-
-const normalizeInstallation = (
- installation: AppShellInstallation | null | undefined,
-): AppShellInstallation | undefined => {
- if (!installation) {
- return undefined;
- }
-
- const materializationStatus = installation.materializationStatus?.trim()
- ? installation.materializationStatus
- : installation.isBootstrapped
- ? "succeeded"
- : "not_started";
- const materializationError = installation.materializationError?.trim() || undefined;
-
- return {
- ...installation,
- materializationStatus,
- materializationError,
- };
-};
-
-const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({
- installation: normalizeInstallation(payload?.installation),
- admin: payload?.admin,
- organizations: Array.isArray(payload?.organizations) ? payload.organizations : [],
- departments: Array.isArray(payload?.departments) ? payload.departments : [],
- teams: Array.isArray(payload?.teams) ? payload.teams : [],
- projects: Array.isArray(payload?.projects) ? payload.projects : [],
- workspaces: Array.isArray(payload?.workspaces) ? payload.workspaces : [],
-});
-
-type AppShellContextValue = {
- status: Accessor<"idle" | "loading" | "success" | "error">;
- error: Accessor;
- installation: Accessor;
- railItems: Accessor;
- activeServer: Accessor;
- activeProject: Accessor;
- activeDepartment: Accessor;
- projectItems: Accessor;
- departmentItems: Accessor;
- workspaceTree: Accessor;
- activeUserProfile: Accessor;
- reload: () => Promise;
-};
-
-const AppShellContext = createContext();
-
-const buildAbbreviation = (name: string, fallback: string): string => {
- const parts = name
- .trim()
- .split(/\s+/)
- .filter(Boolean);
-
- if (parts.length === 0) {
- return fallback;
- }
-
- const abbreviation = parts
- .slice(0, 2)
- .map((part) => part[0]?.toUpperCase() ?? "")
- .join("");
-
- return abbreviation || fallback;
-};
-
-const buildRailItems = (payload: AppShellPayload | null): readonly RailItem[] => {
- if (!payload?.installation || payload.organizations.length === 0) {
- return fallbackRailItems;
- }
-
- const kind = payload.installation.mode === "personal" ? "personal" : "organization";
- const serverName = payload.installation.name || payload.organizations[0]?.name || payload.installation.host;
-
- return payload.organizations.map((organization, index) => ({
- id: organization.id,
- label: serverName || organization.name,
- abbreviation: buildAbbreviation(serverName || organization.name, kind === "personal" ? "P" : "O"),
- kind,
- active: index === 0,
- }));
-};
-
-const buildActiveServer = (payload: AppShellPayload | null): ActiveServer => {
- const installation = payload?.installation;
- const organization = payload?.organizations[0];
-
- if (!installation || !organization) {
- return fallbackActiveServer;
- }
-
- const kind = installation.mode === "personal" ? "personal" : "organization";
- const serverName = installation.name || organization.name || installation.host;
-
- return {
- id: installation.id,
- name: serverName || fallbackActiveServer.name,
- abbreviation: buildAbbreviation(serverName, kind === "personal" ? "P" : "O"),
- kind,
- connectedLabel: kind === "organization" ? `${payload?.teams.length ?? 0} connected` : undefined,
- subtitle: kind === "personal" ? installation.host || payload?.admin?.homeTitle || "Personal home" : undefined,
- dockActions: kind === "personal" ? personalDockActions : organizationAdminDockActions,
- };
-};
-
-const buildProjectItems = (payload: AppShellPayload | null): readonly ProjectItem[] => {
- if (!payload?.projects.length) {
- return fallbackProjectItems;
- }
-
- return payload.projects.map((project, index) => ({
- id: project.id,
- name: project.name,
- description: project.slug || "Persisted project workspace",
- groupLabel: payload.departments.find((department) => department.id === project.departmentId)?.name || "Projects",
- parentLabel:
- payload.teams.find((team) => team.id === project.teamId)?.name ||
- payload.departments.find((department) => department.id === project.departmentId)?.name ||
- "Shared project",
- meta: (() => {
- const workspaceCount = payload.workspaces.filter((workspace) => workspace.projectId === project.id).length;
-
- return workspaceCount > 0 ? `${workspaceCount} workspace${workspaceCount === 1 ? "" : "s"}` : undefined;
- })(),
- active: index === 0,
- }));
-};
-
-const buildActiveProject = (payload: AppShellPayload | null): ActiveProject => {
- const firstProject = payload?.projects[0];
-
- if (!firstProject) {
- return fallbackActiveProject;
- }
-
- return {
- id: firstProject.id,
- name: firstProject.name,
- };
-};
-
-const buildDepartmentItems = (payload: AppShellPayload | null): readonly DepartmentItem[] => {
- if (!payload?.departments.length) {
- return fallbackDepartmentItems;
- }
-
- return payload.departments.map((department, index) => ({
- id: department.id,
- name: department.name,
- teams: payload.teams
- .filter((team) => team.departmentId === department.id)
- .map((team) => team.name),
- active: index === 0,
- }));
-};
-
-const buildActiveDepartment = (payload: AppShellPayload | null): ActiveDepartment => {
- const firstDepartment = payload?.departments[0];
-
- if (!firstDepartment) {
- return fallbackActiveDepartment;
- }
-
- const firstTeamName = payload?.teams.find((team) => team.departmentId === firstDepartment.id)?.name ?? "";
-
- return {
- id: firstDepartment.id,
- name: firstDepartment.name,
- teamName: firstTeamName,
- };
-};
-
-const buildWorkspaceTree = (payload: AppShellPayload | null): readonly WorkspaceTreeNode[] => {
- if (!payload?.projects.length) {
- return fallbackWorkspaceTree;
- }
-
- // The workspace tree should represent items inside the current project, not the
- // project container itself. We do not have project-contents hydration yet, so
- // return an empty tree rather than showing the project root as a fake item.
- return [];
-};
-
-const buildActiveUserProfile = (payload: AppShellPayload | null): ActiveUserProfile => {
- if (!payload?.admin) {
- return fallbackActiveUserProfile;
- }
-
- const organizationName = payload.installation?.name || payload.organizations[0]?.name || fallbackActiveServer.name;
- const departmentName = payload.departments[0]?.name;
-
- return {
- name: payload.admin.displayName,
- email: payload.admin.email,
- roleLabel: payload.admin.isInstanceAdmin ? "Instance admin" : "Member",
- contextLabel: departmentName ? `${organizationName} • ${departmentName}` : organizationName,
- };
-};
-
-export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Element => {
- const [status, setStatus] = createSignal<"idle" | "loading" | "success" | "error">("idle");
- const [error, setError] = createSignal("");
- const [payload, setPayload] = createSignal(null);
-
- const load = async (): Promise => {
- setStatus("loading");
- setError("");
-
- try {
- const response = await fetch(`${resolveAPIBase()}/app-shell`, {
- headers: {
- Accept: "application/json",
- },
- });
-
- const body = (await response.json()) as {
- data?: AppShellPayload;
- error?: { message?: string } | string;
- message?: string;
- };
- const errorMessage =
- typeof body.message === "string"
- ? body.message
- : typeof body.error === "string"
- ? body.error
- : body.error?.message;
-
- if (!response.ok || !body.data) {
- throw new Error(errorMessage || "Failed to load app shell state.");
- }
-
- setPayload(normalizeAppShellPayload(body.data));
- setStatus("success");
- } catch (loadError) {
- setStatus("error");
- setError(loadError instanceof Error ? loadError.message : "Failed to load app shell state.");
- }
- };
-
- onMount(() => {
- void load();
- });
-
- const value: AppShellContextValue = {
- status,
- error,
- installation: createMemo(() => payload()?.installation),
- railItems: createMemo(() => buildRailItems(payload())),
- activeServer: createMemo(() => buildActiveServer(payload())),
- activeProject: createMemo(() => buildActiveProject(payload())),
- activeDepartment: createMemo(() => buildActiveDepartment(payload())),
- projectItems: createMemo(() => buildProjectItems(payload())),
- departmentItems: createMemo(() => buildDepartmentItems(payload())),
- workspaceTree: createMemo(() => buildWorkspaceTree(payload())),
- activeUserProfile: createMemo(() => buildActiveUserProfile(payload())),
- reload: load,
- };
-
- return {props.children};
-};
-
-export const useAppShellData = (): AppShellContextValue => {
- const context = useContext(AppShellContext);
-
- if (!context) {
- throw new Error("useAppShellData must be used within AppShellDataProvider");
- }
-
- return context;
-};
diff --git a/Frontend/src/components/shell/data/shell.data.ts b/Frontend/src/components/shell/data/shell.data.ts
deleted file mode 100644
index 0479831..0000000
--- a/Frontend/src/components/shell/data/shell.data.ts
+++ /dev/null
@@ -1,734 +0,0 @@
-// Path: Frontend/src/components/shell/data/shell.data.ts
-
-import type { Component } from "solid-js";
-import {
- Bell,
- CircleHelp,
- FileText,
- Folder,
- Home,
- Keyboard,
- LayoutGrid,
- ListCollapse,
- LogOut,
- Repeat,
- Search,
- Settings,
- Shield,
- User,
-} from "../../../lib/icons";
-
-type ShellIconProps = {
- class?: string;
- size?: number;
- strokeWidth?: number;
-};
-
-export type ShellIcon = Component;
-
-export type RailItem = {
- id: string;
- label: string;
- abbreviation: string;
- kind: "personal" | "organization";
- active?: boolean;
-};
-
-export type ServerDockAction = {
- id: string;
- label: string;
- icon: ShellIcon;
-};
-
-export type ActiveServer = {
- id: string;
- name: string;
- abbreviation: string;
- kind: "personal" | "organization";
- connectedLabel?: string;
- subtitle?: string;
- dockActions: readonly ServerDockAction[];
-};
-
-export type ActiveProject = {
- id: string;
- name: string;
-};
-
-export type ActiveDepartment = {
- id: string;
- name: string;
- teamName: string;
-};
-
-export type DepartmentItem = {
- id: string;
- name: string;
- teams: readonly string[];
- active?: boolean;
-};
-
-export type ProjectItem = {
- id: string;
- name: string;
- description: string;
- groupLabel?: string;
- parentLabel?: string;
- meta?: string;
- active?: boolean;
-};
-
-export type ProjectMenuTarget =
- | {
- id: string;
- label: string;
- kind: "surface";
- }
- | {
- id: string;
- label: string;
- kind: "folder";
- }
- | {
- id: string;
- label: string;
- kind: "project";
- };
-
-export type ProjectContextMenuAction = {
- id: string;
- label: string;
- tone?: "default" | "danger";
- shortcut?: WorkspaceContextMenuShortcut;
- children?: readonly ProjectContextMenuAction[];
-};
-
-export type ProjectContextMenuSection = {
- id: string;
- label?: string;
- items: readonly ProjectContextMenuAction[];
-};
-
-export type SidebarItem = {
- id: string;
- label: string;
- icon: ShellIcon;
- active?: boolean;
- meta?: string;
-};
-
-export type WorkspaceStaticKind = "workspace" | "home" | "settings";
-
-// Keep this open-ended so future server-driven or plugin-provided item types do
-// not require a frontend source edit before they can be represented safely.
-export type WorkspaceItemTypeId = string;
-
-export type WorkspaceStaticItem = SidebarItem & {
- contextKind: WorkspaceStaticKind;
-};
-
-export type WorkspaceFolderNode = {
- id: string;
- path?: string;
- label: string;
- kind: "folder";
- icon: ShellIcon;
- active?: boolean;
- meta?: string;
- children?: readonly WorkspaceTreeNode[];
-};
-
-export type WorkspaceItemNode = {
- id: string;
- path?: string;
- label: string;
- kind: "item";
- itemType: WorkspaceItemTypeId;
- active?: boolean;
- meta?: string;
- children?: undefined;
-};
-
-export type WorkspaceTreeNode = WorkspaceFolderNode | WorkspaceItemNode;
-
-export type WorkspaceItemTypeDefinition = {
- id: WorkspaceItemTypeId;
- label: string;
- shortLabel: string;
- icon: ShellIcon;
- noun: string;
- actionPrefix: string;
- defaultCreateLabel: string;
- includeInWorkspaceCreate?: boolean;
- description?: string;
-};
-
-export type SidebarHeaderAction = {
- id: string;
- label: string;
- icon: ShellIcon;
-};
-
-export type TopBarAction = {
- id: string;
- label: string;
- icon: ShellIcon;
-};
-
-export type MobileBottomNavItem = {
- id: string;
- label: string;
- icon: ShellIcon;
- active?: boolean;
-};
-
-export type WorkspaceContextMenuTarget =
- | {
- id: string;
- label: string;
- kind: WorkspaceStaticKind;
- }
- | {
- id: string;
- label: string;
- kind: "folder";
- }
- | {
- id: string;
- label: string;
- kind: "item";
- itemType: WorkspaceItemTypeId;
- };
-
-export type WorkspaceContextMenuAction = {
- id: string;
- label: string;
- tone?: "default" | "danger";
- shortcut?: WorkspaceContextMenuShortcut;
- children?: readonly WorkspaceContextMenuAction[];
-};
-
-export type WorkspaceContextMenuShortcutModifier = "meta" | "alt" | "shift";
-
-export type WorkspaceContextMenuShortcutKey = "b" | "c" | "d" | "delete" | "enter" | "f" | "m" | "r";
-
-export type WorkspaceContextMenuShortcut = {
- modifiers?: readonly WorkspaceContextMenuShortcutModifier[];
- key: WorkspaceContextMenuShortcutKey;
-};
-
-export type WorkspaceContextMenuSection = {
- id: string;
- label?: string;
- items: readonly WorkspaceContextMenuAction[];
-};
-
-export const firstPartyWorkspaceItemTypes: readonly WorkspaceItemTypeDefinition[] = [
- {
- id: "core.doc",
- label: "Doc",
- shortLabel: "Doc",
- icon: FileText,
- noun: "doc",
- actionPrefix: "doc",
- defaultCreateLabel: "New doc",
- includeInWorkspaceCreate: true,
- description: "Rich text documents and notes.",
- },
- {
- id: "core.board.kanban",
- label: "Kanban board",
- shortLabel: "Board",
- icon: LayoutGrid,
- noun: "board",
- actionPrefix: "board",
- defaultCreateLabel: "New board",
- includeInWorkspaceCreate: true,
- description: "Default board-style workspace item.",
- },
- {
- id: "core.board.list",
- label: "List board",
- shortLabel: "Board",
- icon: LayoutGrid,
- noun: "board",
- actionPrefix: "list-board",
- defaultCreateLabel: "New list board",
- description: "Alternate first-party board view prepared for the future registry.",
- },
-] as const;
-
-const workspaceItemTypeMap = new Map(
- firstPartyWorkspaceItemTypes.map((definition) => [definition.id, definition]),
-);
-
-const createUnknownWorkspaceItemTypeDefinition = (
- itemType: WorkspaceItemTypeId,
-): WorkspaceItemTypeDefinition => ({
- id: itemType,
- label: "Item",
- shortLabel: "Item",
- icon: FileText,
- noun: "item",
- actionPrefix: "item",
- defaultCreateLabel: "New item",
- description: "Fallback definition for unknown or future workspace item types.",
-});
-
-export const getWorkspaceItemTypeDefinition = (itemType: WorkspaceItemTypeId): WorkspaceItemTypeDefinition => {
- return workspaceItemTypeMap.get(itemType) ?? createUnknownWorkspaceItemTypeDefinition(itemType);
-};
-
-export const getWorkspaceNodeIcon = (node: WorkspaceTreeNode): ShellIcon =>
- node.kind === "folder" ? node.icon : getWorkspaceItemTypeDefinition(node.itemType).icon;
-
-const getWorkspaceCreateActions = (): readonly WorkspaceContextMenuAction[] => [
- { id: "new-folder", label: "New folder", shortcut: { modifiers: ["alt"], key: "f" } },
- ...firstPartyWorkspaceItemTypes
- .filter((definition) => definition.includeInWorkspaceCreate)
- .map((definition) => ({
- id: `create-${definition.actionPrefix}`,
- label: definition.defaultCreateLabel,
- shortcut:
- definition.id === "core.board.kanban"
- ? ({ modifiers: ["alt"], key: "b" } as const)
- : definition.id === "core.doc"
- ? ({ modifiers: ["alt"], key: "d" } as const)
- : undefined,
- })),
-];
-
-export const getWorkspaceContextMenuEyebrow = (target: WorkspaceContextMenuTarget): string => {
- switch (target.kind) {
- case "workspace":
- case "home":
- return "Workspace";
- case "settings":
- return "Configuration";
- case "folder":
- return "Folder";
- case "item":
- return getWorkspaceItemTypeDefinition(target.itemType).shortLabel;
- }
-};
-
-export const createWorkspaceSurfaceTarget = (workspace: ActiveProject): WorkspaceContextMenuTarget => ({
- id: `workspace-${workspace.id}`,
- label: workspace.name,
- kind: "workspace",
-});
-
-export const createWorkspaceStaticTarget = (item: WorkspaceStaticItem): WorkspaceContextMenuTarget => ({
- id: item.id,
- label: item.label,
- kind: item.contextKind,
-});
-
-export const createWorkspaceTreeTarget = (node: WorkspaceTreeNode): WorkspaceContextMenuTarget => ({
- id: node.id,
- label: node.label,
- ...(node.kind === "folder"
- ? { kind: "folder" as const }
- : {
- kind: "item" as const,
- itemType: node.itemType,
- }),
-});
-
-export type NotificationItem = {
- id: string;
- title: string;
- contextLabel: string;
- timeLabel: string;
- unread?: boolean;
-};
-
-export type ProfileMenuAction = {
- id: string;
- label: string;
- icon: ShellIcon;
- tone?: "default" | "danger";
-};
-
-export type ProfileMenuSection = {
- id: string;
- items: readonly ProfileMenuAction[];
-};
-
-export type ActiveUserProfile = {
- name: string;
- email: string;
- roleLabel: string;
- contextLabel: string;
-};
-
-export const personalDockActions: readonly ServerDockAction[] = [
- { id: "account", label: "Account", icon: User },
- { id: "settings", label: "Settings", icon: Settings },
-] as const;
-
-export const organizationAdminDockActions: readonly ServerDockAction[] = [
- { id: "members", label: "Members", icon: User },
- { id: "server", label: "Server", icon: Settings },
-] as const;
-
-// Server shell scaffold data
-export const railItems: readonly RailItem[] = [
- { id: "personal-server", label: "Personal Server Name", abbreviation: "P", kind: "personal" },
- { id: "organization-server", label: "Organization Name", abbreviation: "O", kind: "organization", active: true },
- { id: "design-review", label: "Design Review", abbreviation: "D", kind: "organization" },
-] as const;
-
-export const activeServer: ActiveServer = {
- id: "organization-server",
- name: "Organization Name",
- abbreviation: "O",
- kind: "organization",
- connectedLabel: "12 connected",
- dockActions: organizationAdminDockActions,
-};
-
-// Workspace framing scaffold data
-export const activeProject: ActiveProject = {
- id: "general",
- name: "General",
-};
-
-export const activeDepartment: ActiveDepartment = {
- id: "product",
- name: "Product",
- teamName: "Design Systems",
-};
-
-export const projectItems: readonly ProjectItem[] = [
- {
- id: "general",
- name: "General",
- description: "Default shared project",
- groupLabel: "Shared space",
- parentLabel: "Workspace home",
- meta: "1 workspace",
- active: true,
- },
- {
- id: "operations",
- name: "Operations",
- description: "Cross-team planning and delivery",
- groupLabel: "Team folders",
- parentLabel: "Shared Services",
- meta: "2 workspaces",
- },
- {
- id: "hiring",
- name: "Hiring",
- description: "Candidate pipeline and interview loops",
- groupLabel: "Team folders",
- parentLabel: "People Ops",
- meta: "1 workspace",
- },
-] as const;
-
-export const departmentItems: readonly DepartmentItem[] = [
- { id: "product", name: "Product", teams: ["Design Systems", "Research Ops"], active: true },
- { id: "engineering", name: "Engineering", teams: ["Platform", "Realtime Collaboration"] },
- { id: "operations", name: "Operations", teams: ["Shared Services", "People Ops"] },
-] as const;
-
-// Sidebar and topbar scaffold data
-// These static entries stay pinned in both desktop and mobile workspace navigation.
-export const workspaceStaticItems: readonly WorkspaceStaticItem[] = [
- { id: "home", label: "Home", icon: Home, active: true, contextKind: "home" },
- { id: "workspace-settings", label: "Settings", icon: Settings, contextKind: "settings" },
-] as const;
-
-// Freeform workspace tree scaffold: folders are structural, while non-folder
-// nodes already flow through the future-safe itemType registry seam.
-export const workspaceTree: readonly WorkspaceTreeNode[] = [
- {
- id: "product-workspace",
- label: "Product",
- kind: "folder",
- icon: Folder,
- children: [
- { id: "roadmap-board", label: "Roadmap", kind: "item", itemType: "core.board.kanban", active: true },
- { id: "launch-brief", label: "Launch Brief", kind: "item", itemType: "core.doc" },
- {
- id: "research-folder",
- label: "Research",
- kind: "folder",
- icon: Folder,
- children: [
- { id: "interviews-doc", label: "Interviews", kind: "item", itemType: "core.doc" },
- { id: "signals-board", label: "Signals", kind: "item", itemType: "core.board.kanban", meta: "2" },
- ],
- },
- ],
- },
- {
- id: "design-folder",
- label: "Design",
- kind: "folder",
- icon: Folder,
- children: [
- { id: "system-doc", label: "Design System", kind: "item", itemType: "core.doc" },
- { id: "review-board", label: "Review Queue", kind: "item", itemType: "core.board.kanban" },
- ],
- },
- { id: "general-notes", label: "General Notes", kind: "item", itemType: "core.doc" },
-] as const;
-
-export const workspaceSidebarHeaderActions: readonly SidebarHeaderAction[] = [
- { id: "search-workspace", label: "Search workspace", icon: Search },
- { id: "toggle-workspace-folders", label: "Collapse all folders", icon: ListCollapse },
-] as const;
-
-export const mobileBottomNavItems: readonly MobileBottomNavItem[] = [
- { id: "home", label: "Home", icon: Home, active: true },
- { id: "search", label: "Search", icon: Search },
- { id: "browse", label: "Browse", icon: Folder },
-] as const;
-
-// Initial context-menu IA scaffold. Behavior wiring can evolve later, but the
-// target kinds and action grouping should stay shared across workspace surfaces.
-export const getWorkspaceContextMenuSections = (
- target: WorkspaceContextMenuTarget,
-): readonly WorkspaceContextMenuSection[] => {
- const createActions = getWorkspaceCreateActions();
-
- const createSubmenuAction = {
- id: "create",
- label: "Create",
- children: createActions,
- } as const;
-
- switch (target.kind) {
- case "workspace":
- return [
- {
- id: "create",
- label: undefined,
- items: [createSubmenuAction],
- },
- {
- id: "workspace",
- label: undefined,
- items: [
- { id: "rename-workspace", label: "Rename workspace", shortcut: { key: "enter" } },
- { id: "copy-workspace-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
- ],
- },
- ] as const;
- case "home":
- return [
- {
- id: "create",
- label: undefined,
- items: [createSubmenuAction],
- },
- {
- id: "workspace",
- label: undefined,
- items: [{ id: "open-home", label: "Open home", shortcut: { key: "enter" } }],
- },
- ] as const;
- case "settings":
- return [
- {
- id: "settings",
- label: undefined,
- items: [
- { id: "open-settings", label: "Open settings", shortcut: { key: "enter" } },
- { id: "copy-settings-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
- ],
- },
- ] as const;
- case "folder":
- return [
- {
- id: "open",
- items: [
- { id: "open-folder", label: "Open folder", shortcut: { key: "enter" } },
- { id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
- ],
- },
- {
- id: "create",
- label: undefined,
- items: [createSubmenuAction],
- },
- {
- id: "organize",
- label: undefined,
- items: [
- { id: "move-folder", label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } },
- { id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
- ],
- },
- ] as const;
- case "item": {
- const definition = getWorkspaceItemTypeDefinition(target.itemType);
- const actionPrefix = definition.actionPrefix;
- const nounLabel = definition.noun;
-
- return [
- {
- id: `${actionPrefix}-primary`,
- items: [
- { id: `open-${actionPrefix}`, label: `Open ${nounLabel}`, shortcut: { key: "enter" } },
- { id: `rename-${actionPrefix}`, label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
- ],
- },
- {
- id: "organize",
- label: undefined,
- items: [
- { id: `move-${actionPrefix}`, label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } },
- { id: `delete-${actionPrefix}`, label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
- ],
- },
- ] as const;
- }
- }
-};
-
-const getProjectCreateActions = (): readonly ProjectContextMenuAction[] =>
- [
- { id: "new-project", label: "New project" },
- { id: "new-folder", label: "New folder" },
- ] as const;
-
-const getProjectFolderDangerActions = (): readonly ProjectContextMenuAction[] =>
- [
- { id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
- { id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
- ] as const;
-
-export const createProjectSurfaceTarget = (label = "Projects"): ProjectMenuTarget => ({
- id: "project-surface",
- label,
- kind: "surface",
-});
-
-export const createProjectFolderTarget = (id: string, label: string): ProjectMenuTarget => ({
- id,
- label,
- kind: "folder",
-});
-
-export const createProjectTarget = (project: ProjectItem): ProjectMenuTarget => ({
- id: project.id,
- label: project.name,
- kind: "project",
-});
-
-export const getProjectContextMenuEyebrow = (target: ProjectMenuTarget): string => {
- switch (target.kind) {
- case "surface":
- return "Projects";
- case "folder":
- return "Folder";
- case "project":
- return "Project";
- }
-};
-
-export const getProjectContextMenuSections = (target: ProjectMenuTarget): readonly ProjectContextMenuSection[] => {
- const createActions = getProjectCreateActions();
-
- switch (target.kind) {
- case "surface":
- return [
- {
- id: "create",
- items: createActions,
- },
- ] as const;
- case "folder":
- return [
- {
- id: "create",
- items: createActions,
- },
- {
- id: "organize",
- items: getProjectFolderDangerActions(),
- },
- ] as const;
- case "project":
- return [
- {
- id: "create",
- items: createActions,
- },
- ] as const;
- }
-};
-
-export const topBarActions: readonly TopBarAction[] = [
- { id: "search", label: "Search", icon: Search },
-] as const;
-
-export const notificationItems: readonly NotificationItem[] = [
- {
- id: "comment-design-systems",
- title: "New comment on Design Systems",
- contextLabel: "Product • Review thread updated",
- timeLabel: "2m ago",
- unread: true,
- },
- {
- id: "sprint-platform",
- title: "Sprint updated in Platform",
- contextLabel: "Engineering • Scope changed",
- timeLabel: "15m ago",
- unread: true,
- },
- {
- id: "member-joined",
- title: "New member joined Operations",
- contextLabel: "Organization Name • Access granted",
- timeLabel: "1h ago",
- },
- {
- id: "daily-summary",
- title: "Daily summary is ready",
- contextLabel: "General • 8 updates across boards",
- timeLabel: "Today, 8:00 AM",
- },
-] as const;
-
-export const unreadNotificationCount = notificationItems.filter((item) => item.unread).length;
-
-export const activeUserProfile: ActiveUserProfile = {
- name: "Demo Account",
- email: "demo@moku.work",
- roleLabel: "Founder · Product",
- contextLabel: "Organization Name • Design Systems",
-};
-
-export const profileMenuSections: readonly ProfileMenuSection[] = [
- {
- id: "account",
- items: [
- { id: "profile", label: "Profile", icon: User },
- { id: "account-settings", label: "Account Settings", icon: Settings },
- { id: "notifications", label: "Notifications", icon: Bell },
- { id: "security", label: "Security", icon: Shield },
- ],
- },
- {
- id: "preferences",
- items: [
- { id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard },
- { id: "theme-preferences", label: "Theme Preferences", icon: Settings },
- { id: "help-support", label: "Help & Support", icon: CircleHelp },
- ],
- },
- {
- id: "session",
- items: [
- { id: "switch-account", label: "Switch Account", icon: Repeat },
- { id: "sign-out", label: "Sign Out", icon: LogOut, tone: "danger" },
- ],
- },
-] as const;
diff --git a/Frontend/src/components/shell/shared/useWorkspaceTreeData.ts b/Frontend/src/components/shell/shared/useWorkspaceTreeData.ts
deleted file mode 100644
index 20057f9..0000000
--- a/Frontend/src/components/shell/shared/useWorkspaceTreeData.ts
+++ /dev/null
@@ -1,393 +0,0 @@
-import { createEffect, createSignal, type Accessor, type Setter } from "solid-js";
-import { resolveAPIBase } from "../../../lib/api";
-import { Folder } from "../../../lib/icons";
-import type { WorkspaceItemTypeId, WorkspaceTreeNode } from "../data/shell.data";
-import { isUuidString } from "./navTreeDnd";
-
-type PersistedWorkspaceTreeNodeRecord = {
- id: string;
- path: string;
- label: string;
- kind: "folder" | "item";
- itemType?: string;
- children?: PersistedWorkspaceTreeNodeRecord[];
-};
-
-type WorkspaceTreeResponse = {
- data?: {
- nodes?: PersistedWorkspaceTreeNodeRecord[];
- };
- error?: string;
- message?: string;
-};
-
-type WorkspaceMutationResponse = {
- error?: string;
- message?: string;
-};
-
-const buildPersistedWorkspaceTreeNodes = (
- nodes: readonly PersistedWorkspaceTreeNodeRecord[],
-): WorkspaceTreeNode[] =>
- nodes.map((node) =>
- node.kind === "folder"
- ? {
- id: node.id,
- path: node.path,
- label: node.label,
- kind: "folder",
- icon: Folder,
- children: buildPersistedWorkspaceTreeNodes(node.children ?? []),
- }
- : {
- id: node.id,
- path: node.path,
- label: node.label,
- kind: "item",
- itemType: node.itemType ?? "core.board.kanban",
- }
- );
-
-const readPersistedWorkspaceTreeNodes = (body: WorkspaceTreeResponse): PersistedWorkspaceTreeNodeRecord[] =>
- Array.isArray(body.data?.nodes) ? body.data.nodes : [];
-
-const findNodeById = (nodes: readonly WorkspaceTreeNode[], nodeId: string): WorkspaceTreeNode | null => {
- for (const node of nodes) {
- if (node.id === nodeId) {
- return node;
- }
-
- if (node.kind !== "folder") {
- continue;
- }
-
- const nestedMatch = findNodeById(node.children ?? [], nodeId);
- if (nestedMatch) {
- return nestedMatch;
- }
- }
-
- return null;
-};
-
-type UseWorkspaceTreeDataOptions = {
- activeProjectId: Accessor;
- fallbackWorkspaceTree: Accessor;
-};
-
-type UseWorkspaceTreeDataResult = {
- workspaceTreeNodes: Accessor;
- setWorkspaceTreeNodes: Setter;
- resolveFolderPath: (folderId: string) => string | null;
- resolveItemPath: (itemId: string) => string | null;
- createFolder: (name: string, parentId: string | null) => Promise;
- renameFolder: (folderId: string, name: string) => Promise;
- deleteFolder: (folderId: string) => Promise;
- moveFolder: (folderId: string, parentId: string | null, targetIndex: number) => Promise;
- createItem: (name: string, itemType: WorkspaceItemTypeId, parentId: string | null) => Promise;
- deleteItem: (itemId: string) => Promise;
- moveItem: (itemId: string, parentId: string | null, targetIndex: number) => Promise;
-};
-
-export const useWorkspaceTreeData = (
- options: UseWorkspaceTreeDataOptions,
-): UseWorkspaceTreeDataResult => {
- const [persistedNodes, setPersistedNodes] = createSignal([]);
- const [workspaceTreeNodes, setWorkspaceTreeNodes] = createSignal(
- options.fallbackWorkspaceTree(),
- );
- let latestPersistedTreeRequest = 0;
-
- const syncWorkspaceTree = (): void => {
- const nextTree = options.activeProjectId()
- ? buildPersistedWorkspaceTreeNodes(persistedNodes())
- : options.fallbackWorkspaceTree();
-
- setWorkspaceTreeNodes(nextTree);
- };
-
- const loadPersistedTree = async (projectId: string): Promise => {
- const requestId = latestPersistedTreeRequest + 1;
- latestPersistedTreeRequest = requestId;
-
- if (!projectId || !isUuidString(projectId)) {
- setPersistedNodes([]);
- return false;
- }
-
- try {
- const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree`, {
- headers: {
- Accept: "application/json",
- },
- });
- const body = (await response.json()) as WorkspaceTreeResponse;
-
- if (requestId !== latestPersistedTreeRequest) {
- return false;
- }
-
- if (!response.ok) {
- throw new Error(body.message || "Failed to load project tree.");
- }
-
- setPersistedNodes(readPersistedWorkspaceTreeNodes(body));
- return true;
- } catch (error) {
- if (requestId !== latestPersistedTreeRequest) {
- return false;
- }
-
- console.error(error);
- setPersistedNodes([]);
- return false;
- }
- };
-
- const resolveFolderPath = (folderId: string): string | null => {
- const node = findNodeById(workspaceTreeNodes(), folderId);
- return node?.kind === "folder" ? node.path ?? null : null;
- };
-
- const resolveItemPath = (itemId: string): string | null => {
- const node = findNodeById(workspaceTreeNodes(), itemId);
- return node?.kind === "item" ? node.path ?? null : null;
- };
-
- const refreshAfterMutation = async (projectId: string, response: Response): Promise => {
- const body = (await response.json()) as WorkspaceMutationResponse;
- if (!response.ok) {
- throw new Error(body.message || "Failed to persist project tree mutation.");
- }
-
- return loadPersistedTree(projectId);
- };
-
- const createFolder = async (name: string, parentId: string | null): Promise => {
- const projectId = options.activeProjectId();
- if (!projectId || !isUuidString(projectId)) {
- return false;
- }
-
- const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
- if (parentId && !parentFolderPath) {
- return false;
- }
-
- 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: parentFolderPath,
- }),
- });
-
- return refreshAfterMutation(projectId, response);
- } catch (error) {
- console.error(error);
- return false;
- }
- };
-
- const renameFolder = async (folderId: string, name: string): Promise => {
- const projectId = options.activeProjectId();
- const folderPath = resolveFolderPath(folderId);
- if (!projectId || !isUuidString(projectId) || !folderPath) {
- return false;
- }
-
- try {
- const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders`, {
- method: "PATCH",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- folderId: folderPath,
- name,
- }),
- });
-
- return refreshAfterMutation(projectId, response);
- } catch (error) {
- console.error(error);
- return false;
- }
- };
-
- const deleteFolder = async (folderId: string): Promise => {
- const projectId = options.activeProjectId();
- const folderPath = resolveFolderPath(folderId);
- if (!projectId || !isUuidString(projectId) || !folderPath) {
- return false;
- }
-
- try {
- const response = await fetch(
- `${resolveAPIBase()}/projects/${projectId}/tree/folders?folderId=${encodeURIComponent(folderPath)}`,
- {
- method: "DELETE",
- headers: {
- Accept: "application/json",
- },
- },
- );
-
- return refreshAfterMutation(projectId, response);
- } catch (error) {
- console.error(error);
- return false;
- }
- };
-
- const moveFolder = async (folderId: string, parentId: string | null, targetIndex: number): Promise => {
- const projectId = options.activeProjectId();
- const folderPath = resolveFolderPath(folderId);
- const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
- if (!projectId || !isUuidString(projectId) || !folderPath) {
- return false;
- }
-
- 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: folderPath,
- folderNodeId: folderId,
- parentFolderId: parentFolderPath,
- parentNodeId: parentId,
- targetIndex,
- }),
- });
-
- return refreshAfterMutation(projectId, response);
- } catch (error) {
- console.error(error);
- return false;
- }
- };
-
- const createItem = async (
- name: string,
- itemType: WorkspaceItemTypeId,
- parentId: string | null,
- ): Promise => {
- const projectId = options.activeProjectId();
- if (!projectId || !isUuidString(projectId)) {
- return false;
- }
-
- const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
- if (parentId && !parentFolderPath) {
- return false;
- }
-
- try {
- const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/items`, {
- method: "POST",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- name,
- itemType,
- parentFolderId: parentFolderPath,
- }),
- });
-
- return refreshAfterMutation(projectId, response);
- } catch (error) {
- console.error(error);
- return false;
- }
- };
-
- const deleteItem = async (itemId: string): Promise => {
- const projectId = options.activeProjectId();
- const itemPath = resolveItemPath(itemId);
- if (!projectId || !isUuidString(projectId) || !itemPath) {
- return false;
- }
-
- try {
- const response = await fetch(
- `${resolveAPIBase()}/projects/${projectId}/tree/items?itemId=${encodeURIComponent(itemPath)}`,
- {
- method: "DELETE",
- headers: {
- Accept: "application/json",
- },
- },
- );
-
- return refreshAfterMutation(projectId, response);
- } catch (error) {
- console.error(error);
- return false;
- }
- };
-
- const moveItem = async (itemId: string, parentId: string | null, targetIndex: number): Promise => {
- const projectId = options.activeProjectId();
- const itemPath = resolveItemPath(itemId);
- const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
- if (!projectId || !isUuidString(projectId) || !itemPath) {
- return false;
- }
-
- try {
- const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/items/move`, {
- method: "PATCH",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- itemId: itemPath,
- itemNodeId: itemId,
- parentFolderId: parentFolderPath,
- parentNodeId: parentId,
- targetIndex,
- }),
- });
-
- return refreshAfterMutation(projectId, response);
- } catch (error) {
- console.error(error);
- return false;
- }
- };
-
- createEffect(() => {
- syncWorkspaceTree();
- });
-
- createEffect(() => {
- void loadPersistedTree(options.activeProjectId());
- });
-
- return {
- workspaceTreeNodes,
- setWorkspaceTreeNodes,
- resolveFolderPath,
- resolveItemPath,
- createFolder,
- renameFolder,
- deleteFolder,
- moveFolder,
- createItem,
- deleteItem,
- moveItem,
- };
-};
diff --git a/Frontend/src/components/shell/DepartmentSelector/DepartmentSelector.module.scss b/Frontend/src/components/top-bar/DepartmentSelector/DepartmentSelector.module.scss
similarity index 100%
rename from Frontend/src/components/shell/DepartmentSelector/DepartmentSelector.module.scss
rename to Frontend/src/components/top-bar/DepartmentSelector/DepartmentSelector.module.scss
diff --git a/Frontend/src/components/shell/DepartmentSelector/DepartmentSelector.tsx b/Frontend/src/components/top-bar/DepartmentSelector/DepartmentSelector.tsx
similarity index 96%
rename from Frontend/src/components/shell/DepartmentSelector/DepartmentSelector.tsx
rename to Frontend/src/components/top-bar/DepartmentSelector/DepartmentSelector.tsx
index 43d6fd4..03edcf9 100644
--- a/Frontend/src/components/shell/DepartmentSelector/DepartmentSelector.tsx
+++ b/Frontend/src/components/top-bar/DepartmentSelector/DepartmentSelector.tsx
@@ -1,7 +1,7 @@
import { For, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { ChevronDown } from "../../../lib/icons";
-import { useAppShellData } from "../data/app-shell.context";
-import { type DepartmentItem } from "../data/shell.data";
+import { useAppShellData } from "../../app-shell/data/app-shell.context";
+import { type DepartmentItem } from "../../app-shell/data/shell.data";
import styles from "./DepartmentSelector.module.scss";
export const DepartmentSelector = (): JSX.Element => {
diff --git a/Frontend/src/components/shell/TopBar/NotificationsButton.module.scss b/Frontend/src/components/top-bar/TopBar/NotificationsButton.module.scss
similarity index 100%
rename from Frontend/src/components/shell/TopBar/NotificationsButton.module.scss
rename to Frontend/src/components/top-bar/TopBar/NotificationsButton.module.scss
diff --git a/Frontend/src/components/shell/TopBar/NotificationsButton.tsx b/Frontend/src/components/top-bar/TopBar/NotificationsButton.tsx
similarity index 93%
rename from Frontend/src/components/shell/TopBar/NotificationsButton.tsx
rename to Frontend/src/components/top-bar/TopBar/NotificationsButton.tsx
index 00c8b41..434cfee 100644
--- a/Frontend/src/components/shell/TopBar/NotificationsButton.tsx
+++ b/Frontend/src/components/top-bar/TopBar/NotificationsButton.tsx
@@ -1,6 +1,6 @@
import type { JSX } from "solid-js";
import { Bell } from "../../../lib/icons";
-import { unreadNotificationCount } from "../data/shell.data";
+import { unreadNotificationCount } from "../../app-shell/data/shell.data";
import styles from "./NotificationsButton.module.scss";
type NotificationsButtonProps = {
diff --git a/Frontend/src/components/shell/TopBar/NotificationsMenu.module.scss b/Frontend/src/components/top-bar/TopBar/NotificationsMenu.module.scss
similarity index 100%
rename from Frontend/src/components/shell/TopBar/NotificationsMenu.module.scss
rename to Frontend/src/components/top-bar/TopBar/NotificationsMenu.module.scss
diff --git a/Frontend/src/components/shell/TopBar/NotificationsMenu.tsx b/Frontend/src/components/top-bar/TopBar/NotificationsMenu.tsx
similarity index 98%
rename from Frontend/src/components/shell/TopBar/NotificationsMenu.tsx
rename to Frontend/src/components/top-bar/TopBar/NotificationsMenu.tsx
index 217864b..1003121 100644
--- a/Frontend/src/components/shell/TopBar/NotificationsMenu.tsx
+++ b/Frontend/src/components/top-bar/TopBar/NotificationsMenu.tsx
@@ -1,6 +1,6 @@
import { For, Show, type JSX } from "solid-js";
import { Bell, Settings } from "../../../lib/icons";
-import { notificationItems, unreadNotificationCount } from "../data/shell.data";
+import { notificationItems, unreadNotificationCount } from "../../app-shell/data/shell.data";
import styles from "./NotificationsMenu.module.scss";
type NotificationsMenuProps = {
diff --git a/Frontend/src/components/shell/TopBar/NotificationsNav.module.scss b/Frontend/src/components/top-bar/TopBar/NotificationsNav.module.scss
similarity index 100%
rename from Frontend/src/components/shell/TopBar/NotificationsNav.module.scss
rename to Frontend/src/components/top-bar/TopBar/NotificationsNav.module.scss
diff --git a/Frontend/src/components/shell/TopBar/NotificationsNav.tsx b/Frontend/src/components/top-bar/TopBar/NotificationsNav.tsx
similarity index 100%
rename from Frontend/src/components/shell/TopBar/NotificationsNav.tsx
rename to Frontend/src/components/top-bar/TopBar/NotificationsNav.tsx
diff --git a/Frontend/src/components/shell/TopBar/ProfileMenu.module.scss b/Frontend/src/components/top-bar/TopBar/ProfileMenu.module.scss
similarity index 100%
rename from Frontend/src/components/shell/TopBar/ProfileMenu.module.scss
rename to Frontend/src/components/top-bar/TopBar/ProfileMenu.module.scss
diff --git a/Frontend/src/components/shell/TopBar/ProfileMenu.tsx b/Frontend/src/components/top-bar/TopBar/ProfileMenu.tsx
similarity index 94%
rename from Frontend/src/components/shell/TopBar/ProfileMenu.tsx
rename to Frontend/src/components/top-bar/TopBar/ProfileMenu.tsx
index 62914c4..3689082 100644
--- a/Frontend/src/components/shell/TopBar/ProfileMenu.tsx
+++ b/Frontend/src/components/top-bar/TopBar/ProfileMenu.tsx
@@ -1,7 +1,7 @@
import { For, type JSX } from "solid-js";
import { User } from "../../../lib/icons";
-import { useAppShellData } from "../data/app-shell.context";
-import { profileMenuSections } from "../data/shell.data";
+import { useAppShellData } from "../../app-shell/data/app-shell.context";
+import { profileMenuSections } from "../../app-shell/data/shell.data";
import styles from "./ProfileMenu.module.scss";
type ProfileMenuProps = {
diff --git a/Frontend/src/components/shell/TopBar/ThemeToggle.module.scss b/Frontend/src/components/top-bar/TopBar/ThemeToggle.module.scss
similarity index 100%
rename from Frontend/src/components/shell/TopBar/ThemeToggle.module.scss
rename to Frontend/src/components/top-bar/TopBar/ThemeToggle.module.scss
diff --git a/Frontend/src/components/shell/TopBar/ThemeToggle.tsx b/Frontend/src/components/top-bar/TopBar/ThemeToggle.tsx
similarity index 87%
rename from Frontend/src/components/shell/TopBar/ThemeToggle.tsx
rename to Frontend/src/components/top-bar/TopBar/ThemeToggle.tsx
index 86403c9..bcfd830 100644
--- a/Frontend/src/components/shell/TopBar/ThemeToggle.tsx
+++ b/Frontend/src/components/top-bar/TopBar/ThemeToggle.tsx
@@ -1,7 +1,7 @@
-// Path: Frontend/src/components/shell/TopBar/ThemeToggle.tsx
+// Path: Frontend/src/components/top-bar/TopBar/ThemeToggle.tsx
import type { JSX } from "solid-js";
-import type { Theme } from "../../../theme/runtime";
+import type { Theme } from "../../../helper/themeRuntime";
import { Moon, Sun } from "../../../lib/icons";
import styles from "./ThemeToggle.module.scss";
diff --git a/Frontend/src/components/shell/TopBar/TopBar.module.scss b/Frontend/src/components/top-bar/TopBar/TopBar.module.scss
similarity index 100%
rename from Frontend/src/components/shell/TopBar/TopBar.module.scss
rename to Frontend/src/components/top-bar/TopBar/TopBar.module.scss
diff --git a/Frontend/src/components/shell/TopBar/TopBar.tsx b/Frontend/src/components/top-bar/TopBar/TopBar.tsx
similarity index 90%
rename from Frontend/src/components/shell/TopBar/TopBar.tsx
rename to Frontend/src/components/top-bar/TopBar/TopBar.tsx
index 194b378..34c4806 100644
--- a/Frontend/src/components/shell/TopBar/TopBar.tsx
+++ b/Frontend/src/components/top-bar/TopBar/TopBar.tsx
@@ -1,8 +1,8 @@
-// Path: Frontend/src/components/shell/TopBar/TopBar.tsx
+// Path: Frontend/src/components/top-bar/TopBar/TopBar.tsx
import { For, type JSX } from "solid-js";
-import type { Theme } from "../../../theme/runtime";
-import { topBarActions } from "../data/shell.data";
+import type { Theme } from "../../../helper/themeRuntime";
+import { topBarActions } from "../../app-shell/data/shell.data";
import { DepartmentSelector } from "../DepartmentSelector/DepartmentSelector";
import { NotificationsNav } from "./NotificationsNav";
import { ThemeToggle } from "./ThemeToggle";
diff --git a/Frontend/src/components/shell/TopBar/UserNav.module.scss b/Frontend/src/components/top-bar/TopBar/UserNav.module.scss
similarity index 100%
rename from Frontend/src/components/shell/TopBar/UserNav.module.scss
rename to Frontend/src/components/top-bar/TopBar/UserNav.module.scss
diff --git a/Frontend/src/components/shell/TopBar/UserNav.tsx b/Frontend/src/components/top-bar/TopBar/UserNav.tsx
similarity index 100%
rename from Frontend/src/components/shell/TopBar/UserNav.tsx
rename to Frontend/src/components/top-bar/TopBar/UserNav.tsx
diff --git a/Frontend/src/components/shell/TopBar/UserNavButton.module.scss b/Frontend/src/components/top-bar/TopBar/UserNavButton.module.scss
similarity index 100%
rename from Frontend/src/components/shell/TopBar/UserNavButton.module.scss
rename to Frontend/src/components/top-bar/TopBar/UserNavButton.module.scss
diff --git a/Frontend/src/components/shell/TopBar/UserNavButton.tsx b/Frontend/src/components/top-bar/TopBar/UserNavButton.tsx
similarity index 93%
rename from Frontend/src/components/shell/TopBar/UserNavButton.tsx
rename to Frontend/src/components/top-bar/TopBar/UserNavButton.tsx
index 6d28932..3c4945a 100644
--- a/Frontend/src/components/shell/TopBar/UserNavButton.tsx
+++ b/Frontend/src/components/top-bar/TopBar/UserNavButton.tsx
@@ -1,4 +1,4 @@
-// Path: Frontend/src/components/shell/TopBar/UserNavButton.tsx
+// Path: Frontend/src/components/top-bar/TopBar/UserNavButton.tsx
import type { JSX } from "solid-js";
import { User } from "../../../lib/icons";
diff --git a/Frontend/src/components/shell/TopBar/createDesktopMenuController.ts b/Frontend/src/components/top-bar/TopBar/createDesktopMenuController.ts
similarity index 100%
rename from Frontend/src/components/shell/TopBar/createDesktopMenuController.ts
rename to Frontend/src/components/top-bar/TopBar/createDesktopMenuController.ts
diff --git a/Frontend/src/components/workspace-home/WorkspaceHome.module.scss b/Frontend/src/components/workspace-home/WorkspaceHome.module.scss
new file mode 100644
index 0000000..cb1ab20
--- /dev/null
+++ b/Frontend/src/components/workspace-home/WorkspaceHome.module.scss
@@ -0,0 +1,25 @@
+/* Path: Frontend/src/components/workspace-home/WorkspaceHome.module.scss */
+
+.viewport {
+ --workspace-content-max-width: var(--content-width-wide);
+
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ align-content: start;
+ gap: var(--space-4);
+ padding: var(--space-5) var(--space-6);
+}
+
+.title {
+ @include text-display;
+ font-family: var(--font-family-display);
+ max-width: 12ch;
+}
+
+@include respond-down(mobile) {
+ .viewport {
+ gap: var(--space-4);
+ padding: var(--space-4) var(--space-4) calc(var(--space-8) + env(safe-area-inset-bottom, 0px));
+ }
+}
diff --git a/Frontend/src/components/workspace-home/WorkspaceHome.tsx b/Frontend/src/components/workspace-home/WorkspaceHome.tsx
new file mode 100644
index 0000000..b1e770b
--- /dev/null
+++ b/Frontend/src/components/workspace-home/WorkspaceHome.tsx
@@ -0,0 +1,15 @@
+// Path: Frontend/src/components/workspace-home/WorkspaceHome.tsx
+
+import { type JSX } from "solid-js";
+import { useAppShellData } from "../app-shell/data/app-shell.context";
+import styles from "./WorkspaceHome.module.scss";
+
+export const WorkspaceHome = (): JSX.Element => {
+ const appShellData = useAppShellData();
+
+ return (
+
+ {appShellData.activeServer().name}
+
+ );
+};
diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
deleted file mode 100644
index 96ec8b9..0000000
--- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
+++ /dev/null
@@ -1,271 +0,0 @@
-// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
-
-import { Show, createMemo, type JSX } from "solid-js";
-import { Portal } from "solid-js/web";
-import { ChevronLeft, ChevronRight } from "../../../lib/icons";
-import { useAppShellData } from "../../shell/data/app-shell.context";
-import { bootstrapPersonaDefinitions } from "./WorkspaceHome.data";
-import { useWorkspaceHomeWizard } from "./WorkspaceHome.hook";
-import styles from "./WorkspaceHome.module.scss";
-import { BootstrapAdminStep, BootstrapFinishingState, BootstrapInstanceStep, BootstrapModeStep, BootstrapPersonaStep, BootstrapStructureStep, BootstrapWizardProgress } from "./WorkspaceHome.parts";
-
-type WorkspaceHomeProps = {
- sidebarCollapsed: boolean;
- onToggleSidebarCollapse: () => void;
-};
-
-export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
- const appShellData = useAppShellData();
- const {
- instanceForm,
- setInstanceForm,
- modeForm,
- setModeForm,
- adminForm,
- setAdminForm,
- structureForm,
- setStructureForm,
- selectedPersona,
- hasChosenPersona,
- stepState,
- isBootstrapStateResolved,
- isWizardOpen,
- setIsWizardOpen,
- setIsFinishingBootstrapFlow,
- fieldTooltip,
- materializationState,
- isMaterializationInFlight,
- hasMaterializationFailed,
- showBootstrapFinishingState,
- materializationStatusLabel,
- materializationMessage,
- personaDefinition,
- selectedPersonaIsAvailable,
- usesCondensedBootstrapFlow,
- activeWizardSteps,
- bootstrapNamePlaceholder,
- bootstrapStepCount,
- currentStep,
- currentWizardStepIndex,
- wizardProgressFillWidth,
- currentStepState,
- isFirstStep,
- canDismissWizard,
- handleCurrentStepSubmit,
- applyPersonaSelection,
- statusLabel,
- showFieldTooltip,
- hideFieldTooltip,
- stepStatusLabel,
- navigateBack,
- navigateToVisibleStep,
- } = useWorkspaceHomeWizard(appShellData);
- const isBootstrapPersisted = createMemo(() => appShellData.installation()?.isBootstrapped ?? false);
-
- const sidebarToggleLabel = (): string => (props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar");
- const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`;
-
- return (
- <>
-
-
-
-
-
-
-
- {breadcrumb()}
-
-
-
-
-
-
- {isBootstrapPersisted() ? appShellData.activeServer().name : "Server"}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- setIsFinishingBootstrapFlow(false);
- setIsWizardOpen(false);
- }}
- />
- }
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
- {currentStepState().error}
-
-
-
-
-
-
-
- {(tooltip): JSX.Element => (
-
- )}
-
-
-
-
- >
- );
-};
diff --git a/Frontend/src/components/shell/LeftRail/LeftRail.module.scss b/Frontend/src/components/workspace-navigation/LeftRail/LeftRail.module.scss
similarity index 100%
rename from Frontend/src/components/shell/LeftRail/LeftRail.module.scss
rename to Frontend/src/components/workspace-navigation/LeftRail/LeftRail.module.scss
diff --git a/Frontend/src/components/shell/LeftRail/LeftRail.tsx b/Frontend/src/components/workspace-navigation/LeftRail/LeftRail.tsx
similarity index 90%
rename from Frontend/src/components/shell/LeftRail/LeftRail.tsx
rename to Frontend/src/components/workspace-navigation/LeftRail/LeftRail.tsx
index 075e1d6..6332ddc 100644
--- a/Frontend/src/components/shell/LeftRail/LeftRail.tsx
+++ b/Frontend/src/components/workspace-navigation/LeftRail/LeftRail.tsx
@@ -1,8 +1,8 @@
-// Path: Frontend/src/components/shell/LeftRail/LeftRail.tsx
+// Path: Frontend/src/components/workspace-navigation/LeftRail/LeftRail.tsx
import { For, Show, type JSX } from "solid-js";
-import { useAppShellData } from "../data/app-shell.context";
-import { type RailItem } from "../data/shell.data";
+import { useAppShellData } from "../../app-shell/data/app-shell.context";
+import { type RailItem } from "../../app-shell/data/shell.data";
import styles from "./LeftRail.module.scss";
type RailEntryProps = {
diff --git a/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.data.ts b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.data.ts
new file mode 100644
index 0000000..1a896bf
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.data.ts
@@ -0,0 +1,91 @@
+// Path: Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.data.ts
+
+import type { WorkspaceTreeNode } from "../../app-shell/data/shell.data";
+
+export type MobileWorkspaceBrowserProps = {
+ open: boolean;
+ onClose: VoidFunction;
+};
+
+export type MobileWorkspaceDialogState =
+ | {
+ kind: "text";
+ title: string;
+ message: string;
+ confirmLabel: string;
+ initialValue: string;
+ onConfirm: (value: string) => void;
+ }
+ | {
+ kind: "confirm";
+ title: string;
+ message: string;
+ confirmLabel: string;
+ tone?: "danger";
+ onConfirm: () => void;
+ };
+
+export type MobileMoveTargetState = {
+ kind: "folder" | "item";
+ id: string;
+ label: string;
+};
+
+export type MobileMoveDestination = {
+ id: string | null;
+ label: string;
+ depth: number;
+ meta?: string;
+};
+
+export const collectMoveDestinations = (
+ nodes: readonly WorkspaceTreeNode[],
+ movingTarget: MobileMoveTargetState,
+ depth = 0,
+ ancestorBlocked = false,
+): MobileMoveDestination[] => {
+ const destinations: MobileMoveDestination[] = [];
+
+ for (const node of nodes) {
+ if (node.kind !== "folder") {
+ continue;
+ }
+
+ const isBlockedFolder = movingTarget.kind === "folder" && node.id === movingTarget.id;
+ if (!ancestorBlocked && !isBlockedFolder) {
+ destinations.push({
+ id: node.id,
+ label: node.label,
+ depth,
+ meta: "Folder",
+ });
+ }
+
+ destinations.push(
+ ...collectMoveDestinations(node.children ?? [], movingTarget, depth + 1, ancestorBlocked || isBlockedFolder),
+ );
+ }
+
+ return destinations;
+};
+
+export const findTreeNodeById = (nodes: readonly WorkspaceTreeNode[], nodeId: string): WorkspaceTreeNode | null => {
+ for (const node of nodes) {
+ if (node.id === nodeId) {
+ return node;
+ }
+
+ if (node.kind !== "folder") {
+ continue;
+ }
+
+ const nestedMatch = findTreeNodeById(node.children ?? [], nodeId);
+ if (nestedMatch) {
+ return nestedMatch;
+ }
+ }
+
+ return null;
+};
+
+export const isDangerDialogState = (state: MobileWorkspaceDialogState): boolean => state.kind === "confirm" && state.tone === "danger";
diff --git a/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.hook.ts b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.hook.ts
new file mode 100644
index 0000000..1a845f2
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.hook.ts
@@ -0,0 +1,273 @@
+// Path: Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.hook.ts
+
+import { createSignal } from "solid-js";
+import { createLongPressGesture } from "../../../helper/createLongPressGesture";
+import { useAppShellData } from "../../app-shell/data/app-shell.context";
+import {
+ createWorkspaceSurfaceTarget,
+ getWorkspaceItemTypeDefinition,
+ type WorkspaceContextMenuAction,
+ type WorkspaceContextMenuTarget,
+ type WorkspaceItemTypeId,
+} from "../../app-shell/data/shell.data";
+import { useWorkspaceTreeData } from "../shared/useWorkspaceTreeData";
+import {
+ collectMoveDestinations,
+ findTreeNodeById,
+ type MobileMoveDestination,
+ type MobileMoveTargetState,
+ type MobileWorkspaceDialogState,
+} from "./MobileWorkspaceBrowser.data";
+
+export const useMobileWorkspaceBrowser = () => {
+ const appShellData = useAppShellData();
+ const [actionSheetTarget, setActionSheetTarget] = createSignal(null);
+ const [dialogState, setDialogState] = createSignal(null);
+ const [dialogValue, setDialogValue] = createSignal("");
+ const [moveTarget, setMoveTarget] = createSignal(null);
+ const { workspaceTreeNodes, createFolder, renameFolder, deleteFolder, moveFolder, createItem, deleteItem, moveItem } = useWorkspaceTreeData({
+ activeProjectId: () => appShellData.activeProject().id,
+ fallbackWorkspaceTree: () => appShellData.workspaceTree(),
+ });
+ const workspaceTarget = () => createWorkspaceSurfaceTarget(appShellData.activeProject());
+
+ const moveDestinations = (): MobileMoveDestination[] => {
+ const target = moveTarget();
+ if (!target) {
+ return [];
+ }
+
+ return [{ id: null, label: "Items root", depth: 0, meta: "Root" }, ...collectMoveDestinations(workspaceTreeNodes(), target)];
+ };
+
+ const resolveCreateItemType = (actionId: string): WorkspaceItemTypeId | null => {
+ switch (actionId) {
+ case "create-doc":
+ return "core.doc";
+ case "create-board":
+ return "core.board.kanban";
+ case "create-list-board":
+ return "core.board.list";
+ default:
+ return null;
+ }
+ };
+
+ const createPersistedItem = (itemType: WorkspaceItemTypeId, parentId: string | null): void => {
+ const definition = getWorkspaceItemTypeDefinition(itemType);
+ void createItem(definition.defaultCreateLabel, itemType, parentId);
+ };
+
+ const openActionSheet = (target: WorkspaceContextMenuTarget): void => {
+ setActionSheetTarget(target);
+ };
+
+ const closeActionSheet = (): void => {
+ setActionSheetTarget(null);
+ };
+
+ const closeMoveSheet = (): void => {
+ setMoveTarget(null);
+ };
+
+ const openWorkspaceActionSheet = (): void => {
+ openActionSheet(workspaceTarget());
+ };
+
+ const closeDialog = (): void => {
+ setDialogState(null);
+ setDialogValue("");
+ };
+
+ const openMoveSheet = (target: MobileMoveTargetState): void => {
+ setMoveTarget(target);
+ };
+
+ const openTextDialog = (config: Omit, "kind">): void => {
+ setDialogValue(config.initialValue);
+ setDialogState({ kind: "text", ...config });
+ };
+
+ const openConfirmDialog = (config: Omit, "kind">): void => {
+ setDialogValue("");
+ setDialogState({ kind: "confirm", ...config });
+ };
+
+ const submitDialog = (): void => {
+ const state = dialogState();
+ if (!state) {
+ return;
+ }
+
+ if (state.kind === "text") {
+ const value = dialogValue().trim();
+ if (!value) {
+ return;
+ }
+
+ closeDialog();
+ state.onConfirm(value);
+ return;
+ }
+
+ closeDialog();
+ state.onConfirm();
+ };
+
+ const handleMoveDestinationSelect = (destinationId: string | null): void => {
+ const target = moveTarget();
+ if (!target) {
+ return;
+ }
+
+ const destinationNode = destinationId ? findTreeNodeById(workspaceTreeNodes(), destinationId) : null;
+ const targetIndex = destinationNode?.kind === "folder"
+ ? destinationNode.children?.length ?? 0
+ : destinationId
+ ? 0
+ : workspaceTreeNodes().length;
+
+ closeMoveSheet();
+
+ if (target.kind === "folder") {
+ void moveFolder(target.id, destinationId, targetIndex);
+ return;
+ }
+
+ void moveItem(target.id, destinationId, targetIndex);
+ };
+
+ const handleActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
+ const createItemType = resolveCreateItemType(action.id);
+ if (createItemType) {
+ switch (target.kind) {
+ case "workspace":
+ case "home":
+ createPersistedItem(createItemType, null);
+ return;
+ case "folder":
+ createPersistedItem(createItemType, target.id);
+ return;
+ case "settings":
+ case "item":
+ return;
+ }
+ }
+
+ switch (action.id) {
+ case "new-folder": {
+ if (target.kind === "settings" || target.kind === "item") {
+ return;
+ }
+
+ openTextDialog({
+ title: "New folder",
+ message: target.kind === "folder" ? `Create a folder inside "${target.label}".` : "Create a folder at the root of Items.",
+ confirmLabel: "Create",
+ initialValue: "Untitled folder",
+ onConfirm: (name) => {
+ void createFolder(name, target.kind === "folder" ? target.id : null);
+ },
+ });
+ return;
+ }
+ case "rename-folder": {
+ if (target.kind !== "folder") {
+ return;
+ }
+
+ openTextDialog({
+ title: "Rename folder",
+ message: `Update the name for "${target.label}".`,
+ confirmLabel: "Save",
+ initialValue: target.label,
+ onConfirm: (name) => {
+ if (name === target.label) {
+ return;
+ }
+ void renameFolder(target.id, name);
+ },
+ });
+ return;
+ }
+ case "move-folder": {
+ if (target.kind !== "folder") {
+ return;
+ }
+
+ openMoveSheet({ kind: "folder", id: target.id, label: target.label });
+ return;
+ }
+ case "delete-folder": {
+ if (target.kind !== "folder") {
+ return;
+ }
+
+ openConfirmDialog({
+ title: "Delete folder?",
+ message: `"${target.label}" and everything inside it will be removed.`,
+ confirmLabel: "Delete",
+ tone: "danger",
+ onConfirm: () => {
+ void deleteFolder(target.id);
+ },
+ });
+ return;
+ }
+ case "move-doc":
+ case "move-board":
+ case "move-list-board": {
+ if (target.kind !== "item") {
+ return;
+ }
+
+ openMoveSheet({ kind: "item", id: target.id, label: target.label });
+ return;
+ }
+ case "delete-doc":
+ case "delete-board":
+ case "delete-list-board": {
+ if (target.kind !== "item") {
+ return;
+ }
+
+ openConfirmDialog({
+ title: "Delete item?",
+ message: `"${target.label}" will be removed from the project tree.`,
+ confirmLabel: "Delete",
+ tone: "danger",
+ onConfirm: () => {
+ void deleteItem(target.id);
+ },
+ });
+ return;
+ }
+ default:
+ return;
+ }
+ };
+
+ const workspaceLongPress = createLongPressGesture({
+ onLongPress: openWorkspaceActionSheet,
+ });
+
+ return {
+ appShellData,
+ actionSheetTarget,
+ openActionSheet,
+ closeActionSheet,
+ dialogState,
+ dialogValue,
+ setDialogValue,
+ closeDialog,
+ submitDialog,
+ moveTarget,
+ moveDestinations,
+ closeMoveSheet,
+ handleMoveDestinationSelect,
+ workspaceTreeNodes,
+ handleActionSelect,
+ workspaceLongPress,
+ openWorkspaceActionSheet,
+ };
+};
diff --git a/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.module.scss b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.module.scss
similarity index 100%
rename from Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.module.scss
rename to Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.module.scss
diff --git a/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.parts.tsx b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.parts.tsx
new file mode 100644
index 0000000..15b4e2d
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.parts.tsx
@@ -0,0 +1,140 @@
+// Path: Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.parts.tsx
+
+import { For, Show, type JSX } from "solid-js";
+import { ChevronRight } from "../../../lib/icons";
+import { createLongPressGesture } from "../../../helper/createLongPressGesture";
+import {
+ createWorkspaceStaticTarget,
+ createWorkspaceTreeTarget,
+ getWorkspaceNodeIcon,
+ type SidebarItem,
+ type WorkspaceContextMenuTarget,
+ type WorkspaceStaticItem,
+ type WorkspaceTreeNode,
+} from "../../app-shell/data/shell.data";
+import styles from "./MobileWorkspaceBrowser.module.scss";
+
+export const TreeRow = (props: { node: WorkspaceTreeNode; depth?: number }): JSX.Element => {
+ const depth = props.depth ?? 0;
+ const Icon = getWorkspaceNodeIcon(props.node);
+ const hasChildren = (props.node.children?.length ?? 0) > 0;
+
+ return (
+
+ );
+};
+
+export const StaticRow = (props: { item: SidebarItem }): JSX.Element => {
+ const Icon = props.item.icon;
+
+ return (
+
+ );
+};
+
+export const WorkspaceStaticRow = (props: {
+ item: WorkspaceStaticItem;
+ onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
+}): JSX.Element => {
+ const target = createWorkspaceStaticTarget(props.item);
+ const longPress = createLongPressGesture({
+ onLongPress: () => {
+ props.onOpenActionSheet(target);
+ },
+ });
+
+ return (
+ {
+ event.preventDefault();
+ props.onOpenActionSheet(target);
+ }}
+ {...longPress}
+ >
+
+
+ );
+};
+
+export const WorkspaceTreeBranch = (props: {
+ nodes: readonly WorkspaceTreeNode[];
+ depth?: number;
+ onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
+}): JSX.Element => {
+ const depth = props.depth ?? 0;
+
+ return (
+
+ {(node): JSX.Element => {
+ const target = createWorkspaceTreeTarget(node);
+ const longPress = createLongPressGesture({
+ onLongPress: () => {
+ props.onOpenActionSheet(target);
+ },
+ });
+
+ return (
+ {
+ event.preventDefault();
+ props.onOpenActionSheet(target);
+ }}
+ {...longPress}
+ >
+
+
+
+
+
+
+ );
+ }}
+
+ );
+};
diff --git a/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx
new file mode 100644
index 0000000..780fc50
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx
@@ -0,0 +1,192 @@
+import { For, Show, type JSX } from "solid-js";
+import { Portal } from "solid-js/web";
+import { Plus, X } from "../../../lib/icons";
+import {
+ workspaceStaticItems,
+} from "../../app-shell/data/shell.data";
+import { WorkspaceMobileActionSheet } from "../WorkspaceMobileActionSheet/WorkspaceMobileActionSheet";
+import { useMobileWorkspaceBrowser } from "./MobileWorkspaceBrowser.hook";
+import { isDangerDialogState, type MobileWorkspaceBrowserProps } from "./MobileWorkspaceBrowser.data";
+import { WorkspaceStaticRow, WorkspaceTreeBranch } from "./MobileWorkspaceBrowser.parts";
+import styles from "./MobileWorkspaceBrowser.module.scss";
+
+export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.Element => {
+ const {
+ appShellData,
+ actionSheetTarget,
+ openActionSheet,
+ closeActionSheet,
+ dialogState,
+ dialogValue,
+ setDialogValue,
+ closeDialog,
+ submitDialog,
+ moveTarget,
+ moveDestinations,
+ closeMoveSheet,
+ handleMoveDestinationSelect,
+ workspaceTreeNodes,
+ handleActionSelect,
+ workspaceLongPress,
+ openWorkspaceActionSheet,
+ } = useMobileWorkspaceBrowser();
+
+ return (
+
+
+
+
+
+
+
+ Workspace
+
+
+ {(item): JSX.Element => }
+
+
+
+
+
+
+
+
+
+
+
+ {(state): JSX.Element => (
+
+
+
+
+
+
+
+
+
Destination
+
+
+ {(destination): JSX.Element => (
+
+ )}
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ {(state): JSX.Element => (
+
+
+
+
+
+
{state().title}
+
{state().message}
+
+
+
+ {
+ setDialogValue(event.currentTarget.value);
+ }}
+ onKeyDown={(event): void => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ submitDialog();
+ }
+ }}
+ autofocus
+ />
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ );
+};
diff --git a/Frontend/src/components/shell/ProjectContextMenu/ProjectContextMenu.tsx b/Frontend/src/components/workspace-navigation/ProjectContextMenu/ProjectContextMenu.tsx
similarity index 99%
rename from Frontend/src/components/shell/ProjectContextMenu/ProjectContextMenu.tsx
rename to Frontend/src/components/workspace-navigation/ProjectContextMenu/ProjectContextMenu.tsx
index 089c8ee..c27c049 100644
--- a/Frontend/src/components/shell/ProjectContextMenu/ProjectContextMenu.tsx
+++ b/Frontend/src/components/workspace-navigation/ProjectContextMenu/ProjectContextMenu.tsx
@@ -7,7 +7,7 @@ import {
type ProjectContextMenuAction,
type ProjectMenuTarget,
type WorkspaceContextMenuShortcut,
-} from "../data/shell.data";
+} from "../../app-shell/data/shell.data";
import styles from "../WorkspaceContextMenu/WorkspaceContextMenu.module.scss";
type ShortcutPlatform = "mac" | "windows";
diff --git a/Frontend/src/components/shell/ProjectContextMenu/createProjectContextMenuController.ts b/Frontend/src/components/workspace-navigation/ProjectContextMenu/createProjectContextMenuController.ts
similarity index 97%
rename from Frontend/src/components/shell/ProjectContextMenu/createProjectContextMenuController.ts
rename to Frontend/src/components/workspace-navigation/ProjectContextMenu/createProjectContextMenuController.ts
index 3110220..5e5e5d5 100644
--- a/Frontend/src/components/shell/ProjectContextMenu/createProjectContextMenuController.ts
+++ b/Frontend/src/components/workspace-navigation/ProjectContextMenu/createProjectContextMenuController.ts
@@ -1,5 +1,5 @@
import { createEffect, createSignal, onCleanup } from "solid-js";
-import type { ProjectMenuTarget } from "../data/shell.data";
+import type { ProjectMenuTarget } from "../../app-shell/data/shell.data";
type ProjectContextMenuState = {
target: ProjectMenuTarget;
diff --git a/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.data.ts b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.data.ts
new file mode 100644
index 0000000..69279ad
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.data.ts
@@ -0,0 +1,144 @@
+// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.data.ts
+
+import type { JSX } from "solid-js";
+import type {
+ NavTreeAdapter,
+ NavTreeDropTarget,
+} from "../shared/navTreeDnd";
+import type { ProjectItem } from "../../app-shell/data/shell.data";
+
+export type ProjectSelectorProps = {
+ compact?: boolean;
+ isOpen: boolean;
+ onToggle: () => void;
+ onClose: () => void;
+};
+
+export type ProjectFolderNode = {
+ kind: "folder";
+ id: string;
+ path: string;
+ label: string;
+ meta?: string;
+ children: ProjectTreeNode[];
+};
+
+export type ProjectLeafNode = {
+ kind: "project";
+ item: ProjectItem;
+};
+
+export type ProjectTreeNode = ProjectFolderNode | ProjectLeafNode;
+
+export type PersistedProjectFolderRecord = {
+ id: string;
+ path: string;
+ label: string;
+ children: PersistedProjectFolderRecord[];
+};
+
+export type ProjectFoldersResponse = {
+ data?: {
+ folders?: PersistedProjectFolderRecord[];
+ renamedFolder?: PersistedProjectFolderRecord;
+ movedFolder?: PersistedProjectFolderRecord;
+ previousFolderId?: string;
+ previousFolderPath?: string;
+ };
+ error?: string;
+ message?: string;
+};
+
+export type PendingProjectFolderDraft = {
+ parentId: string | null;
+ depth: number;
+};
+
+export type PendingProjectFolderRename = {
+ folderId: string;
+ depth: number;
+};
+
+export type ProjectDragTarget = NavTreeDropTarget;
+
+export type ProjectDragState = {
+ draggedNodeId: string;
+ dropTarget: ProjectDragTarget | null;
+};
+
+export type DragGhostPosition = {
+ x: number;
+ y: number;
+};
+
+export type ProjectFolderBranchProps = {
+ 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;
+};
+
+export const LONG_PRESS_MS = 320;
+
+export const getProjectTreeNodeId = (node: ProjectTreeNode): string =>
+ node.kind === "folder" ? node.id : node.item.id;
+
+export const buildPersistedFolderNodes = (folders: readonly PersistedProjectFolderRecord[] = []): ProjectTreeNode[] =>
+ folders.map((folder) => ({
+ kind: "folder",
+ id: folder.id,
+ path: folder.path,
+ label: folder.label,
+ children: buildPersistedFolderNodes(folder.children ?? []),
+ }));
+
+export const buildProjectTree = (
+ items: readonly ProjectItem[],
+ folders: readonly PersistedProjectFolderRecord[] = [],
+): ProjectTreeNode[] => [
+ ...items.map((item) => ({
+ kind: "project" as const,
+ item,
+ })),
+ ...buildPersistedFolderNodes(folders),
+];
+
+export const countProjectFolderSiblingsBeforeIndex = (
+ siblings: readonly ProjectTreeNode[],
+ index: number,
+): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length;
+
+export const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] =>
+ Array.isArray(body.data?.folders) ? body.data.folders : [];
+
+export const projectTreeAdapter: NavTreeAdapter = {
+ getNodeId: getProjectTreeNodeId,
+ isBranchNode: (node) => node.kind === "folder",
+ getChildren: (node) => (node.kind === "folder" ? node.children : []),
+ withChildren: (node, children) =>
+ node.kind === "folder"
+ ? {
+ ...node,
+ children: [...children],
+ }
+ : node,
+};
diff --git a/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.hook.ts b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.hook.ts
new file mode 100644
index 0000000..bf82f2d
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.hook.ts
@@ -0,0 +1,671 @@
+// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.hook.ts
+
+import { createEffect, createSignal, onCleanup, onMount } from "solid-js";
+import { useAppShellData } from "../../app-shell/data/app-shell.context";
+import {
+ createProjectFolderTarget,
+ createProjectSurfaceTarget,
+ createProjectTarget,
+ type ProjectItem,
+ type ProjectMenuTarget,
+} from "../../app-shell/data/shell.data";
+import {
+ collectBranchNodeIds,
+ findTreeNodeDepth,
+ findTreeNodeLocation,
+ getPointerRelativeY,
+ isUuidString,
+ moveTreeNode,
+ resolveTreeDropTarget,
+} from "../shared/navTreeDnd";
+import { createProjectContextMenuController } from "../ProjectContextMenu/createProjectContextMenuController";
+import {
+ LONG_PRESS_MS,
+ buildProjectTree,
+ countProjectFolderSiblingsBeforeIndex,
+ projectTreeAdapter,
+ readPersistedFolders,
+ type DragGhostPosition,
+ type PendingProjectFolderDraft,
+ type PendingProjectFolderRename,
+ type PersistedProjectFolderRecord,
+ type ProjectDragState,
+ type ProjectFolderNode,
+ type ProjectSelectorProps,
+ type ProjectTreeNode,
+} from "./ProjectSelector.data";
+import {
+ createProjectFolderRequest,
+ deleteProjectFolderRequest,
+ fetchProjectFolders,
+ isValidProjectFolderProjectId,
+ moveProjectFolderRequest,
+ renameProjectFolderRequest,
+} from "./projectFolders.api";
+
+export const useProjectSelector = (props: ProjectSelectorProps) => {
+ const appShellData = useAppShellData();
+ const [selectedProject, setSelectedProject] = createSignal(appShellData.activeProject());
+ const [drawerTop, setDrawerTop] = createSignal(0);
+ const [collapsedFolderIds, setCollapsedFolderIds] = createSignal([]);
+ const [persistedFolders, setPersistedFolders] = createSignal([]);
+ const [projectTreeNodes, setProjectTreeNodes] = createSignal(buildProjectTree(appShellData.projectItems(), persistedFolders()));
+ const [pendingFolderDraft, setPendingFolderDraft] = createSignal(null);
+ const [pendingFolderName, setPendingFolderName] = createSignal("");
+ const [pendingFolderRename, setPendingFolderRename] = createSignal(null);
+ const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal("");
+ const [dragState, setDragState] = createSignal(null);
+ const [dragGhostPosition, setDragGhostPosition] = createSignal({ x: 0, y: 0 });
+ 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 setRootRef = (element: HTMLDivElement | undefined): void => {
+ rootRef = element;
+ };
+
+ const setTriggerRef = (element: HTMLButtonElement | undefined): void => {
+ triggerRef = element;
+ };
+
+ const setContextMenuElement = (element: HTMLDivElement | undefined): void => {
+ contextMenuRef = element;
+ if (element) {
+ contextMenu.setMenuRef(element);
+ }
+ };
+
+ const clearLongPressTimer = (): void => {
+ if (longPressTimer !== undefined) {
+ window.clearTimeout(longPressTimer);
+ longPressTimer = undefined;
+ }
+ };
+
+ const updateDragGhostPosition = (x: number, y: number): void => {
+ setDragGhostPosition({ x: x + 18, y: y + 18 });
+ };
+
+ const draggedNode = (): ProjectTreeNode | null => {
+ const currentDragState = dragState();
+ if (!currentDragState) {
+ return null;
+ }
+
+ return findTreeNodeLocation(projectTreeNodes(), currentDragState.draggedNodeId, projectTreeAdapter)?.node ?? null;
+ };
+
+ const draggedNodeMeta = (): string => {
+ const node = draggedNode();
+ if (!node) {
+ return "";
+ }
+
+ return node.kind === "folder" ? "Folder" : "Project";
+ };
+
+ 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 => {
+ const requestId = latestPersistedFoldersRequest + 1;
+ latestPersistedFoldersRequest = requestId;
+
+ if (!isValidProjectFolderProjectId(projectId) || !isUuidString(projectId)) {
+ setPersistedFolders([]);
+ return;
+ }
+
+ try {
+ const body = await fetchProjectFolders(projectId);
+
+ if (requestId !== latestPersistedFoldersRequest) {
+ return;
+ }
+
+ 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(() => {
+ void loadPersistedFolders(selectedProject().id);
+ });
+
+ 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, 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 ? countProjectFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index) : 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!, 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();
+ };
+
+ const handlePointerMove = (event: PointerEvent): void => {
+ if (!dragState()) {
+ return;
+ }
+
+ updateDragGhostPosition(event.clientX, event.clientY);
+ };
+
+ document.addEventListener("pointerdown", handlePointerDown);
+ window.addEventListener("pointerup", handlePointerUp);
+ window.addEventListener("pointercancel", handlePointerUp);
+ window.addEventListener("pointermove", handlePointerMove);
+ 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("pointermove", handlePointerMove);
+ 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 => {
+ 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 body = await createProjectFolderRequest(projectId, name, parentFolderPath);
+
+ setPersistedFolders(readPersistedFolders(body));
+ setPendingFolderDraft(null);
+ setPendingFolderName("");
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ const deletePersistedFolder = async (folderId: string): Promise => {
+ const projectId = selectedProject().id;
+ if (!folderId || !isUuidString(projectId)) {
+ return;
+ }
+
+ const folderPath = resolveFolderPath(folderId);
+ if (!folderPath) {
+ return;
+ }
+
+ try {
+ const body = await deleteProjectFolderRequest(projectId, folderPath);
+
+ setPersistedFolders(readPersistedFolders(body));
+ setCollapsedFolderIds((current) => current.filter((id) => id !== folderId));
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ const movePersistedFolder = async (
+ folderPath: string,
+ parentFolderPath: string | null,
+ folderStableId: string,
+ parentStableId: string | null,
+ targetIndex: number,
+ ): Promise => {
+ const projectId = selectedProject().id;
+ if (!folderPath || !folderStableId || !isUuidString(projectId)) {
+ return;
+ }
+
+ try {
+ const body = await moveProjectFolderRequest(projectId, {
+ folderId: folderPath,
+ folderNodeId: folderStableId,
+ parentFolderId: parentFolderPath,
+ parentNodeId: parentStableId,
+ targetIndex,
+ });
+
+ setPersistedFolders(readPersistedFolders(body));
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ const submitPendingFolderRename = async (): Promise => {
+ 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 body = await renameProjectFolderRequest(projectId, folderPath, name);
+
+ 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 openRootCreateMenu = (element: HTMLElement): void => {
+ contextMenu.openMenuFromElement(element, 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;
+ }
+
+ updateDragGhostPosition(event.clientX, event.clientY);
+ 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 || nextDragState.draggedNodeId === (node.kind === "folder" ? node.id : node.item.id)) {
+ return;
+ }
+
+ const relativeY = getPointerRelativeY(event);
+ if (relativeY === null) {
+ return;
+ }
+
+ setDragState({
+ ...nextDragState,
+ dropTarget: resolveTreeDropTarget({
+ parentId,
+ index,
+ node,
+ relativeY,
+ adapter: projectTreeAdapter,
+ }),
+ });
+ };
+
+ const openFolderMenu = (event: MouseEvent, folder: ProjectFolderNode): void => {
+ event.stopPropagation();
+ contextMenu.openMenu(event, createProjectFolderTarget(folder.id, folder.label));
+ };
+
+ const openProjectMenu = (event: MouseEvent, item: ProjectItem): void => {
+ event.stopPropagation();
+ contextMenu.openMenu(event, createProjectTarget(item));
+ };
+
+ const contextMenuPosition = () => {
+ const state = contextMenu.menuState();
+ return state ? { x: state.x, y: state.y } : null;
+ };
+
+ return {
+ selectedProject,
+ drawerTop,
+ setRootRef,
+ setTriggerRef,
+ setContextMenuElement,
+ toggleOpen,
+ handleSurfaceContextMenu,
+ openRootCreateMenu,
+ treeControlLabel,
+ toggleAllFolders,
+ totalFolderCount,
+ areAllFoldersCollapsed,
+ projectTreeNodes,
+ isFolderCollapsed,
+ toggleFolder,
+ selectProject,
+ openFolderMenu,
+ openProjectMenu,
+ handleNodePointerDown,
+ handleNodePointerMove,
+ pendingFolderDraft,
+ pendingFolderName,
+ setPendingFolderName,
+ submitPendingFolder,
+ cancelPendingFolder,
+ pendingFolderRename,
+ pendingFolderRenameName,
+ setPendingFolderRenameName,
+ submitPendingFolderRename,
+ cancelPendingFolderRename,
+ dragState,
+ suppressNextTreeClick,
+ contextMenu,
+ contextMenuPosition,
+ handleContextActionSelect,
+ draggedNode,
+ dragGhostPosition,
+ draggedNodeMeta,
+ };
+};
diff --git a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.module.scss
similarity index 100%
rename from Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss
rename to Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.module.scss
diff --git a/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.parts.tsx b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.parts.tsx
new file mode 100644
index 0000000..4e3e2a4
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.parts.tsx
@@ -0,0 +1,213 @@
+// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.parts.tsx
+
+import { For, Show, type JSX } from "solid-js";
+import { ChevronRight, Folder, LayoutGrid } from "../../../lib/icons";
+import type { ProjectItem } from "../../app-shell/data/shell.data";
+import {
+ getProjectTreeNodeId,
+ type PendingProjectFolderDraft,
+ type PendingProjectFolderRename,
+ type ProjectDragState,
+ type ProjectFolderBranchProps,
+ type ProjectFolderNode,
+ type ProjectTreeNode,
+} from "./ProjectSelector.data";
+import styles from "./ProjectSelector.module.scss";
+
+export 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 (
+
+
+
+ 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();
+ }
+ }}
+ />
+
+
+ );
+};
+
+export const ProjectFolderBranch = (props: ProjectFolderBranchProps): JSX.Element => (
+
+
+ -
+
+
+
+
+
+ {(node, indexAccessor): JSX.Element => {
+ const nodeId = (): string => getProjectTreeNodeId(node);
+ const isDraggedNode = (): boolean => props.dragState?.draggedNodeId === nodeId();
+ const dropIntent = (): "before" | "after" | "inside" | 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 (
+ -
+ {
+ 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)}
+ >
+
+
+ {node.label}
+
+ {node.meta}
+
+
+ }
+ >
+
+
+
+ 0 || props.pendingFolderDraft?.parentId === node.id)}>
+
+
+
+ );
+ }
+
+ return (
+ -
+
+
+ );
+ }}
+
+
+
+
+
+
+);
diff --git a/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.tsx b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.tsx
new file mode 100644
index 0000000..679b490
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.tsx
@@ -0,0 +1,222 @@
+// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.tsx
+
+import { Show, type JSX } from "solid-js";
+import { Portal } from "solid-js/web";
+import { ChevronDown, ChevronRight, Folder, LayoutGrid, ListCollapse, Plus, UnfoldVertical } from "../../../lib/icons";
+import { ProjectContextMenu } from "../ProjectContextMenu/ProjectContextMenu";
+import { useProjectSelector } from "./ProjectSelector.hook";
+import { ProjectFolderBranch } from "./ProjectSelector.parts";
+import { type ProjectSelectorProps } from "./ProjectSelector.data";
+import styles from "./ProjectSelector.module.scss";
+
+export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
+ const {
+ selectedProject,
+ drawerTop,
+ setRootRef,
+ setTriggerRef,
+ setContextMenuElement,
+ toggleOpen,
+ handleSurfaceContextMenu,
+ openRootCreateMenu,
+ treeControlLabel,
+ toggleAllFolders,
+ totalFolderCount,
+ areAllFoldersCollapsed,
+ projectTreeNodes,
+ isFolderCollapsed,
+ toggleFolder,
+ selectProject,
+ openFolderMenu,
+ openProjectMenu,
+ handleNodePointerDown,
+ handleNodePointerMove,
+ pendingFolderDraft,
+ pendingFolderName,
+ setPendingFolderName,
+ submitPendingFolder,
+ cancelPendingFolder,
+ pendingFolderRename,
+ pendingFolderRenameName,
+ setPendingFolderRenameName,
+ submitPendingFolderRename,
+ cancelPendingFolderRename,
+ dragState,
+ suppressNextTreeClick,
+ contextMenu,
+ contextMenuPosition,
+ handleContextActionSelect,
+ draggedNode,
+ dragGhostPosition,
+ draggedNodeMeta,
+ } = useProjectSelector(props);
+
+ return (
+
+
+
+
+ <>
+
+
+
+ >
+
+
+
+
+
+ {(node): JSX.Element => {
+ const GhostIcon = node.kind === "folder" ? Folder : LayoutGrid;
+
+ return (
+
+
+
+
+
+
{node.kind === "folder" ? node.label : node.item.name}
+
{draggedNodeMeta()}
+
+
+
+
+ );
+ }}
+
+
+ );
+};
diff --git a/Frontend/src/components/workspace-navigation/ProjectSelector/projectFolders.api.ts b/Frontend/src/components/workspace-navigation/ProjectSelector/projectFolders.api.ts
new file mode 100644
index 0000000..213d9be
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/ProjectSelector/projectFolders.api.ts
@@ -0,0 +1,109 @@
+import { resolveAPIBase } from "../../../lib/api";
+import type { ProjectFoldersResponse } from "./ProjectSelector.data";
+
+const readProjectFoldersResponse = async (response: Response): Promise =>
+ (await response.json()) as ProjectFoldersResponse;
+
+export const isValidProjectFolderProjectId = (projectId: string): boolean =>
+ Boolean(projectId);
+
+export const fetchProjectFolders = async (projectId: string): Promise => {
+ const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
+ headers: { Accept: "application/json" },
+ });
+ const body = await readProjectFoldersResponse(response);
+
+ if (!response.ok) {
+ throw new Error(body.message || "Failed to load project folders.");
+ }
+
+ return body;
+};
+
+export const createProjectFolderRequest = async (
+ projectId: string,
+ name: string,
+ parentFolderId: string | null,
+): Promise => {
+ const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ name, parentFolderId }),
+ });
+ const body = await readProjectFoldersResponse(response);
+
+ if (!response.ok) {
+ throw new Error(body.message || "Failed to create project folder.");
+ }
+
+ return body;
+};
+
+export const deleteProjectFolderRequest = async (
+ projectId: string,
+ folderId: string,
+): Promise => {
+ const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders?folderId=${encodeURIComponent(folderId)}`, {
+ method: "DELETE",
+ headers: { Accept: "application/json" },
+ });
+ const body = await readProjectFoldersResponse(response);
+
+ if (!response.ok) {
+ throw new Error(body.message || "Failed to delete project folder.");
+ }
+
+ return body;
+};
+
+export const moveProjectFolderRequest = async (
+ projectId: string,
+ payload: {
+ folderId: string;
+ folderNodeId: string;
+ parentFolderId: string | null;
+ parentNodeId: string | null;
+ targetIndex: number;
+ },
+): Promise => {
+ const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders/move`, {
+ method: "PATCH",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+ const body = await readProjectFoldersResponse(response);
+
+ if (!response.ok) {
+ throw new Error(body.message || "Failed to move project folder.");
+ }
+
+ return body;
+};
+
+export const renameProjectFolderRequest = async (
+ projectId: string,
+ folderId: string,
+ name: string,
+): Promise => {
+ const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
+ method: "PATCH",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ folderId, name }),
+ });
+ const body = await readProjectFoldersResponse(response);
+
+ if (!response.ok) {
+ throw new Error(body.message || "Failed to rename project folder.");
+ }
+
+ return body;
+};
diff --git a/Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.module.scss b/Frontend/src/components/workspace-navigation/WorkspaceContextMenu/WorkspaceContextMenu.module.scss
similarity index 100%
rename from Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.module.scss
rename to Frontend/src/components/workspace-navigation/WorkspaceContextMenu/WorkspaceContextMenu.module.scss
diff --git a/Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.tsx b/Frontend/src/components/workspace-navigation/WorkspaceContextMenu/WorkspaceContextMenu.tsx
similarity index 99%
rename from Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.tsx
rename to Frontend/src/components/workspace-navigation/WorkspaceContextMenu/WorkspaceContextMenu.tsx
index 8b97f0f..79e353f 100644
--- a/Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.tsx
+++ b/Frontend/src/components/workspace-navigation/WorkspaceContextMenu/WorkspaceContextMenu.tsx
@@ -7,7 +7,7 @@ import {
type WorkspaceContextMenuAction,
type WorkspaceContextMenuShortcut,
type WorkspaceContextMenuTarget,
-} from "../data/shell.data";
+} from "../../app-shell/data/shell.data";
import styles from "./WorkspaceContextMenu.module.scss";
type ShortcutPlatform = "mac" | "windows";
diff --git a/Frontend/src/components/shell/WorkspaceContextMenu/createWorkspaceContextMenuController.ts b/Frontend/src/components/workspace-navigation/WorkspaceContextMenu/createWorkspaceContextMenuController.ts
similarity index 97%
rename from Frontend/src/components/shell/WorkspaceContextMenu/createWorkspaceContextMenuController.ts
rename to Frontend/src/components/workspace-navigation/WorkspaceContextMenu/createWorkspaceContextMenuController.ts
index e5f21a8..5143de7 100644
--- a/Frontend/src/components/shell/WorkspaceContextMenu/createWorkspaceContextMenuController.ts
+++ b/Frontend/src/components/workspace-navigation/WorkspaceContextMenu/createWorkspaceContextMenuController.ts
@@ -1,5 +1,5 @@
import { createEffect, createSignal, onCleanup } from "solid-js";
-import type { WorkspaceContextMenuTarget } from "../data/shell.data";
+import type { WorkspaceContextMenuTarget } from "../../app-shell/data/shell.data";
type WorkspaceContextMenuState = {
target: WorkspaceContextMenuTarget;
diff --git a/Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.module.scss b/Frontend/src/components/workspace-navigation/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.module.scss
similarity index 100%
rename from Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.module.scss
rename to Frontend/src/components/workspace-navigation/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.module.scss
diff --git a/Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx b/Frontend/src/components/workspace-navigation/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx
similarity index 99%
rename from Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx
rename to Frontend/src/components/workspace-navigation/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx
index c4f9a01..754094e 100644
--- a/Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx
+++ b/Frontend/src/components/workspace-navigation/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx
@@ -7,7 +7,7 @@ import {
type WorkspaceContextMenuAction,
type WorkspaceContextMenuSection,
type WorkspaceContextMenuTarget,
-} from "../data/shell.data";
+} from "../../app-shell/data/shell.data";
import styles from "./WorkspaceMobileActionSheet.module.scss";
type WorkspaceMobileActionSheetProps = {
diff --git a/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.data.ts b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.data.ts
new file mode 100644
index 0000000..c7e3604
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.data.ts
@@ -0,0 +1,57 @@
+// Path: Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.data.ts
+
+import type { NavTreeAdapter, NavTreeDropTarget } from "../shared/navTreeDnd";
+import type { WorkspaceTreeNode } from "../../app-shell/data/shell.data";
+
+export type WorkspaceSidebarProps = {
+ collapsed: boolean;
+ railCollapsed: boolean;
+ onToggleRailCollapse: () => void;
+};
+
+export type PendingWorkspaceFolderDraft = {
+ parentId: string | null;
+ depth: number;
+};
+
+export type WorkspaceDragTarget = NavTreeDropTarget;
+
+export type WorkspaceDragState = {
+ draggedNodeId: string;
+ dropTarget: WorkspaceDragTarget | null;
+};
+
+export type DragGhostPosition = {
+ x: number;
+ y: number;
+};
+
+export type PendingWorkspaceFolderRename = {
+ folderId: string;
+ depth: number;
+};
+
+export const LONG_PRESS_MS = 320;
+
+export const getWorkspaceTreeNodeId = (node: WorkspaceTreeNode): string => node.id;
+
+export const countWorkspaceFolderSiblingsBeforeIndex = (
+ siblings: readonly WorkspaceTreeNode[],
+ index: number,
+): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length;
+
+export const workspaceTreeAdapter: NavTreeAdapter = {
+ getNodeId: getWorkspaceTreeNodeId,
+ isBranchNode: (node) => node.kind === "folder",
+ getChildren: (node) => (node.kind === "folder" ? (node.children ?? []) : []),
+ withChildren: (node, children) =>
+ node.kind === "folder"
+ ? {
+ ...node,
+ children: [...children],
+ }
+ : node,
+};
+
+export const isContextMenuKeyboardTrigger = (event: KeyboardEvent): boolean =>
+ event.key === "ContextMenu" || (event.shiftKey && event.key === "F10");
diff --git a/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.hook.ts b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.hook.ts
new file mode 100644
index 0000000..1c0cf7f
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.hook.ts
@@ -0,0 +1,534 @@
+// Path: Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.hook.ts
+
+import { createEffect, createSignal, onCleanup, onMount } from "solid-js";
+import { useAppShellData } from "../../app-shell/data/app-shell.context";
+import {
+ createWorkspaceSurfaceTarget,
+ getWorkspaceItemTypeDefinition,
+ type WorkspaceContextMenuAction,
+ type WorkspaceContextMenuTarget,
+ type WorkspaceItemTypeId,
+ type WorkspaceTreeNode,
+} from "../../app-shell/data/shell.data";
+import {
+ collectBranchNodeIds,
+ findTreeNodeDepth,
+ findTreeNodeLocation,
+ getPointerRelativeY,
+ isUuidString,
+ moveTreeNode,
+ resolveTreeDropTarget,
+} from "../shared/navTreeDnd";
+import { useWorkspaceTreeData } from "../shared/useWorkspaceTreeData";
+import { createWorkspaceContextMenuController } from "../WorkspaceContextMenu/createWorkspaceContextMenuController";
+import {
+ countWorkspaceFolderSiblingsBeforeIndex,
+ getWorkspaceTreeNodeId,
+ LONG_PRESS_MS,
+ workspaceTreeAdapter,
+ type DragGhostPosition,
+ type PendingWorkspaceFolderDraft,
+ type PendingWorkspaceFolderRename,
+ type WorkspaceDragTarget,
+ type WorkspaceDragState,
+} from "./WorkspaceSidebar.data";
+
+export const useWorkspaceSidebar = () => {
+ const appShellData = useAppShellData();
+ const activeProject = () => appShellData.activeProject();
+ const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false);
+ const [collapsedFolderIds, setCollapsedFolderIds] = createSignal([]);
+ const [pendingFolderDraft, setPendingFolderDraft] = createSignal(null);
+ const [pendingFolderName, setPendingFolderName] = createSignal("");
+ const [pendingFolderRename, setPendingFolderRename] = createSignal(null);
+ const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal("");
+ const [dragState, setDragState] = createSignal(null);
+ const [dragGhostPosition, setDragGhostPosition] = createSignal({ x: 0, y: 0 });
+ const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false);
+ let lastSelectedProjectId: string | null = null;
+ const {
+ workspaceTreeNodes,
+ setWorkspaceTreeNodes,
+ resolveFolderPath,
+ resolveItemPath,
+ createFolder,
+ renameFolder,
+ deleteFolder,
+ moveFolder,
+ createItem,
+ deleteItem,
+ moveItem,
+ } = useWorkspaceTreeData({
+ activeProjectId: () => activeProject()?.id ?? "",
+ fallbackWorkspaceTree: () => appShellData.workspaceTree(),
+ });
+ const contextMenu = createWorkspaceContextMenuController();
+ let longPressTimer: number | undefined;
+ let suppressClickTimer: number | undefined;
+ 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 syncCollapsedFolderIds = (): void => {
+ const availableFolderIds = new Set(collectBranchNodeIds(workspaceTreeNodes(), workspaceTreeAdapter));
+ setCollapsedFolderIds((current) => current.filter((id) => availableFolderIds.has(id)));
+ };
+ const updateDragGhostPosition = (x: number, y: number): void => {
+ setDragGhostPosition({ x: x + 18, y: y + 18 });
+ };
+ const draggedNode = (): WorkspaceTreeNode | null => {
+ const currentDragState = dragState();
+ if (!currentDragState) {
+ return null;
+ }
+
+ return findTreeNodeLocation(workspaceTreeNodes(), currentDragState.draggedNodeId, workspaceTreeAdapter)?.node ?? null;
+ };
+ const draggedNodeMeta = (): string => {
+ const node = draggedNode();
+ if (!node) {
+ return "";
+ }
+
+ if (node.kind === "folder") {
+ return "Folder";
+ }
+
+ return getWorkspaceItemTypeDefinition(node.itemType).label;
+ };
+ 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(() => {
+ syncCollapsedFolderIds();
+ });
+
+ createEffect(() => {
+ const projectId = activeProject()?.id ?? null;
+
+ if (lastSelectedProjectId === null) {
+ lastSelectedProjectId = projectId;
+ return;
+ }
+
+ if (projectId === lastSelectedProjectId) {
+ return;
+ }
+
+ lastSelectedProjectId = projectId;
+ resetWorkspaceTreeInteractionState();
+ });
+
+ onMount(() => {
+ const handlePointerMove = (event: PointerEvent): void => {
+ if (!dragState()) {
+ return;
+ }
+
+ updateDragGhostPosition(event.clientX, event.clientY);
+ };
+
+ 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 canPersistMove = isUuidString(activeProject()?.id ?? "");
+ const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter);
+ const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, workspaceTreeAdapter);
+ const persistedParentLocation = previewLocation?.parentId
+ ? findTreeNodeLocation(previewNodes, previewLocation.parentId, workspaceTreeAdapter)
+ : null;
+ const previewSiblings = previewLocation?.parentId
+ ? persistedParentLocation?.node.kind === "folder"
+ ? persistedParentLocation.node.children ?? []
+ : []
+ : previewNodes;
+ const targetIndex = previewLocation
+ ? countWorkspaceFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index)
+ : 0;
+
+ if (
+ canPersistMove &&
+ draggedLocation?.node.kind === "folder" &&
+ (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder")
+ ) {
+ void moveFolder(
+ draggedLocation.node.id,
+ persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null,
+ targetIndex,
+ );
+ } else if (
+ canPersistMove &&
+ draggedLocation?.node.kind === "item" &&
+ resolveItemPath(draggedLocation.node.id) &&
+ (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder")
+ ) {
+ void moveItem(
+ draggedLocation.node.id,
+ persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null,
+ previewLocation?.index ?? 0,
+ );
+ } 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("pointermove", handlePointerMove);
+ window.addEventListener("keydown", handleEscape);
+
+ onCleanup(() => {
+ clearLongPressTimer();
+ if (suppressClickTimer !== undefined) {
+ window.clearTimeout(suppressClickTimer);
+ }
+ window.removeEventListener("pointerup", handlePointerUp);
+ window.removeEventListener("pointercancel", handlePointerUp);
+ window.removeEventListener("pointermove", handlePointerMove);
+ 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 => {
+ 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;
+ }
+
+ const parentFolderPath = draft.parentId ? resolveFolderPath(draft.parentId) : null;
+ if (draft.parentId && !parentFolderPath) {
+ cancelPendingFolder();
+ return;
+ }
+
+ const created = await createFolder(name, draft.parentId);
+ if (created) {
+ setPendingFolderDraft(null);
+ setPendingFolderName("");
+ }
+ };
+
+ const deletePersistedFolder = async (folderId: string): Promise => {
+ const deleted = await deleteFolder(folderId);
+ if (deleted) {
+ setCollapsedFolderIds((current) => current.filter((id) => id !== folderId));
+ }
+ };
+
+ const submitPendingFolderRename = async (): Promise => {
+ 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;
+ }
+
+ const folderPath = resolveFolderPath(draft.folderId);
+ if (!folderPath) {
+ cancelPendingFolderRename();
+ return;
+ }
+
+ const renamed = await renameFolder(draft.folderId, name);
+ if (renamed) {
+ setPendingFolderRename(null);
+ setPendingFolderRenameName("");
+ }
+ };
+
+ 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 openWorkspaceCreateMenu = (element: HTMLElement): void => {
+ contextMenu.openMenuFromElement(element, sidebarContextMenuTarget());
+ };
+
+ const resolveCreateItemType = (actionId: string): WorkspaceItemTypeId | null => {
+ switch (actionId) {
+ case "create-doc":
+ return "core.doc";
+ case "create-board":
+ return "core.board.kanban";
+ case "create-list-board":
+ return "core.board.list";
+ default:
+ return null;
+ }
+ };
+
+ const createPersistedItem = async (itemType: WorkspaceItemTypeId, parentId: string | null): Promise => {
+ const definition = getWorkspaceItemTypeDefinition(itemType);
+ await createItem(definition.defaultCreateLabel, itemType, parentId);
+ };
+
+ const handleNodePointerDown = (event: PointerEvent, nodeId: string): void => {
+ if (event.button !== 0 || pendingFolderDraft()) {
+ return;
+ }
+
+ updateDragGhostPosition(event.clientX, event.clientY);
+ 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 => {
+ const createItemType = resolveCreateItemType(action.id);
+ if (createItemType) {
+ switch (target.kind) {
+ case "workspace":
+ case "home":
+ void createPersistedItem(createItemType, null);
+ return;
+ case "folder":
+ void createPersistedItem(createItemType, target.id);
+ return;
+ case "settings":
+ case "item":
+ return;
+ }
+ }
+
+ 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;
+ case "delete-doc":
+ case "delete-board":
+ case "delete-list-board":
+ if (target.kind === "item") {
+ void deleteItem(target.id);
+ }
+ return;
+ default:
+ return;
+ }
+ };
+
+ return {
+ activeProject,
+ contextMenu,
+ sidebarContextMenuTarget,
+ isProjectDrawerOpen,
+ setIsProjectDrawerOpen,
+ workspaceTreeNodes,
+ isFolderCollapsed,
+ pendingFolderDraft,
+ pendingFolderName,
+ setPendingFolderName,
+ submitPendingFolder,
+ cancelPendingFolder,
+ pendingFolderRename,
+ pendingFolderRenameName,
+ setPendingFolderRenameName,
+ submitPendingFolderRename,
+ cancelPendingFolderRename,
+ handleNodePointerDown,
+ handleNodePointerMove,
+ dragState,
+ suppressNextTreeClick,
+ draggedNode,
+ dragGhostPosition,
+ draggedNodeMeta,
+ workspaceFolderToggleLabel,
+ areAllFoldersCollapsed,
+ totalFolderCount,
+ handleHeaderActionClick,
+ openWorkspaceCreateMenu,
+ handleContextActionSelect,
+ toggleFolder,
+ };
+};
diff --git a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.module.scss b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.module.scss
similarity index 100%
rename from Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.module.scss
rename to Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.module.scss
diff --git a/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.parts.tsx b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.parts.tsx
new file mode 100644
index 0000000..461763e
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.parts.tsx
@@ -0,0 +1,287 @@
+// Path: Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.parts.tsx
+
+import { For, Show, type JSX } from "solid-js";
+import { ChevronRight, Folder } from "../../../lib/icons";
+import {
+ createWorkspaceStaticTarget,
+ createWorkspaceTreeTarget,
+ getWorkspaceNodeIcon,
+ type WorkspaceContextMenuTarget,
+ type WorkspaceStaticItem,
+ type WorkspaceTreeNode,
+} from "../../app-shell/data/shell.data";
+import {
+ isContextMenuKeyboardTrigger,
+ type PendingWorkspaceFolderDraft,
+ type PendingWorkspaceFolderRename,
+ type WorkspaceDragState,
+ type WorkspaceDragTarget,
+} from "./WorkspaceSidebar.data";
+import styles from "./WorkspaceSidebar.module.scss";
+
+export 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 (
+
+
+
+ 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();
+ }
+ }}
+ />
+
+
+ );
+};
+
+export 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 (
+
+
+
+ );
+};
+
+export 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 (
+
+
+ -
+
+
+
+
+ {(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 (
+ -
+ {
+ 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);
+ }}
+ >
+
+
+
+
+ {node.label}
+
+ {node.meta}
+
+
+ }
+ >
+
+
+
+ 0) || props.pendingFolderDraft?.parentId === node.id)
+ }
+ >
+
+
+
+ );
+ }}
+
+
+
+
+
+
+ );
+};
diff --git a/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.tsx b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.tsx
new file mode 100644
index 0000000..591eae5
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.tsx
@@ -0,0 +1,250 @@
+// Path: Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.tsx
+
+import { For, Show, type JSX } from "solid-js";
+import { Portal } from "solid-js/web";
+import { ChevronLeft, ChevronRight, ListCollapse, Plus, UnfoldVertical } from "../../../lib/icons";
+import { ProjectSelector } from "../ProjectSelector/ProjectSelector";
+import {
+ getWorkspaceNodeIcon,
+ workspaceSidebarHeaderActions,
+ workspaceStaticItems,
+} from "../../app-shell/data/shell.data";
+import { WorkspaceContextMenu } from "../WorkspaceContextMenu/WorkspaceContextMenu";
+import type { WorkspaceSidebarProps } from "./WorkspaceSidebar.data";
+import { useWorkspaceSidebar } from "./WorkspaceSidebar.hook";
+import { WorkspaceHomeEntry, WorkspaceTreeBranch } from "./WorkspaceSidebar.parts";
+import styles from "./WorkspaceSidebar.module.scss";
+
+export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
+ const railToggleLabel = (): string => (props.railCollapsed ? "Expand server rail" : "Collapse server rail");
+ const {
+ contextMenu,
+ sidebarContextMenuTarget,
+ isProjectDrawerOpen,
+ setIsProjectDrawerOpen,
+ workspaceTreeNodes,
+ isFolderCollapsed,
+ pendingFolderDraft,
+ pendingFolderName,
+ setPendingFolderName,
+ submitPendingFolder,
+ cancelPendingFolder,
+ pendingFolderRename,
+ pendingFolderRenameName,
+ setPendingFolderRenameName,
+ submitPendingFolderRename,
+ cancelPendingFolderRename,
+ handleNodePointerDown,
+ handleNodePointerMove,
+ dragState,
+ suppressNextTreeClick,
+ draggedNode,
+ dragGhostPosition,
+ draggedNodeMeta,
+ workspaceFolderToggleLabel,
+ areAllFoldersCollapsed,
+ totalFolderCount,
+ handleHeaderActionClick,
+ openWorkspaceCreateMenu,
+ handleContextActionSelect,
+ toggleFolder,
+ } = useWorkspaceSidebar();
+
+ return (
+ <>
+
+
+ {
+ const state = contextMenu.menuState();
+ return state
+ ? {
+ x: state.x,
+ y: state.y,
+ }
+ : null;
+ })()}
+ menuRef={contextMenu.setMenuRef}
+ onClose={contextMenu.closeMenu}
+ onSelect={handleContextActionSelect}
+ />
+
+
+ {(node): JSX.Element => {
+ const GhostIcon = getWorkspaceNodeIcon(node);
+
+ return (
+
+
+
+
+
+
{node.label}
+
{draggedNodeMeta()}
+
+
+
+
+ );
+ }}
+
+ >
+ );
+};
diff --git a/Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.module.scss b/Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.module.scss
new file mode 100644
index 0000000..73b7a0a
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.module.scss
@@ -0,0 +1,81 @@
+.workspaceTopBar {
+ width: 100%;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--space-2);
+ min-height: calc(var(--control-size-md) - var(--space-3));
+ padding: var(--space-5) var(--space-6) 0;
+}
+
+.workspaceTopBarStart,
+.workspaceTopBarEnd {
+ min-width: calc(var(--control-size-md) - 0.5rem);
+ display: inline-flex;
+ align-items: center;
+}
+
+.workspaceTopBarEnd {
+ justify-content: flex-end;
+}
+
+.workspaceTopBarCenter {
+ min-width: 0;
+ display: flex;
+ justify-content: center;
+}
+
+.workspaceBreadcrumb {
+ @include text-caption;
+ min-width: 0;
+ color: var(--color-text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.workspaceCollapseButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: calc(var(--control-size-md) - 0.5rem);
+ height: calc(var(--control-size-md) - 0.5rem);
+ border: 1px solid color-mix(in srgb, var(--color-border-strong) 44%, transparent);
+ border-radius: var(--radius-pill);
+ background: color-mix(in srgb, var(--color-surface) 94%, transparent);
+ color: var(--color-text-muted);
+ box-shadow: var(--shadow-soft);
+ transition:
+ background 160ms var(--easing-standard),
+ color 160ms var(--easing-standard),
+ border-color 160ms var(--easing-standard),
+ transform 180ms var(--easing-standard);
+}
+
+.workspaceCollapseButton:hover,
+.workspaceCollapseButton:focus-visible {
+ background: var(--color-surface-hover);
+ border-color: var(--color-border);
+ color: var(--color-text);
+}
+
+.workspaceCollapseButton:hover {
+ transform: translateY(-1px);
+}
+
+@include respond-down(mobile) {
+ .workspaceTopBar {
+ grid-template-columns: minmax(0, 1fr);
+ padding: var(--space-4) var(--space-4) 0;
+ }
+
+ .workspaceTopBarStart,
+ .workspaceTopBarEnd,
+ .workspaceCollapseButton {
+ display: none;
+ }
+
+ .workspaceTopBarCenter {
+ justify-content: flex-start;
+ }
+}
diff --git a/Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.tsx b/Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.tsx
new file mode 100644
index 0000000..a4589e1
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.tsx
@@ -0,0 +1,29 @@
+import { type JSX } from "solid-js";
+import { ChevronLeft, ChevronRight } from "../../../lib/icons";
+import styles from "./WorkspaceTopBar.module.scss";
+
+type WorkspaceTopBarProps = {
+ sidebarCollapsed: boolean;
+ breadcrumb: string;
+ onToggleSidebarCollapse: () => void;
+};
+
+export const WorkspaceTopBar = (props: WorkspaceTopBarProps): JSX.Element => {
+ const sidebarToggleLabel = (): string => (props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar");
+
+ return (
+
+
+
+
+
+
+ {props.breadcrumb}
+
+
+
+
+ );
+};
diff --git a/Frontend/src/components/shell/shared/_tree-nav.scss b/Frontend/src/components/workspace-navigation/shared/_tree-nav.scss
similarity index 100%
rename from Frontend/src/components/shell/shared/_tree-nav.scss
rename to Frontend/src/components/workspace-navigation/shared/_tree-nav.scss
diff --git a/Frontend/src/components/shell/shared/navTreeDnd.ts b/Frontend/src/components/workspace-navigation/shared/navTreeDnd.ts
similarity index 100%
rename from Frontend/src/components/shell/shared/navTreeDnd.ts
rename to Frontend/src/components/workspace-navigation/shared/navTreeDnd.ts
diff --git a/Frontend/src/components/workspace-navigation/shared/useWorkspaceTreeData.ts b/Frontend/src/components/workspace-navigation/shared/useWorkspaceTreeData.ts
new file mode 100644
index 0000000..314d655
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/shared/useWorkspaceTreeData.ts
@@ -0,0 +1,263 @@
+import { createEffect, createSignal, type Accessor, type Setter } from "solid-js";
+import type { WorkspaceItemTypeId, WorkspaceTreeNode } from "../../app-shell/data/shell.data";
+import {
+ buildPersistedWorkspaceTreeNodes,
+ findWorkspaceTreeNodeById,
+ readPersistedWorkspaceTreeNodes,
+} from "./workspaceTree.adapters";
+import {
+ createWorkspaceFolderRequest,
+ createWorkspaceItemRequest,
+ deleteWorkspaceFolderRequest,
+ deleteWorkspaceItemRequest,
+ fetchWorkspaceTree,
+ isValidWorkspaceProjectId,
+ moveWorkspaceFolderRequest,
+ moveWorkspaceItemRequest,
+ readWorkspaceMutationResponse,
+ renameWorkspaceFolderRequest,
+} from "./workspaceTree.api";
+import type {
+ PersistedWorkspaceTreeNodeRecord,
+ UseWorkspaceTreeDataOptions,
+ UseWorkspaceTreeDataResult,
+} from "./workspaceTree.types";
+
+export const useWorkspaceTreeData = (
+ options: UseWorkspaceTreeDataOptions,
+): UseWorkspaceTreeDataResult => {
+ const [persistedNodes, setPersistedNodes] = createSignal([]);
+ const [workspaceTreeNodes, setWorkspaceTreeNodes] = createSignal(
+ options.fallbackWorkspaceTree(),
+ );
+ let latestPersistedTreeRequest = 0;
+
+ const syncWorkspaceTree = (): void => {
+ const nextTree = options.activeProjectId()
+ ? buildPersistedWorkspaceTreeNodes(persistedNodes())
+ : options.fallbackWorkspaceTree();
+
+ setWorkspaceTreeNodes(nextTree);
+ };
+
+ const loadPersistedTree = async (projectId: string): Promise => {
+ const requestId = latestPersistedTreeRequest + 1;
+ latestPersistedTreeRequest = requestId;
+
+ if (!isValidWorkspaceProjectId(projectId)) {
+ setPersistedNodes([]);
+ return false;
+ }
+
+ try {
+ const body = await fetchWorkspaceTree(projectId);
+
+ if (requestId !== latestPersistedTreeRequest) {
+ return false;
+ }
+
+ if (body.error) {
+ throw new Error(body.message || "Failed to load project tree.");
+ }
+
+ setPersistedNodes(readPersistedWorkspaceTreeNodes(body));
+ return true;
+ } catch (error) {
+ if (requestId !== latestPersistedTreeRequest) {
+ return false;
+ }
+
+ console.error(error);
+ setPersistedNodes([]);
+ return false;
+ }
+ };
+
+ const resolveFolderPath = (folderId: string): string | null => {
+ const node = findWorkspaceTreeNodeById(workspaceTreeNodes(), folderId);
+ return node?.kind === "folder" ? node.path ?? null : null;
+ };
+
+ const resolveItemPath = (itemId: string): string | null => {
+ const node = findWorkspaceTreeNodeById(workspaceTreeNodes(), itemId);
+ return node?.kind === "item" ? node.path ?? null : null;
+ };
+
+ const refreshAfterMutation = async (projectId: string, response: Response): Promise => {
+ const body = await readWorkspaceMutationResponse(response);
+ if (!response.ok) {
+ throw new Error(body.message || "Failed to persist project tree mutation.");
+ }
+
+ return loadPersistedTree(projectId);
+ };
+
+ const createFolder = async (name: string, parentId: string | null): Promise => {
+ const projectId = options.activeProjectId();
+ if (!isValidWorkspaceProjectId(projectId)) {
+ return false;
+ }
+
+ const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
+ if (parentId && !parentFolderPath) {
+ return false;
+ }
+
+ try {
+ const response = await createWorkspaceFolderRequest(projectId, name, parentFolderPath);
+
+ return refreshAfterMutation(projectId, response);
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ };
+
+ const renameFolder = async (folderId: string, name: string): Promise => {
+ const projectId = options.activeProjectId();
+ const folderPath = resolveFolderPath(folderId);
+ if (!isValidWorkspaceProjectId(projectId) || !folderPath) {
+ return false;
+ }
+
+ try {
+ const response = await renameWorkspaceFolderRequest(projectId, folderPath, name);
+
+ return refreshAfterMutation(projectId, response);
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ };
+
+ const deleteFolder = async (folderId: string): Promise => {
+ const projectId = options.activeProjectId();
+ const folderPath = resolveFolderPath(folderId);
+ if (!isValidWorkspaceProjectId(projectId) || !folderPath) {
+ return false;
+ }
+
+ try {
+ const response = await deleteWorkspaceFolderRequest(projectId, folderPath);
+
+ return refreshAfterMutation(projectId, response);
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ };
+
+ const moveFolder = async (folderId: string, parentId: string | null, targetIndex: number): Promise => {
+ const projectId = options.activeProjectId();
+ const folderPath = resolveFolderPath(folderId);
+ const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
+ if (!isValidWorkspaceProjectId(projectId) || !folderPath) {
+ return false;
+ }
+
+ try {
+ const response = await moveWorkspaceFolderRequest(
+ projectId,
+ folderPath,
+ folderId,
+ parentFolderPath,
+ parentId,
+ targetIndex,
+ );
+
+ return refreshAfterMutation(projectId, response);
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ };
+
+ const createItem = async (
+ name: string,
+ itemType: WorkspaceItemTypeId,
+ parentId: string | null,
+ ): Promise => {
+ const projectId = options.activeProjectId();
+ if (!isValidWorkspaceProjectId(projectId)) {
+ return false;
+ }
+
+ const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
+ if (parentId && !parentFolderPath) {
+ return false;
+ }
+
+ try {
+ const response = await createWorkspaceItemRequest(projectId, name, itemType, parentFolderPath);
+
+ return refreshAfterMutation(projectId, response);
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ };
+
+ const deleteItem = async (itemId: string): Promise => {
+ const projectId = options.activeProjectId();
+ const itemPath = resolveItemPath(itemId);
+ if (!isValidWorkspaceProjectId(projectId) || !itemPath) {
+ return false;
+ }
+
+ try {
+ const response = await deleteWorkspaceItemRequest(projectId, itemPath);
+
+ return refreshAfterMutation(projectId, response);
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ };
+
+ const moveItem = async (itemId: string, parentId: string | null, targetIndex: number): Promise => {
+ const projectId = options.activeProjectId();
+ const itemPath = resolveItemPath(itemId);
+ const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
+ if (!isValidWorkspaceProjectId(projectId) || !itemPath) {
+ return false;
+ }
+
+ try {
+ const response = await moveWorkspaceItemRequest(
+ projectId,
+ itemPath,
+ itemId,
+ parentFolderPath,
+ parentId,
+ targetIndex,
+ );
+
+ return refreshAfterMutation(projectId, response);
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ };
+
+ createEffect(() => {
+ syncWorkspaceTree();
+ });
+
+ createEffect(() => {
+ void loadPersistedTree(options.activeProjectId());
+ });
+
+ return {
+ workspaceTreeNodes,
+ setWorkspaceTreeNodes,
+ resolveFolderPath,
+ resolveItemPath,
+ createFolder,
+ renameFolder,
+ deleteFolder,
+ moveFolder,
+ createItem,
+ deleteItem,
+ moveItem,
+ };
+};
diff --git a/Frontend/src/components/workspace-navigation/shared/workspaceTree.adapters.ts b/Frontend/src/components/workspace-navigation/shared/workspaceTree.adapters.ts
new file mode 100644
index 0000000..0600194
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/shared/workspaceTree.adapters.ts
@@ -0,0 +1,50 @@
+import { Folder } from "../../../lib/icons";
+import type { WorkspaceTreeNode } from "../../app-shell/data/shell.data";
+import type { PersistedWorkspaceTreeNodeRecord, WorkspaceTreeResponse } from "./workspaceTree.types";
+
+export const buildPersistedWorkspaceTreeNodes = (
+ nodes: readonly PersistedWorkspaceTreeNodeRecord[],
+): WorkspaceTreeNode[] =>
+ nodes.map((node) =>
+ node.kind === "folder"
+ ? {
+ id: node.id,
+ path: node.path,
+ label: node.label,
+ kind: "folder",
+ icon: Folder,
+ children: buildPersistedWorkspaceTreeNodes(node.children ?? []),
+ }
+ : {
+ id: node.id,
+ path: node.path,
+ label: node.label,
+ kind: "item",
+ itemType: node.itemType ?? "core.board.kanban",
+ }
+ );
+
+export const readPersistedWorkspaceTreeNodes = (body: WorkspaceTreeResponse): PersistedWorkspaceTreeNodeRecord[] =>
+ Array.isArray(body.data?.nodes) ? body.data.nodes : [];
+
+export const findWorkspaceTreeNodeById = (
+ nodes: readonly WorkspaceTreeNode[],
+ nodeId: string,
+): WorkspaceTreeNode | null => {
+ for (const node of nodes) {
+ if (node.id === nodeId) {
+ return node;
+ }
+
+ if (node.kind !== "folder") {
+ continue;
+ }
+
+ const nestedMatch = findWorkspaceTreeNodeById(node.children ?? [], nodeId);
+ if (nestedMatch) {
+ return nestedMatch;
+ }
+ }
+
+ return null;
+};
diff --git a/Frontend/src/components/workspace-navigation/shared/workspaceTree.api.ts b/Frontend/src/components/workspace-navigation/shared/workspaceTree.api.ts
new file mode 100644
index 0000000..6448602
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/shared/workspaceTree.api.ts
@@ -0,0 +1,129 @@
+import { resolveAPIBase } from "../../../lib/api";
+import { isUuidString } from "./navTreeDnd";
+import type { WorkspaceItemTypeId } from "../../app-shell/data/shell.data";
+import type { WorkspaceMutationResponse, WorkspaceTreeResponse } from "./workspaceTree.types";
+
+export const isValidWorkspaceProjectId = (projectId: string): boolean => Boolean(projectId) && isUuidString(projectId);
+
+const requestWorkspaceTree = async (path: string, init?: RequestInit): Promise =>
+ fetch(`${resolveAPIBase()}${path}`, init);
+
+export const fetchWorkspaceTree = async (projectId: string): Promise => {
+ const response = await requestWorkspaceTree(`/projects/${projectId}/tree`, {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+
+ return (await response.json()) as WorkspaceTreeResponse;
+};
+
+export const readWorkspaceMutationResponse = async (response: Response): Promise =>
+ (await response.json()) as WorkspaceMutationResponse;
+
+export const createWorkspaceFolderRequest = (projectId: string, name: string, parentFolderId: string | null): Promise =>
+ requestWorkspaceTree(`/projects/${projectId}/tree/folders`, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ name,
+ parentFolderId,
+ }),
+ });
+
+export const renameWorkspaceFolderRequest = (projectId: string, folderId: string, name: string): Promise =>
+ requestWorkspaceTree(`/projects/${projectId}/tree/folders`, {
+ method: "PATCH",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ folderId,
+ name,
+ }),
+ });
+
+export const deleteWorkspaceFolderRequest = (projectId: string, folderId: string): Promise =>
+ requestWorkspaceTree(`/projects/${projectId}/tree/folders?folderId=${encodeURIComponent(folderId)}`, {
+ method: "DELETE",
+ headers: {
+ Accept: "application/json",
+ },
+ });
+
+export const moveWorkspaceFolderRequest = (
+ projectId: string,
+ folderId: string,
+ folderNodeId: string,
+ parentFolderId: string | null,
+ parentNodeId: string | null,
+ targetIndex: number,
+): Promise =>
+ requestWorkspaceTree(`/projects/${projectId}/tree/folders/move`, {
+ method: "PATCH",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ folderId,
+ folderNodeId,
+ parentFolderId,
+ parentNodeId,
+ targetIndex,
+ }),
+ });
+
+export const createWorkspaceItemRequest = (
+ projectId: string,
+ name: string,
+ itemType: WorkspaceItemTypeId,
+ parentFolderId: string | null,
+): Promise =>
+ requestWorkspaceTree(`/projects/${projectId}/tree/items`, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ name,
+ itemType,
+ parentFolderId,
+ }),
+ });
+
+export const deleteWorkspaceItemRequest = (projectId: string, itemId: string): Promise =>
+ requestWorkspaceTree(`/projects/${projectId}/tree/items?itemId=${encodeURIComponent(itemId)}`, {
+ method: "DELETE",
+ headers: {
+ Accept: "application/json",
+ },
+ });
+
+export const moveWorkspaceItemRequest = (
+ projectId: string,
+ itemId: string,
+ itemNodeId: string,
+ parentFolderId: string | null,
+ parentNodeId: string | null,
+ targetIndex: number,
+): Promise =>
+ requestWorkspaceTree(`/projects/${projectId}/tree/items/move`, {
+ method: "PATCH",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ itemId,
+ itemNodeId,
+ parentFolderId,
+ parentNodeId,
+ targetIndex,
+ }),
+ });
diff --git a/Frontend/src/components/workspace-navigation/shared/workspaceTree.types.ts b/Frontend/src/components/workspace-navigation/shared/workspaceTree.types.ts
new file mode 100644
index 0000000..08db383
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/shared/workspaceTree.types.ts
@@ -0,0 +1,43 @@
+import type { Accessor, Setter } from "solid-js";
+import type { WorkspaceItemTypeId, WorkspaceTreeNode } from "../../app-shell/data/shell.data";
+
+export type PersistedWorkspaceTreeNodeRecord = {
+ id: string;
+ path: string;
+ label: string;
+ kind: "folder" | "item";
+ itemType?: string;
+ children?: PersistedWorkspaceTreeNodeRecord[];
+};
+
+export type WorkspaceTreeResponse = {
+ data?: {
+ nodes?: PersistedWorkspaceTreeNodeRecord[];
+ };
+ error?: string;
+ message?: string;
+};
+
+export type WorkspaceMutationResponse = {
+ error?: string;
+ message?: string;
+};
+
+export type UseWorkspaceTreeDataOptions = {
+ activeProjectId: Accessor;
+ fallbackWorkspaceTree: Accessor;
+};
+
+export type UseWorkspaceTreeDataResult = {
+ workspaceTreeNodes: Accessor;
+ setWorkspaceTreeNodes: Setter;
+ resolveFolderPath: (folderId: string) => string | null;
+ resolveItemPath: (itemId: string) => string | null;
+ createFolder: (name: string, parentId: string | null) => Promise;
+ renameFolder: (folderId: string, name: string) => Promise;
+ deleteFolder: (folderId: string) => Promise;
+ moveFolder: (folderId: string, parentId: string | null, targetIndex: number) => Promise;
+ createItem: (name: string, itemType: WorkspaceItemTypeId, parentId: string | null) => Promise;
+ deleteItem: (itemId: string) => Promise;
+ moveItem: (itemId: string, parentId: string | null, targetIndex: number) => Promise;
+};
diff --git a/Frontend/src/entry-client.tsx b/Frontend/src/entry-client.tsx
index a725265..fa166d1 100644
--- a/Frontend/src/entry-client.tsx
+++ b/Frontend/src/entry-client.tsx
@@ -3,7 +3,7 @@
// @refresh reload
import { mount, StartClient } from "@solidjs/start/client";
import type { JSX } from "solid-js";
-import { initializeThemeRuntime } from "./theme/runtime";
+import { initializeThemeRuntime } from "./helper/themeRuntime";
const getAppRoot = (): HTMLElement => {
const appRoot = document.getElementById("app");
diff --git a/Frontend/src/entry-server.tsx b/Frontend/src/entry-server.tsx
index 37888e3..0f1d7e8 100644
--- a/Frontend/src/entry-server.tsx
+++ b/Frontend/src/entry-server.tsx
@@ -3,7 +3,7 @@
// @refresh reload
import type { JSX } from "solid-js";
import { createHandler, StartServer } from "@solidjs/start/server";
-import { DEFAULT_THEME, THEME_STORAGE_KEY } from "./theme/runtime";
+import { DEFAULT_THEME, THEME_STORAGE_KEY } from "./helper/themeRuntime";
import { THEME_MODE_NAMES } from "./theme/schema";
const themeBootstrapScript = `
diff --git a/Frontend/src/components/shell/createLongPressGesture.ts b/Frontend/src/helper/createLongPressGesture.ts
similarity index 96%
rename from Frontend/src/components/shell/createLongPressGesture.ts
rename to Frontend/src/helper/createLongPressGesture.ts
index 86d0a70..7073ef0 100644
--- a/Frontend/src/components/shell/createLongPressGesture.ts
+++ b/Frontend/src/helper/createLongPressGesture.ts
@@ -1,3 +1,5 @@
+// Path: Frontend/src/helper/createLongPressGesture.ts
+
import type { JSX } from "solid-js";
type PointerHandler = NonNullable["onPointerDown"]>;
diff --git a/Frontend/src/theme/runtime.ts b/Frontend/src/helper/themeRuntime.ts
similarity index 94%
rename from Frontend/src/theme/runtime.ts
rename to Frontend/src/helper/themeRuntime.ts
index 93db250..4b8f796 100644
--- a/Frontend/src/theme/runtime.ts
+++ b/Frontend/src/helper/themeRuntime.ts
@@ -1,7 +1,13 @@
-// Path: Frontend/src/theme/runtime.ts
+// Path: Frontend/src/helper/themeRuntime.ts
-import { defaultThemePresetMeta, defaultThemePresetPath, resolveThemePresetPath } from "./presets";
-import { createCssVariableMap, isThemeModeName, validateThemeDefinition, type ThemeDefinition, type ThemeModeName } from "./schema";
+import { defaultThemePresetMeta, defaultThemePresetPath, resolveThemePresetPath } from "../theme/presets";
+import {
+ createCssVariableMap,
+ isThemeModeName,
+ validateThemeDefinition,
+ type ThemeDefinition,
+ type ThemeModeName,
+} from "../theme/schema";
export type Theme = ThemeModeName;