From fc5ef23af80d44393b8100ce8359fe53e371f0d0 Mon Sep 17 00:00:00 2001 From: MangoPig Date: Mon, 29 Jun 2026 03:06:18 +0100 Subject: [PATCH 1/2] Refactor: improve code modularity --- Frontend/src/app.tsx | 2 +- .../AppShell/AppShell.module.scss | 0 .../AppShell/AppShell.tsx | 39 +- .../MobileBottomNav.module.scss | 0 .../MobileBottomNav/MobileBottomNav.tsx | 2 +- .../ServerDock/ServerDock.module.scss | 0 .../ServerDock/ServerDock.tsx | 2 +- .../app-shell/data/app-shell.builders.ts | 169 +++ .../app-shell/data/app-shell.context.tsx | 97 ++ .../app-shell/data/app-shell.types.ts | 130 ++ .../app-shell/data/shell.context-menu.ts | 174 +++ .../components/app-shell/data/shell.data.ts | 6 + .../app-shell/data/shell.registry.ts | 100 ++ .../app-shell/data/shell.scaffold.ts | 222 +++ .../components/app-shell/data/shell.types.ts | 233 ++++ .../BootstrapWizard/BootstrapWizard.data.ts} | 2 +- .../BootstrapWizard/BootstrapWizard.hook.ts} | 83 +- .../BootstrapWizard.module.scss} | 2 +- .../BootstrapWizard.parts.tsx} | 6 +- .../BootstrapWizard/BootstrapWizard.tsx | 107 ++ .../BootstrapWizard/BootstrapWizardDialog.tsx | 234 ++++ .../BootstrapWizard/bootstrapWizard.api.ts | 81 ++ .../MobileWorkspaceBrowser.tsx | 638 --------- .../shell/ProjectSelector/ProjectSelector.tsx | 1216 ----------------- .../WorkspaceSidebar/WorkspaceSidebar.tsx | 1017 -------------- .../shell/data/app-shell.context.tsx | 375 ----- .../src/components/shell/data/shell.data.ts | 734 ---------- .../shell/shared/useWorkspaceTreeData.ts | 393 ------ .../DepartmentSelector.module.scss | 0 .../DepartmentSelector/DepartmentSelector.tsx | 4 +- .../TopBar/NotificationsButton.module.scss | 0 .../TopBar/NotificationsButton.tsx | 2 +- .../TopBar/NotificationsMenu.module.scss | 0 .../TopBar/NotificationsMenu.tsx | 2 +- .../TopBar/NotificationsNav.module.scss | 0 .../TopBar/NotificationsNav.tsx | 0 .../TopBar/ProfileMenu.module.scss | 0 .../{shell => top-bar}/TopBar/ProfileMenu.tsx | 4 +- .../TopBar/ThemeToggle.module.scss | 0 .../{shell => top-bar}/TopBar/ThemeToggle.tsx | 4 +- .../TopBar/TopBar.module.scss | 0 .../{shell => top-bar}/TopBar/TopBar.tsx | 6 +- .../TopBar/UserNav.module.scss | 0 .../{shell => top-bar}/TopBar/UserNav.tsx | 0 .../TopBar/UserNavButton.module.scss | 0 .../TopBar/UserNavButton.tsx | 2 +- .../TopBar/createDesktopMenuController.ts | 0 .../workspace-home/WorkspaceHome.module.scss | 25 + .../workspace-home/WorkspaceHome.tsx | 15 + .../WorkspaceHome/WorkspaceHome.tsx | 271 ---- .../LeftRail/LeftRail.module.scss | 0 .../LeftRail/LeftRail.tsx | 6 +- .../MobileWorkspaceBrowser.data.ts | 91 ++ .../MobileWorkspaceBrowser.hook.ts | 273 ++++ .../MobileWorkspaceBrowser.module.scss | 0 .../MobileWorkspaceBrowser.parts.tsx | 140 ++ .../MobileWorkspaceBrowser.tsx | 192 +++ .../ProjectContextMenu/ProjectContextMenu.tsx | 2 +- .../createProjectContextMenuController.ts | 2 +- .../ProjectSelector/ProjectSelector.data.ts | 144 ++ .../ProjectSelector/ProjectSelector.hook.ts | 671 +++++++++ .../ProjectSelector.module.scss | 0 .../ProjectSelector/ProjectSelector.parts.tsx | 213 +++ .../ProjectSelector/ProjectSelector.tsx | 222 +++ .../ProjectSelector/projectFolders.api.ts | 109 ++ .../WorkspaceContextMenu.module.scss | 0 .../WorkspaceContextMenu.tsx | 2 +- .../createWorkspaceContextMenuController.ts | 2 +- .../WorkspaceMobileActionSheet.module.scss | 0 .../WorkspaceMobileActionSheet.tsx | 2 +- .../WorkspaceSidebar/WorkspaceSidebar.data.ts | 57 + .../WorkspaceSidebar/WorkspaceSidebar.hook.ts | 534 ++++++++ .../WorkspaceSidebar.module.scss | 0 .../WorkspaceSidebar.parts.tsx | 287 ++++ .../WorkspaceSidebar/WorkspaceSidebar.tsx | 250 ++++ .../WorkspaceTopBar.module.scss | 81 ++ .../WorkspaceTopBar/WorkspaceTopBar.tsx | 29 + .../shared/_tree-nav.scss | 0 .../shared/navTreeDnd.ts | 0 .../shared/useWorkspaceTreeData.ts | 263 ++++ .../shared/workspaceTree.adapters.ts | 50 + .../shared/workspaceTree.api.ts | 129 ++ .../shared/workspaceTree.types.ts | 43 + Frontend/src/entry-client.tsx | 2 +- Frontend/src/entry-server.tsx | 2 +- .../createLongPressGesture.ts | 2 + .../runtime.ts => helper/themeRuntime.ts} | 12 +- 87 files changed, 5442 insertions(+), 4769 deletions(-) rename Frontend/src/components/{shell => app-shell}/AppShell/AppShell.module.scss (100%) rename Frontend/src/components/{shell => app-shell}/AppShell/AppShell.tsx (81%) rename Frontend/src/components/{shell => app-shell}/MobileBottomNav/MobileBottomNav.module.scss (100%) rename Frontend/src/components/{shell => app-shell}/MobileBottomNav/MobileBottomNav.tsx (95%) rename Frontend/src/components/{shell => app-shell}/ServerDock/ServerDock.module.scss (100%) rename Frontend/src/components/{shell => app-shell}/ServerDock/ServerDock.tsx (95%) create mode 100644 Frontend/src/components/app-shell/data/app-shell.builders.ts create mode 100644 Frontend/src/components/app-shell/data/app-shell.context.tsx create mode 100644 Frontend/src/components/app-shell/data/app-shell.types.ts create mode 100644 Frontend/src/components/app-shell/data/shell.context-menu.ts create mode 100644 Frontend/src/components/app-shell/data/shell.data.ts create mode 100644 Frontend/src/components/app-shell/data/shell.registry.ts create mode 100644 Frontend/src/components/app-shell/data/shell.scaffold.ts create mode 100644 Frontend/src/components/app-shell/data/shell.types.ts rename Frontend/src/components/{workspace-home/WorkspaceHome/WorkspaceHome.data.ts => bootstrap/BootstrapWizard/BootstrapWizard.data.ts} (98%) rename Frontend/src/components/{workspace-home/WorkspaceHome/WorkspaceHome.hook.ts => bootstrap/BootstrapWizard/BootstrapWizard.hook.ts} (87%) rename Frontend/src/components/{workspace-home/WorkspaceHome/WorkspaceHome.module.scss => bootstrap/BootstrapWizard/BootstrapWizard.module.scss} (99%) rename Frontend/src/components/{workspace-home/WorkspaceHome/WorkspaceHome.parts.tsx => bootstrap/BootstrapWizard/BootstrapWizard.parts.tsx} (98%) create mode 100644 Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.tsx create mode 100644 Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizardDialog.tsx create mode 100644 Frontend/src/components/bootstrap/BootstrapWizard/bootstrapWizard.api.ts delete mode 100644 Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx delete mode 100644 Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx delete mode 100644 Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx delete mode 100644 Frontend/src/components/shell/data/app-shell.context.tsx delete mode 100644 Frontend/src/components/shell/data/shell.data.ts delete mode 100644 Frontend/src/components/shell/shared/useWorkspaceTreeData.ts rename Frontend/src/components/{shell => top-bar}/DepartmentSelector/DepartmentSelector.module.scss (100%) rename Frontend/src/components/{shell => top-bar}/DepartmentSelector/DepartmentSelector.tsx (96%) rename Frontend/src/components/{shell => top-bar}/TopBar/NotificationsButton.module.scss (100%) rename Frontend/src/components/{shell => top-bar}/TopBar/NotificationsButton.tsx (93%) rename Frontend/src/components/{shell => top-bar}/TopBar/NotificationsMenu.module.scss (100%) rename Frontend/src/components/{shell => top-bar}/TopBar/NotificationsMenu.tsx (98%) rename Frontend/src/components/{shell => top-bar}/TopBar/NotificationsNav.module.scss (100%) rename Frontend/src/components/{shell => top-bar}/TopBar/NotificationsNav.tsx (100%) rename Frontend/src/components/{shell => top-bar}/TopBar/ProfileMenu.module.scss (100%) rename Frontend/src/components/{shell => top-bar}/TopBar/ProfileMenu.tsx (94%) rename Frontend/src/components/{shell => top-bar}/TopBar/ThemeToggle.module.scss (100%) rename Frontend/src/components/{shell => top-bar}/TopBar/ThemeToggle.tsx (87%) rename Frontend/src/components/{shell => top-bar}/TopBar/TopBar.module.scss (100%) rename Frontend/src/components/{shell => top-bar}/TopBar/TopBar.tsx (90%) rename Frontend/src/components/{shell => top-bar}/TopBar/UserNav.module.scss (100%) rename Frontend/src/components/{shell => top-bar}/TopBar/UserNav.tsx (100%) rename Frontend/src/components/{shell => top-bar}/TopBar/UserNavButton.module.scss (100%) rename Frontend/src/components/{shell => top-bar}/TopBar/UserNavButton.tsx (93%) rename Frontend/src/components/{shell => top-bar}/TopBar/createDesktopMenuController.ts (100%) create mode 100644 Frontend/src/components/workspace-home/WorkspaceHome.module.scss create mode 100644 Frontend/src/components/workspace-home/WorkspaceHome.tsx delete mode 100644 Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx rename Frontend/src/components/{shell => workspace-navigation}/LeftRail/LeftRail.module.scss (100%) rename Frontend/src/components/{shell => workspace-navigation}/LeftRail/LeftRail.tsx (90%) create mode 100644 Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.data.ts create mode 100644 Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.hook.ts rename Frontend/src/components/{shell => workspace-navigation}/MobileWorkspaceBrowser/MobileWorkspaceBrowser.module.scss (100%) create mode 100644 Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.parts.tsx create mode 100644 Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx rename Frontend/src/components/{shell => workspace-navigation}/ProjectContextMenu/ProjectContextMenu.tsx (99%) rename Frontend/src/components/{shell => workspace-navigation}/ProjectContextMenu/createProjectContextMenuController.ts (97%) create mode 100644 Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.data.ts create mode 100644 Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.hook.ts rename Frontend/src/components/{shell => workspace-navigation}/ProjectSelector/ProjectSelector.module.scss (100%) create mode 100644 Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.parts.tsx create mode 100644 Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.tsx create mode 100644 Frontend/src/components/workspace-navigation/ProjectSelector/projectFolders.api.ts rename Frontend/src/components/{shell => workspace-navigation}/WorkspaceContextMenu/WorkspaceContextMenu.module.scss (100%) rename Frontend/src/components/{shell => workspace-navigation}/WorkspaceContextMenu/WorkspaceContextMenu.tsx (99%) rename Frontend/src/components/{shell => workspace-navigation}/WorkspaceContextMenu/createWorkspaceContextMenuController.ts (97%) rename Frontend/src/components/{shell => workspace-navigation}/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.module.scss (100%) rename Frontend/src/components/{shell => workspace-navigation}/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx (99%) create mode 100644 Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.data.ts create mode 100644 Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.hook.ts rename Frontend/src/components/{shell => workspace-navigation}/WorkspaceSidebar/WorkspaceSidebar.module.scss (100%) create mode 100644 Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.parts.tsx create mode 100644 Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.tsx create mode 100644 Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.module.scss create mode 100644 Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.tsx rename Frontend/src/components/{shell => workspace-navigation}/shared/_tree-nav.scss (100%) rename Frontend/src/components/{shell => workspace-navigation}/shared/navTreeDnd.ts (100%) create mode 100644 Frontend/src/components/workspace-navigation/shared/useWorkspaceTreeData.ts create mode 100644 Frontend/src/components/workspace-navigation/shared/workspaceTree.adapters.ts create mode 100644 Frontend/src/components/workspace-navigation/shared/workspaceTree.api.ts create mode 100644 Frontend/src/components/workspace-navigation/shared/workspaceTree.types.ts rename Frontend/src/{components/shell => helper}/createLongPressGesture.ts (96%) rename Frontend/src/{theme/runtime.ts => helper/themeRuntime.ts} (94%) 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 => ( + + +
+ + + +); 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 ( - -
    -
    -
    -
    { - event.preventDefault(); - openWorkspaceActionSheet(); - }} - {...workspaceLongPress} - > - {/* Long-pressing the browser header exposes workspace-level actions on mobile. */} - Moku Work - {appShellData.activeProject().name} - {appShellData.activeServer().name} -
    - -
    - - - -
    -
    - -
    -
    - Workspace -
      - - {(item): JSX.Element => } - -
    -
    - -
    - Items -
      - -
    -
    -
    -
    - - - - - {(state): JSX.Element => ( - -
    - - )} - -
    -
    - -
    - -
    - -
    -
    - )} -
    - - - {(state): JSX.Element => ( - -
    - - -
    - -
    - - )} - - - - ); -}; 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 => ( - -); - -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 ( - - - - ); - }} - - - ); -}; 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 ( - - - - ); - }} - - - ); -}; 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"}

    - -
    - -
    -
    -
    -
    - - - -
    - - - - - ); -}; 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 ( + +
    +
    +
    +
    { + event.preventDefault(); + openWorkspaceActionSheet(); + }} + {...workspaceLongPress} + > + {/* Long-pressing the browser header exposes workspace-level actions on mobile. */} + Moku Work + {appShellData.activeProject().name} + {appShellData.activeServer().name} +
    + +
    + + + +
    +
    + +
    +
    + Workspace +
      + + {(item): JSX.Element => } + +
    +
    + +
    + Items +
      + +
    +
    +
    +
    + + + + + {(state): JSX.Element => ( + +
    + + )} + +
    +
    + +
    + +
    + +
    +
    + )} +
    + + + {(state): JSX.Element => ( + +
    + + +
    + + +
    + )} +
    + + + ); +}; 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 ( + + + + ); + }} + + + ); +}; 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 ( + + + + ); + }} + + + ); +}; 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; From 95fb8629d1d267c2754997f79d69772a280c3bde Mon Sep 17 00:00:00 2001 From: MangoPig Date: Mon, 29 Jun 2026 04:29:05 +0100 Subject: [PATCH 2/2] Refactor: improve backend modularity --- .../internal/bootstrap/bootstrap_helpers.go | 81 + Backend/internal/bootstrap/bootstrap_types.go | 309 ++ Backend/internal/bootstrap/project_disk.go | 48 + .../bootstrap/project_disk_bootstrap.go | 196 ++ .../bootstrap/project_disk_folders.go | 252 ++ .../bootstrap/project_disk_helpers.go | 138 + .../internal/bootstrap/project_disk_items.go | 189 ++ .../internal/bootstrap/project_mutations.go | 491 +++ Backend/internal/bootstrap/project_order.go | 329 ++ Backend/internal/bootstrap/project_queries.go | 347 +++ Backend/internal/bootstrap/service.go | 2712 +---------------- Backend/internal/httpx/api_project_decode.go | 127 + Backend/internal/httpx/api_project_errors.go | 42 + Backend/internal/httpx/api_project_routes.go | 211 +- Backend/internal/jobs/store.go | 2 + Backend/internal/posixproj/projector.go | 459 +-- .../internal/posixproj/projector_classify.go | 175 ++ Backend/internal/posixproj/projector_scan.go | 189 ++ Backend/internal/posixproj/projector_test.go | 2 + Backend/internal/posixproj/projector_types.go | 58 + Backend/internal/worker/runner.go | 2 + Backend/internal/worker/runner_test.go | 2 + 22 files changed, 2989 insertions(+), 3372 deletions(-) create mode 100644 Backend/internal/bootstrap/bootstrap_helpers.go create mode 100644 Backend/internal/bootstrap/bootstrap_types.go create mode 100644 Backend/internal/bootstrap/project_disk.go create mode 100644 Backend/internal/bootstrap/project_disk_bootstrap.go create mode 100644 Backend/internal/bootstrap/project_disk_folders.go create mode 100644 Backend/internal/bootstrap/project_disk_helpers.go create mode 100644 Backend/internal/bootstrap/project_disk_items.go create mode 100644 Backend/internal/bootstrap/project_mutations.go create mode 100644 Backend/internal/bootstrap/project_order.go create mode 100644 Backend/internal/bootstrap/project_queries.go create mode 100644 Backend/internal/httpx/api_project_decode.go create mode 100644 Backend/internal/httpx/api_project_errors.go create mode 100644 Backend/internal/posixproj/projector_classify.go create mode 100644 Backend/internal/posixproj/projector_scan.go create mode 100644 Backend/internal/posixproj/projector_types.go diff --git a/Backend/internal/bootstrap/bootstrap_helpers.go b/Backend/internal/bootstrap/bootstrap_helpers.go new file mode 100644 index 0000000..dddf4f9 --- /dev/null +++ b/Backend/internal/bootstrap/bootstrap_helpers.go @@ -0,0 +1,81 @@ +// Path: Backend/internal/bootstrap/bootstrap_helpers.go + +package bootstrap + +import ( + "context" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + + "moku-backend/internal/database" +) + +func NewService(db *database.DB, posixRoot string) *Service { + return &Service{db: db, posixRoot: strings.TrimSpace(posixRoot)} +} + +func upsertNamedRecord(ctx context.Context, tx pgx.Tx, query string, args ...any) (namedRecord, error) { + var record namedRecord + if err := tx.QueryRow(ctx, query, args...).Scan(&record.ID, &record.Name, &record.Slug); err != nil { + return namedRecord{}, err + } + + return record, nil +} + +func upsertWorkspace(ctx context.Context, tx pgx.Tx, organizationID, name, slug, kind, createdByUserID string, departmentID, teamID, projectID *string) error { + _, err := tx.Exec(ctx, ` + INSERT INTO workspaces (organization_id, name, slug, kind, created_by_user_id, department_id, team_id, project_id) + VALUES ($1::uuid, $2, $3, $4::workspace_kind, $5::uuid, $6::uuid, $7::uuid, $8::uuid) + ON CONFLICT (organization_id, slug) DO UPDATE + SET + name = EXCLUDED.name, + kind = EXCLUDED.kind, + created_by_user_id = EXCLUDED.created_by_user_id, + department_id = EXCLUDED.department_id, + team_id = EXCLUDED.team_id, + project_id = EXCLUDED.project_id, + updated_at = NOW(); + `, organizationID, name, slug, kind, createdByUserID, departmentID, teamID, projectID) + + return err +} + +func defaultRootOrganizationName(installationName, mode, host, adminDisplayName string) string { + trimmedInstallationName := strings.TrimSpace(installationName) + trimmedHost := strings.TrimSpace(host) + trimmedAdminDisplayName := strings.TrimSpace(adminDisplayName) + + if trimmedInstallationName != "" { + return trimmedInstallationName + } + + if strings.EqualFold(mode, defaultInstallationMode) { + if trimmedAdminDisplayName != "" { + return fmt.Sprintf("%s %s", trimmedAdminDisplayName, defaultPersonalServerSuffix) + } + + return defaultPersonalDisplayName + } + + if trimmedHost != "" { + return trimmedHost + } + + return defaultOrganizationName +} + +func personalHomeTitle(displayName string) string { + trimmedDisplayName := strings.TrimSpace(displayName) + if trimmedDisplayName == "" { + return "Home" + } + + if strings.HasSuffix(strings.ToLower(trimmedDisplayName), "s") { + return fmt.Sprintf("%s' Home", trimmedDisplayName) + } + + return fmt.Sprintf("%s's Home", trimmedDisplayName) +} diff --git a/Backend/internal/bootstrap/bootstrap_types.go b/Backend/internal/bootstrap/bootstrap_types.go new file mode 100644 index 0000000..fea221c --- /dev/null +++ b/Backend/internal/bootstrap/bootstrap_types.go @@ -0,0 +1,309 @@ +// Path: Backend/internal/bootstrap/bootstrap_types.go + +package bootstrap + +import ( + "errors" + + "moku-backend/internal/database" +) + +const ( + primaryOrganizationSlug = "primary-organization" + primaryDepartmentSlug = "primary-department" + primaryTeamSlug = "primary-team" + primaryProjectSlug = "primary-project" + organizationWorkspaceSlug = "organization-home" + departmentWorkspaceSlug = "department-home" + teamWorkspaceSlug = "team-home" + projectWorkspaceSlug = "project-home" + defaultInstallationHost = "localhost" + defaultInstallationMode = "personal" + defaultInstallationAccess = "local" + defaultInstallationProtocol = "http" + defaultOrganizationName = "Moku" + defaultPersonalServerSuffix = "Personal" + defaultPersonalDisplayName = "Personal" + bootstrapWorkspaceKindOrg = "organization" + bootstrapWorkspaceKindDept = "department" + bootstrapWorkspaceKindTeam = "team" + bootstrapWorkspaceKindProject = "project" + projectFolderOrderRootKey = "__root__" + projectFolderOrderHierarchy = "hierarchy" + projectFolderOrderTree = "tree" +) + +var ( + ErrInstallationNotConfigured = errors.New("bootstrap installation step has not been completed") + ErrAdminNotConfigured = errors.New("bootstrap admin step has not been completed") + ErrBootstrapStructureMissing = errors.New("bootstrap structure is incomplete") + ErrProjectNotFound = errors.New("project not found") + ErrProjectFolderNotFound = errors.New("project folder not found") + ErrProjectItemNotFound = errors.New("project item not found") + ErrInvalidProjectFolderMove = errors.New("invalid project folder move") + ErrInvalidProjectItemMove = errors.New("invalid project item move") +) + +type Service struct { + db *database.DB + posixRoot string +} + +type SaveInstanceInput struct { + Protocol string + Access string + Host string +} + +type SaveModeInput struct { + Mode string + Name string +} + +type SaveAdminInput struct { + DisplayName string + Email string + Password string +} + +type SaveStructureInput struct { + OrganizationName string + DepartmentName string + TeamName string + ProjectName string +} + +type InstallationRecord struct { + ID string `json:"id"` + Name string `json:"name"` + Mode string `json:"mode"` + Access string `json:"access"` + Protocol string `json:"protocol"` + Host string `json:"host"` + IsBootstrapped bool `json:"isBootstrapped"` + MaterializationStatus string `json:"materializationStatus"` + MaterializationError *string `json:"materializationError,omitempty"` +} + +type AdminRecord struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + IsInstanceAdmin bool `json:"isInstanceAdmin"` + HomeTitle string `json:"homeTitle"` +} + +type OrganizationRecord struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +type DepartmentRecord struct { + ID string `json:"id"` + OrganizationID string `json:"organizationId"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +type TeamRecord struct { + ID string `json:"id"` + OrganizationID string `json:"organizationId"` + DepartmentID *string `json:"departmentId,omitempty"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +type ProjectRecord struct { + ID string `json:"id"` + OrganizationID string `json:"organizationId"` + DepartmentID *string `json:"departmentId,omitempty"` + TeamID *string `json:"teamId,omitempty"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +type WorkspaceRecord struct { + ID string `json:"id"` + OrganizationID string `json:"organizationId"` + Name string `json:"name"` + Slug string `json:"slug"` + Kind string `json:"kind"` + DepartmentID *string `json:"departmentId,omitempty"` + TeamID *string `json:"teamId,omitempty"` + ProjectID *string `json:"projectId,omitempty"` +} + +type StructureRecord struct { + Installation InstallationRecord `json:"installation"` + Organization namedRecord `json:"organization"` + Department namedRecord `json:"department"` + Team namedRecord `json:"team"` + Project namedRecord `json:"project"` + Admin AdminSummary `json:"admin"` +} + +type AdminSummary struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"displayName"` +} + +type BootstrapStructureState struct { + Organization *OrganizationRecord `json:"organization,omitempty"` + Department *DepartmentRecord `json:"department,omitempty"` + Team *TeamRecord `json:"team,omitempty"` + Project *ProjectRecord `json:"project,omitempty"` + Workspaces []WorkspaceRecord `json:"workspaces"` +} + +type BootstrapState struct { + Installation *InstallationRecord `json:"installation,omitempty"` + Admin *AdminRecord `json:"admin,omitempty"` + Structure BootstrapStructureState `json:"structure"` +} + +type AppShellState struct { + Installation *InstallationRecord `json:"installation,omitempty"` + Admin *AdminRecord `json:"admin,omitempty"` + Organizations []OrganizationRecord `json:"organizations"` + Departments []DepartmentRecord `json:"departments"` + Teams []TeamRecord `json:"teams"` + Projects []ProjectRecord `json:"projects"` + Workspaces []WorkspaceRecord `json:"workspaces"` +} + +type namedRecord struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +type ProjectHierarchyFolderRecord struct { + ID string `json:"id"` + Path string `json:"path"` + Label string `json:"label"` + Children []ProjectHierarchyFolderRecord `json:"children"` +} + +type ProjectTreeNodeRecord struct { + ID string `json:"id"` + Path string `json:"path"` + Label string `json:"label"` + Kind string `json:"kind"` + ItemType string `json:"itemType,omitempty"` + Children []ProjectTreeNodeRecord `json:"children,omitempty"` +} + +type CreateProjectFolderInput struct { + ProjectID string + ParentFolderPath string + Name string +} + +type DeleteProjectFolderInput struct { + ProjectID string + FolderPath string +} + +type RenameProjectFolderInput struct { + ProjectID string + FolderPath string + Name string +} + +type MoveProjectFolderInput struct { + ProjectID string + FolderPath string + FolderStableID string + ParentFolderPath string + ParentStableID string + TargetIndex int +} + +type CreateProjectItemInput struct { + ProjectID string + ParentFolderPath string + Name string + ItemType string +} + +type DeleteProjectItemInput struct { + ProjectID string + ItemPath string +} + +type MoveProjectItemInput struct { + ProjectID string + ItemPath string + ItemStableID string + ParentFolderPath string + ParentStableID string + TargetIndex int +} + +type CreateProjectFolderResult struct { + ProjectID string `json:"projectId"` + CreatedFolder ProjectHierarchyFolderRecord `json:"createdFolder"` + Folders []ProjectHierarchyFolderRecord `json:"folders"` +} + +type DeleteProjectFolderResult struct { + ProjectID string `json:"projectId"` + DeletedFolderStableID string `json:"deletedFolderId"` + DeletedFolderPath string `json:"deletedFolderPath"` + Folders []ProjectHierarchyFolderRecord `json:"folders"` +} + +type RenameProjectFolderResult struct { + ProjectID string `json:"projectId"` + PreviousFolderStableID string `json:"previousFolderId"` + PreviousFolderPath string `json:"previousFolderPath"` + RenamedFolder ProjectHierarchyFolderRecord `json:"renamedFolder"` + Folders []ProjectHierarchyFolderRecord `json:"folders"` +} + +type MoveProjectFolderResult struct { + ProjectID string `json:"projectId"` + PreviousFolderStableID string `json:"previousFolderId"` + PreviousFolderPath string `json:"previousFolderPath"` + MovedFolder ProjectHierarchyFolderRecord `json:"movedFolder"` + Folders []ProjectHierarchyFolderRecord `json:"folders"` +} + +type CreateProjectItemResult struct { + ProjectID string `json:"projectId"` + CreatedItem ProjectTreeNodeRecord `json:"createdItem"` + Nodes []ProjectTreeNodeRecord `json:"nodes"` +} + +type DeleteProjectItemResult struct { + ProjectID string `json:"projectId"` + DeletedItemStableID string `json:"deletedItemId"` + DeletedItemPath string `json:"deletedItemPath"` + Nodes []ProjectTreeNodeRecord `json:"nodes"` +} + +type MoveProjectItemResult struct { + ProjectID string `json:"projectId"` + PreviousItemStableID string `json:"previousItemId"` + PreviousItemPath string `json:"previousItemPath"` + MovedItem ProjectTreeNodeRecord `json:"movedItem"` + Nodes []ProjectTreeNodeRecord `json:"nodes"` +} + +type projectHierarchyFolderRow struct { + ID string + Path string + ParentPath string + Label string +} + +type projectTreeNodeRow struct { + ID string + Path string + ParentPath string + Label string + Kind string + ItemType string +} diff --git a/Backend/internal/bootstrap/project_disk.go b/Backend/internal/bootstrap/project_disk.go new file mode 100644 index 0000000..60fedbf --- /dev/null +++ b/Backend/internal/bootstrap/project_disk.go @@ -0,0 +1,48 @@ +// Path: Backend/internal/bootstrap/project_disk.go + +package bootstrap + +import ( + "path/filepath" +) + +func (service *Service) createProjectHierarchyFolderOnDisk(projectSlug, parentFolderID, name string) (string, string, error) { + return service.createProjectFolderOnDisk(projectSlug, parentFolderID, name, projectHierarchyRootPath) +} + +func (service *Service) createProjectTreeFolderOnDisk(projectSlug, parentFolderID, name string) (string, string, error) { + return service.createProjectFolderOnDisk(projectSlug, parentFolderID, name, projectTreeRootPath) +} + +func (service *Service) deleteProjectHierarchyFolderOnDisk(projectSlug, folderID string) (string, error) { + return service.deleteProjectFolderOnDisk(projectSlug, folderID, projectHierarchyRootPath) +} + +func (service *Service) deleteProjectTreeFolderOnDisk(projectSlug, folderID string) (string, error) { + return service.deleteProjectFolderOnDisk(projectSlug, folderID, projectTreeRootPath) +} + +func (service *Service) renameProjectHierarchyFolderOnDisk(projectSlug, folderID, name string) (string, string, error) { + return service.renameProjectFolderOnDisk(projectSlug, folderID, name, projectHierarchyRootPath) +} + +func (service *Service) renameProjectTreeFolderOnDisk(projectSlug, folderID, name string) (string, string, error) { + return service.renameProjectFolderOnDisk(projectSlug, folderID, name, projectTreeRootPath) +} + +func (service *Service) moveProjectHierarchyFolderOnDisk(projectSlug, folderID, parentFolderID string) (string, string, error) { + return service.moveProjectFolderOnDisk(projectSlug, folderID, parentFolderID, projectHierarchyRootPath) +} + +func (service *Service) moveProjectTreeFolderOnDisk(projectSlug, folderID, parentFolderID string) (string, string, error) { + return service.moveProjectFolderOnDisk(projectSlug, folderID, parentFolderID, projectTreeRootPath) +} + + +func projectHierarchyRootPath(projectSlug string) string { + return filepath.ToSlash(filepath.Join("projects", slugDir("project", projectSlug), "children")) +} + +func projectTreeRootPath(projectSlug string) string { + return filepath.ToSlash(filepath.Join("projects", slugDir("project", projectSlug), "tree")) +} diff --git a/Backend/internal/bootstrap/project_disk_bootstrap.go b/Backend/internal/bootstrap/project_disk_bootstrap.go new file mode 100644 index 0000000..716bcf4 --- /dev/null +++ b/Backend/internal/bootstrap/project_disk_bootstrap.go @@ -0,0 +1,196 @@ +// Path: Backend/internal/bootstrap/project_disk_bootstrap.go + +package bootstrap + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func (service *Service) ensureBootstrapPOSIXSkeleton( + installation InstallationRecord, + admin AdminSummary, + organization namedRecord, + department namedRecord, + team namedRecord, + project namedRecord, +) error { + rootPath := strings.TrimSpace(service.posixRoot) + if rootPath == "" { + return nil + } + + if err := os.MkdirAll(rootPath, 0o755); err != nil { + return fmt.Errorf("create POSIX root: %w", err) + } + + if err := writeJSONFile(filepath.Join(rootPath, "settings.json"), map[string]any{ + "installation": map[string]any{ + "id": installation.ID, + "name": installation.Name, + "mode": installation.Mode, + "access": installation.Access, + "protocol": installation.Protocol, + "host": installation.Host, + "isBootstrapped": installation.IsBootstrapped, + }, + "organization": map[string]any{ + "id": organization.ID, + "name": organization.Name, + "slug": organization.Slug, + }, + }); err != nil { + return fmt.Errorf("write tenant settings.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(rootPath, "layout.json"), map[string]any{ + "version": 1, + "type": "tenant-layout", + "home": map[string]any{ + "defaultProjectSlug": project.Slug, + }, + }); err != nil { + return fmt.Errorf("write tenant layout.json: %w", err) + } + + if err := os.MkdirAll(filepath.Join(rootPath, "catalog", "packs"), 0o755); err != nil { + return fmt.Errorf("create catalog packs root: %w", err) + } + + if err := os.MkdirAll(filepath.Join(rootPath, "catalog", "standalone"), 0o755); err != nil { + return fmt.Errorf("create catalog standalone root: %w", err) + } + + departmentPath := filepath.Join(rootPath, "departments", slugDir("department", department.Slug)) + teamPath := filepath.Join(departmentPath, "teams", slugDir("team", team.Slug)) + projectPath := filepath.Join(rootPath, "projects", slugDir("project", project.Slug)) + usersPath := filepath.Join(rootPath, "users") + personalName := strings.TrimSpace(admin.DisplayName) + if personalName == "" { + personalName = defaultPersonalDisplayName + } + personalSlug := normalizePOSIXSlug(personalName) + personalHomePath := filepath.Join(usersPath, "personals", slugDir("personal", personalSlug)) + + for _, dirPath := range []string{ + departmentPath, + teamPath, + projectPath, + filepath.Join(projectPath, "children"), + filepath.Join(projectPath, "tree"), + filepath.Join(usersPath, "personals"), + personalHomePath, + filepath.Join(personalHomePath, "tree"), + } { + if err := os.MkdirAll(dirPath, 0o755); err != nil { + return fmt.Errorf("create POSIX directory %s: %w", dirPath, err) + } + } + + if err := writeJSONFile(filepath.Join(departmentPath, "settings.json"), map[string]any{ + "id": department.ID, + "name": department.Name, + "slug": department.Slug, + "type": "department", + }); err != nil { + return fmt.Errorf("write department settings.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(departmentPath, "users.json"), map[string]any{ + "owners": []map[string]string{{ + "id": admin.ID, + "email": admin.Email, + "displayName": admin.DisplayName, + }}, + }); err != nil { + return fmt.Errorf("write department users.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(teamPath, "settings.json"), map[string]any{ + "id": team.ID, + "name": team.Name, + "slug": team.Slug, + "type": "team", + }); err != nil { + return fmt.Errorf("write team settings.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(teamPath, "users.json"), map[string]any{ + "owners": []map[string]string{{ + "id": admin.ID, + "email": admin.Email, + "displayName": admin.DisplayName, + }}, + }); err != nil { + return fmt.Errorf("write team users.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(projectPath, "settings.json"), map[string]any{ + "id": project.ID, + "name": project.Name, + "slug": project.Slug, + "type": "project", + }); err != nil { + return fmt.Errorf("write project settings.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(projectPath, "home.json"), map[string]any{ + "type": "project-home", + "title": project.Name, + }); err != nil { + return fmt.Errorf("write project home.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(projectPath, "acl.json"), map[string]any{ + "version": 1, + "inherits": true, + "rules": []any{}, + }); err != nil { + return fmt.Errorf("write project acl.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(usersPath, "settings.json"), map[string]any{ + "primaryAdminId": admin.ID, + }); err != nil { + return fmt.Errorf("write users settings.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(usersPath, "data.json"), map[string]any{ + "admins": []map[string]string{{ + "id": admin.ID, + "email": admin.Email, + "displayName": admin.DisplayName, + }}, + }); err != nil { + return fmt.Errorf("write users data.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(personalHomePath, "settings.json"), map[string]any{ + "type": "personal", + "name": personalName, + "slug": personalSlug, + }); err != nil { + return fmt.Errorf("write personal settings.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(personalHomePath, "layout.json"), map[string]any{ + "version": 1, + "type": "personal-layout", + "home": map[string]any{ + "defaultProjectSlug": project.Slug, + }, + }); err != nil { + return fmt.Errorf("write personal layout.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(personalHomePath, "home.json"), map[string]any{ + "type": "personal-home", + "title": personalHomeTitle(personalName), + }); err != nil { + return fmt.Errorf("write personal home.json: %w", err) + } + + return nil +} diff --git a/Backend/internal/bootstrap/project_disk_folders.go b/Backend/internal/bootstrap/project_disk_folders.go new file mode 100644 index 0000000..4059051 --- /dev/null +++ b/Backend/internal/bootstrap/project_disk_folders.go @@ -0,0 +1,252 @@ +// Path: Backend/internal/bootstrap/project_disk_folders.go + +package bootstrap + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +func (service *Service) createProjectFolderOnDisk(projectSlug, parentFolderID, name string, rootPathBuilder func(projectSlug string) string) (string, string, error) { + posixRoot := strings.TrimSpace(service.posixRoot) + if posixRoot == "" { + return "", "", fmt.Errorf("POSIX root is not configured") + } + trimmedName := strings.TrimSpace(name) + if trimmedName == "" { + return "", "", fmt.Errorf("folder name is required") + } + containerProjectionPath := rootPathBuilder(projectSlug) + parentDir := filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath)) + if strings.TrimSpace(parentFolderID) != "" { + containerProjectionPath = filepath.ToSlash(filepath.Join(strings.TrimSpace(parentFolderID), "children")) + parentDir = filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath)) + info, err := os.Stat(parentDir) + if err != nil { + if os.IsNotExist(err) { + return "", "", ErrProjectFolderNotFound + } + return "", "", fmt.Errorf("stat parent project folder: %w", err) + } + if !info.IsDir() { + return "", "", ErrProjectFolderNotFound + } + } + if err := os.MkdirAll(parentDir, 0o755); err != nil { + return "", "", fmt.Errorf("create parent project folder path: %w", err) + } + baseSlug := normalizePOSIXSlug(trimmedName) + folderName := slugDir("folder", baseSlug) + folderDir := filepath.Join(parentDir, folderName) + folderSlug := baseSlug + for attempt := 2; ; attempt += 1 { + if _, err := os.Stat(folderDir); os.IsNotExist(err) { + break + } else if err != nil { + return "", "", fmt.Errorf("stat candidate project folder: %w", err) + } + folderSlug = fmt.Sprintf("%s-%d", baseSlug, attempt) + folderName = slugDir("folder", folderSlug) + folderDir = filepath.Join(parentDir, folderName) + } + if err := os.MkdirAll(filepath.Join(folderDir, "children"), 0o755); err != nil { + return "", "", fmt.Errorf("create project hierarchy folder: %w", err) + } + folderID := uuid.NewString() + if err := writeJSONFile(filepath.Join(folderDir, "folder.json"), map[string]any{"id": folderID, "name": trimmedName, "slug": folderSlug, "type": "folder"}); err != nil { + return "", "", fmt.Errorf("write project folder.json: %w", err) + } + if err := writeJSONFile(filepath.Join(folderDir, "acl.json"), map[string]any{"version": 1, "inherits": true, "rules": []any{}}); err != nil { + return "", "", fmt.Errorf("write project acl.json: %w", err) + } + return filepath.ToSlash(filepath.Join(containerProjectionPath, folderName)), folderSlug, nil +} + +func (service *Service) deleteProjectFolderOnDisk(projectSlug, folderID string, rootPathBuilder func(projectSlug string) string) (string, error) { + posixRoot := strings.TrimSpace(service.posixRoot) + if posixRoot == "" { + return "", fmt.Errorf("POSIX root is not configured") + } + trimmedFolderID := strings.TrimSpace(folderID) + if trimmedFolderID == "" { + return "", ErrProjectFolderNotFound + } + rootProjectionPath := rootPathBuilder(projectSlug) + folderProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedFolderID)), "/") + if folderProjectionPath == "." || folderProjectionPath == rootProjectionPath || !strings.HasPrefix(folderProjectionPath, rootProjectionPath+"/") { + return "", ErrProjectFolderNotFound + } + folderDir := filepath.Join(posixRoot, filepath.FromSlash(folderProjectionPath)) + info, err := os.Stat(folderDir) + if err != nil { + if os.IsNotExist(err) { + return "", ErrProjectFolderNotFound + } + return "", fmt.Errorf("stat project folder: %w", err) + } + if !info.IsDir() { + return "", ErrProjectFolderNotFound + } + if err := os.RemoveAll(folderDir); err != nil { + return "", fmt.Errorf("delete project folder: %w", err) + } + return folderProjectionPath, nil +} + +func (service *Service) renameProjectFolderOnDisk(projectSlug, folderID, name string, rootPathBuilder func(projectSlug string) string) (string, string, error) { + posixRoot := strings.TrimSpace(service.posixRoot) + if posixRoot == "" { + return "", "", fmt.Errorf("POSIX root is not configured") + } + trimmedFolderID := strings.TrimSpace(folderID) + if trimmedFolderID == "" { + return "", "", ErrProjectFolderNotFound + } + trimmedName := strings.TrimSpace(name) + if trimmedName == "" { + return "", "", fmt.Errorf("folder name is required") + } + rootProjectionPath := rootPathBuilder(projectSlug) + folderProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedFolderID)), "/") + if folderProjectionPath == "." || folderProjectionPath == rootProjectionPath || !strings.HasPrefix(folderProjectionPath, rootProjectionPath+"/") { + return "", "", ErrProjectFolderNotFound + } + folderDir := filepath.Join(posixRoot, filepath.FromSlash(folderProjectionPath)) + info, err := os.Stat(folderDir) + if err != nil { + if os.IsNotExist(err) { + return "", "", ErrProjectFolderNotFound + } + return "", "", fmt.Errorf("stat project folder: %w", err) + } + if !info.IsDir() { + return "", "", ErrProjectFolderNotFound + } + parentDir := filepath.Dir(folderDir) + baseSlug := normalizePOSIXSlug(trimmedName) + folderName := slugDir("folder", baseSlug) + folderSlug := baseSlug + destinationDir := filepath.Join(parentDir, folderName) + for attempt := 2; ; attempt += 1 { + if destinationDir == folderDir { + break + } + if _, err := os.Stat(destinationDir); os.IsNotExist(err) { + break + } else if err != nil { + return "", "", fmt.Errorf("stat candidate renamed project folder: %w", err) + } + folderSlug = fmt.Sprintf("%s-%d", baseSlug, attempt) + folderName = slugDir("folder", folderSlug) + destinationDir = filepath.Join(parentDir, folderName) + } + renamedProjectionPath := filepath.ToSlash(filepath.Join(filepath.Dir(folderProjectionPath), folderName)) + if destinationDir != folderDir { + if err := os.Rename(folderDir, destinationDir); err != nil { + return "", "", fmt.Errorf("rename project folder: %w", err) + } + } + folderPayload := readJSONFileMap(filepath.Join(destinationDir, "folder.json")) + folderMetadataID, _ := folderPayload["id"].(string) + if strings.TrimSpace(folderMetadataID) == "" { + folderMetadataID = uuid.NewString() + } + if err := writeJSONFile(filepath.Join(destinationDir, "folder.json"), map[string]any{"id": folderMetadataID, "name": trimmedName, "slug": folderSlug, "type": "folder"}); err != nil { + return "", "", fmt.Errorf("write renamed project folder.json: %w", err) + } + return folderProjectionPath, renamedProjectionPath, nil +} + +func (service *Service) moveProjectFolderOnDisk(projectSlug, folderID, parentFolderID string, rootPathBuilder func(projectSlug string) string) (string, string, error) { + posixRoot := strings.TrimSpace(service.posixRoot) + if posixRoot == "" { + return "", "", fmt.Errorf("POSIX root is not configured") + } + rootProjectionPath := rootPathBuilder(projectSlug) + folderProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(folderID))), "/") + if folderProjectionPath == "." || folderProjectionPath == rootProjectionPath || !strings.HasPrefix(folderProjectionPath, rootProjectionPath+"/") { + return "", "", ErrProjectFolderNotFound + } + folderDir := filepath.Join(posixRoot, filepath.FromSlash(folderProjectionPath)) + info, err := os.Stat(folderDir) + if err != nil { + if os.IsNotExist(err) { + return "", "", ErrProjectFolderNotFound + } + return "", "", fmt.Errorf("stat project folder: %w", err) + } + if !info.IsDir() { + return "", "", ErrProjectFolderNotFound + } + trimmedParentFolderID := strings.TrimSpace(parentFolderID) + parentChildrenProjectionPath := rootProjectionPath + parentDir := filepath.Join(posixRoot, filepath.FromSlash(rootProjectionPath)) + if trimmedParentFolderID != "" { + parentProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedParentFolderID)), "/") + if parentProjectionPath == "." || parentProjectionPath == rootProjectionPath || !strings.HasPrefix(parentProjectionPath, rootProjectionPath+"/") { + return "", "", ErrProjectFolderNotFound + } + if parentProjectionPath == folderProjectionPath || strings.HasPrefix(parentProjectionPath, folderProjectionPath+"/children/") { + return "", "", ErrInvalidProjectFolderMove + } + parentChildrenProjectionPath = filepath.ToSlash(filepath.Join(parentProjectionPath, "children")) + parentDir = filepath.Join(posixRoot, filepath.FromSlash(parentChildrenProjectionPath)) + } + parentInfo, err := os.Stat(parentDir) + if err != nil { + if os.IsNotExist(err) { + return "", "", ErrProjectFolderNotFound + } + return "", "", fmt.Errorf("stat project folder parent: %w", err) + } + if !parentInfo.IsDir() { + return "", "", ErrProjectFolderNotFound + } + currentParentDir := filepath.Dir(folderDir) + if samePath(currentParentDir, parentDir) { + return folderProjectionPath, folderProjectionPath, nil + } + folderPayload := readJSONFileMap(filepath.Join(folderDir, "folder.json")) + folderMetadataID, _ := folderPayload["id"].(string) + if strings.TrimSpace(folderMetadataID) == "" { + folderMetadataID = uuid.NewString() + } + folderName, _ := folderPayload["name"].(string) + if strings.TrimSpace(folderName) == "" { + folderName = fallbackFolderLabel(folderProjectionPath) + } + currentBase := filepath.Base(folderDir) + baseSlug := strings.TrimPrefix(currentBase, "folder-") + if strings.TrimSpace(baseSlug) == "" { + baseSlug = normalizePOSIXSlug(folderName) + } + folderSlug := baseSlug + folderDirName := slugDir("folder", folderSlug) + destinationDir := filepath.Join(parentDir, folderDirName) + for attempt := 2; ; attempt += 1 { + if _, err := os.Stat(destinationDir); os.IsNotExist(err) { + break + } else if err != nil { + return "", "", fmt.Errorf("stat candidate moved project folder: %w", err) + } + folderSlug = fmt.Sprintf("%s-%d", baseSlug, attempt) + folderDirName = slugDir("folder", folderSlug) + destinationDir = filepath.Join(parentDir, folderDirName) + } + if err := os.Rename(folderDir, destinationDir); err != nil { + return "", "", fmt.Errorf("move project folder: %w", err) + } + folderPayload["id"] = folderMetadataID + folderPayload["name"] = folderName + folderPayload["slug"] = folderSlug + folderPayload["type"] = "folder" + if err := writeJSONFile(filepath.Join(destinationDir, "folder.json"), folderPayload); err != nil { + return "", "", fmt.Errorf("write moved project folder.json: %w", err) + } + movedProjectionPath := filepath.ToSlash(filepath.Join(parentChildrenProjectionPath, folderDirName)) + return folderProjectionPath, movedProjectionPath, nil +} diff --git a/Backend/internal/bootstrap/project_disk_helpers.go b/Backend/internal/bootstrap/project_disk_helpers.go new file mode 100644 index 0000000..80571a9 --- /dev/null +++ b/Backend/internal/bootstrap/project_disk_helpers.go @@ -0,0 +1,138 @@ +// Path: Backend/internal/bootstrap/project_disk_helpers.go + +package bootstrap + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "unicode" +) + +func samePath(left, right string) bool { + cleanLeft := filepath.Clean(left) + cleanRight := filepath.Clean(right) + if cleanLeft == cleanRight { + return true + } + leftInfo, leftErr := os.Stat(cleanLeft) + rightInfo, rightErr := os.Stat(cleanRight) + if leftErr == nil && rightErr == nil { + return os.SameFile(leftInfo, rightInfo) + } + return false +} + +func normalizePOSIXSlug(value string) string { + trimmed := strings.TrimSpace(strings.ToLower(value)) + if trimmed == "" { + return "untitled" + } + var builder strings.Builder + lastDash := false + for _, r := range trimmed { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + builder.WriteRune(r) + lastDash = false + case r == '-' || r == '_' || unicode.IsSpace(r): + if !lastDash && builder.Len() > 0 { + builder.WriteByte('-') + lastDash = true + } + } + } + slug := strings.Trim(builder.String(), "-") + if slug == "" { + return "untitled" + } + return slug +} + +func fallbackFolderLabel(path string) string { + base := filepath.Base(filepath.FromSlash(path)) + trimmed := strings.TrimPrefix(base, "folder-") + parts := strings.FieldsFunc(trimmed, func(r rune) bool { return r == '-' || r == '_' }) + for index, part := range parts { + if part != "" { + parts[index] = strings.ToUpper(part[:1]) + part[1:] + } + } + label := strings.Join(parts, " ") + if label == "" { + return base + } + return label +} + +func fallbackItemLabel(path string) string { + base := filepath.Base(filepath.FromSlash(path)) + trimmed := strings.TrimPrefix(base, "item-") + parts := strings.FieldsFunc(trimmed, func(r rune) bool { return r == '-' || r == '_' }) + for index, part := range parts { + if part != "" { + parts[index] = strings.ToUpper(part[:1]) + part[1:] + } + } + label := strings.Join(parts, " ") + if label == "" { + return base + } + return label +} + +func normalizeProjectTreeItemType(itemType string) string { + switch strings.TrimSpace(strings.ToLower(itemType)) { + case "", "board", "core.board", "core.board.kanban", "kanban": + return "core.board.kanban" + case "core.doc", "doc", "document": + return "core.doc" + case "core.board.list", "list", "list-board": + return "core.board.list" + default: + return strings.TrimSpace(itemType) + } +} + +func defaultProjectTreeItemSchema(itemType string) map[string]any { + return map[string]any{"type": "object", "itemType": normalizeProjectTreeItemType(itemType)} +} + +func defaultProjectTreeItemData(itemType, name string) map[string]any { + return map[string]any{"title": strings.TrimSpace(name), "itemType": normalizeProjectTreeItemType(itemType)} +} + +func slugDir(prefix, slug string) string { + trimmedSlug := strings.TrimSpace(slug) + if trimmedSlug == "" { + return prefix + } + return fmt.Sprintf("%s-%s", prefix, trimmedSlug) +} + +func writeJSONFile(path string, payload any) error { + parentDir := filepath.Dir(path) + if err := os.MkdirAll(parentDir, 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o644) +} + +func readJSONFileMap(path string) map[string]any { + data, err := os.ReadFile(path) + if err != nil { + return map[string]any{} + } + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil || payload == nil { + return map[string]any{} + } + return payload +} diff --git a/Backend/internal/bootstrap/project_disk_items.go b/Backend/internal/bootstrap/project_disk_items.go new file mode 100644 index 0000000..f31682b --- /dev/null +++ b/Backend/internal/bootstrap/project_disk_items.go @@ -0,0 +1,189 @@ +// Path: Backend/internal/bootstrap/project_disk_items.go + +package bootstrap + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +func (service *Service) createProjectTreeItemOnDisk(projectSlug, parentFolderPath, name, itemType string) (string, error) { + posixRoot := strings.TrimSpace(service.posixRoot) + if posixRoot == "" { + return "", fmt.Errorf("POSIX root is not configured") + } + trimmedName := strings.TrimSpace(name) + if trimmedName == "" { + return "", fmt.Errorf("item name is required") + } + canonicalItemType := normalizeProjectTreeItemType(itemType) + containerProjectionPath := projectTreeRootPath(projectSlug) + parentDir := filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath)) + if trimmedParentFolderPath := strings.TrimSpace(parentFolderPath); trimmedParentFolderPath != "" { + containerProjectionPath = trimmedParentFolderPath + parentDir = filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath)) + info, err := os.Stat(parentDir) + if err != nil { + if os.IsNotExist(err) { + return "", ErrProjectFolderNotFound + } + return "", fmt.Errorf("stat parent project folder: %w", err) + } + if !info.IsDir() { + return "", ErrProjectFolderNotFound + } + } + if err := os.MkdirAll(parentDir, 0o755); err != nil { + return "", fmt.Errorf("create parent project item path: %w", err) + } + baseSlug := normalizePOSIXSlug(trimmedName) + itemDirName := slugDir("item", baseSlug) + itemDir := filepath.Join(parentDir, itemDirName) + itemSlug := baseSlug + for attempt := 2; ; attempt += 1 { + if _, err := os.Stat(itemDir); os.IsNotExist(err) { + break + } else if err != nil { + return "", fmt.Errorf("stat candidate project item: %w", err) + } + itemSlug = fmt.Sprintf("%s-%d", baseSlug, attempt) + itemDirName = slugDir("item", itemSlug) + itemDir = filepath.Join(parentDir, itemDirName) + } + if err := os.MkdirAll(itemDir, 0o755); err != nil { + return "", fmt.Errorf("create project item: %w", err) + } + itemID := uuid.NewString() + if err := writeJSONFile(filepath.Join(itemDir, "item.json"), map[string]any{"id": itemID, "name": trimmedName, "slug": itemSlug, "type": canonicalItemType}); err != nil { + return "", fmt.Errorf("write project item.json: %w", err) + } + if err := writeJSONFile(filepath.Join(itemDir, "schema.json"), defaultProjectTreeItemSchema(canonicalItemType)); err != nil { + return "", fmt.Errorf("write project schema.json: %w", err) + } + if err := writeJSONFile(filepath.Join(itemDir, "data.json"), defaultProjectTreeItemData(canonicalItemType, trimmedName)); err != nil { + return "", fmt.Errorf("write project data.json: %w", err) + } + return filepath.ToSlash(filepath.Join(containerProjectionPath, itemDirName)), nil +} + +func (service *Service) deleteProjectTreeItemOnDisk(projectSlug, itemPath string) (string, error) { + posixRoot := strings.TrimSpace(service.posixRoot) + if posixRoot == "" { + return "", fmt.Errorf("POSIX root is not configured") + } + rootProjectionPath := projectTreeRootPath(projectSlug) + itemProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(itemPath))), "/") + if itemProjectionPath == "." || itemProjectionPath == rootProjectionPath || !strings.HasPrefix(itemProjectionPath, rootProjectionPath+"/") { + return "", ErrProjectItemNotFound + } + itemDir := filepath.Join(posixRoot, filepath.FromSlash(itemProjectionPath)) + info, err := os.Stat(itemDir) + if err != nil { + if os.IsNotExist(err) { + return "", ErrProjectItemNotFound + } + return "", fmt.Errorf("stat project item: %w", err) + } + if !info.IsDir() { + return "", ErrProjectItemNotFound + } + if err := os.RemoveAll(itemDir); err != nil { + return "", fmt.Errorf("delete project item: %w", err) + } + return itemProjectionPath, nil +} + +func (service *Service) moveProjectTreeItemOnDisk(projectSlug, itemPath, parentFolderPath string) (string, string, error) { + posixRoot := strings.TrimSpace(service.posixRoot) + if posixRoot == "" { + return "", "", fmt.Errorf("POSIX root is not configured") + } + rootProjectionPath := projectTreeRootPath(projectSlug) + itemProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(itemPath))), "/") + if itemProjectionPath == "." || itemProjectionPath == rootProjectionPath || !strings.HasPrefix(itemProjectionPath, rootProjectionPath+"/") { + return "", "", ErrProjectItemNotFound + } + itemDir := filepath.Join(posixRoot, filepath.FromSlash(itemProjectionPath)) + info, err := os.Stat(itemDir) + if err != nil { + if os.IsNotExist(err) { + return "", "", ErrProjectItemNotFound + } + return "", "", fmt.Errorf("stat project item: %w", err) + } + if !info.IsDir() { + return "", "", ErrProjectItemNotFound + } + trimmedParentFolderPath := strings.TrimSpace(parentFolderPath) + parentProjectionPath := rootProjectionPath + parentDir := filepath.Join(posixRoot, filepath.FromSlash(parentProjectionPath)) + if trimmedParentFolderPath != "" { + parentProjectionPath = strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedParentFolderPath)), "/") + if parentProjectionPath == "." || parentProjectionPath == rootProjectionPath || !strings.HasPrefix(parentProjectionPath, rootProjectionPath+"/") { + return "", "", ErrProjectFolderNotFound + } + parentDir = filepath.Join(posixRoot, filepath.FromSlash(parentProjectionPath)) + } + parentInfo, err := os.Stat(parentDir) + if err != nil { + if os.IsNotExist(err) { + return "", "", ErrProjectFolderNotFound + } + return "", "", fmt.Errorf("stat project item parent: %w", err) + } + if !parentInfo.IsDir() { + return "", "", ErrProjectFolderNotFound + } + currentParentDir := filepath.Dir(itemDir) + currentBase := filepath.Base(itemDir) + itemPayload := readJSONFileMap(filepath.Join(itemDir, "item.json")) + itemID, _ := itemPayload["id"].(string) + if strings.TrimSpace(itemID) == "" { + itemID = uuid.NewString() + } + itemName, _ := itemPayload["name"].(string) + if strings.TrimSpace(itemName) == "" { + itemName = fallbackItemLabel(itemProjectionPath) + } + itemType, _ := itemPayload["type"].(string) + canonicalItemType := normalizeProjectTreeItemType(itemType) + baseSlug := strings.TrimPrefix(currentBase, "item-") + if strings.TrimSpace(baseSlug) == "" { + baseSlug = normalizePOSIXSlug(itemName) + } + itemSlug := baseSlug + itemDirName := slugDir("item", itemSlug) + destinationDir := filepath.Join(parentDir, itemDirName) + for attempt := 2; ; attempt += 1 { + if samePath(destinationDir, itemDir) { + break + } + if _, err := os.Stat(destinationDir); os.IsNotExist(err) { + break + } else if err != nil { + return "", "", fmt.Errorf("stat candidate moved project item: %w", err) + } + itemSlug = fmt.Sprintf("%s-%d", baseSlug, attempt) + itemDirName = slugDir("item", itemSlug) + destinationDir = filepath.Join(parentDir, itemDirName) + } + movedProjectionPath := filepath.ToSlash(filepath.Join(parentProjectionPath, itemDirName)) + if samePath(currentParentDir, parentDir) && currentBase == itemDirName { + return itemProjectionPath, itemProjectionPath, nil + } + if err := os.Rename(itemDir, destinationDir); err != nil { + return "", "", fmt.Errorf("move project item: %w", err) + } + itemPayload["id"] = itemID + itemPayload["name"] = itemName + itemPayload["slug"] = itemSlug + itemPayload["type"] = canonicalItemType + if err := writeJSONFile(filepath.Join(destinationDir, "item.json"), itemPayload); err != nil { + return "", "", fmt.Errorf("write moved project item.json: %w", err) + } + return itemProjectionPath, movedProjectionPath, nil +} diff --git a/Backend/internal/bootstrap/project_mutations.go b/Backend/internal/bootstrap/project_mutations.go new file mode 100644 index 0000000..15730a3 --- /dev/null +++ b/Backend/internal/bootstrap/project_mutations.go @@ -0,0 +1,491 @@ +// Path: Backend/internal/bootstrap/project_mutations.go + +package bootstrap + +import ( + "context" + "fmt" + "strings" +) + +func (service *Service) CreateProjectFolder(ctx context.Context, input CreateProjectFolderInput) (CreateProjectFolderResult, error) { + return service.createProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.createProjectHierarchyFolderOnDisk) +} + +func (service *Service) CreateProjectTreeFolder(ctx context.Context, input CreateProjectFolderInput) (CreateProjectFolderResult, error) { + return service.createProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.createProjectTreeFolderOnDisk) +} + +func (service *Service) DeleteProjectFolder(ctx context.Context, input DeleteProjectFolderInput) (DeleteProjectFolderResult, error) { + return service.deleteProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.deleteProjectHierarchyFolderOnDisk) +} + +func (service *Service) DeleteProjectTreeFolder(ctx context.Context, input DeleteProjectFolderInput) (DeleteProjectFolderResult, error) { + return service.deleteProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.deleteProjectTreeFolderOnDisk) +} + +func (service *Service) RenameProjectFolder(ctx context.Context, input RenameProjectFolderInput) (RenameProjectFolderResult, error) { + return service.renameProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.renameProjectHierarchyFolderOnDisk) +} + +func (service *Service) RenameProjectTreeFolder(ctx context.Context, input RenameProjectFolderInput) (RenameProjectFolderResult, error) { + return service.renameProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.renameProjectTreeFolderOnDisk) +} + +func (service *Service) MoveProjectFolder(ctx context.Context, input MoveProjectFolderInput) (MoveProjectFolderResult, error) { + return service.moveProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.moveProjectHierarchyFolderOnDisk) +} + +func (service *Service) MoveProjectTreeFolder(ctx context.Context, input MoveProjectFolderInput) (MoveProjectFolderResult, error) { + return service.moveProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.moveProjectTreeFolderOnDisk) +} + +func (service *Service) CreateProjectTreeItem(ctx context.Context, input CreateProjectItemInput) (CreateProjectItemResult, error) { + return service.createProjectTreeItem(ctx, input, projectTreeRootPath) +} + +func (service *Service) DeleteProjectTreeItem(ctx context.Context, input DeleteProjectItemInput) (DeleteProjectItemResult, error) { + return service.deleteProjectTreeItem(ctx, input, projectTreeRootPath) +} + +func (service *Service) MoveProjectTreeItem(ctx context.Context, input MoveProjectItemInput) (MoveProjectItemResult, error) { + return service.moveProjectTreeItem(ctx, input, projectTreeRootPath) +} + +func (service *Service) createProjectHierarchyFolder( + ctx context.Context, + input CreateProjectFolderInput, + rootPath func(projectSlug string) string, + createOnDisk func(projectSlug, parentFolderPath, name string) (string, string, error), +) (CreateProjectFolderResult, error) { + project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) + if err != nil { + return CreateProjectFolderResult{}, err + } + + currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) + if err != nil { + return CreateProjectFolderResult{}, err + } + + parentOrderID := "" + trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath) + if trimmedParentFolderPath != "" { + parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath) + if !found { + return CreateProjectFolderResult{}, ErrProjectFolderNotFound + } + parentOrderID = parentFolder.ID + } + + createdPath, _, err := createOnDisk(project.Slug, strings.TrimSpace(input.ParentFolderPath), input.Name) + if err != nil { + return CreateProjectFolderResult{}, err + } + + if err := service.rebuildProjection(ctx); err != nil { + return CreateProjectFolderResult{}, err + } + + folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) + if err != nil { + return CreateProjectFolderResult{}, err + } + + createdFolder, ok := findProjectHierarchyFolderByPath(folders, createdPath) + if !ok { + return CreateProjectFolderResult{}, fmt.Errorf("created project folder missing from projection") + } + + folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug)) + seedFolderOrderParent(folderOrder, currentFolders, parentOrderID) + insertFolderOrder(folderOrder, parentOrderID, createdFolder.ID, len(folderOrderChildren(folderOrder, parentOrderID))) + if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil { + return CreateProjectFolderResult{}, err + } + + folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) + if err != nil { + return CreateProjectFolderResult{}, err + } + + createdFolder, ok = findProjectHierarchyFolderByPath(folders, createdPath) + if !ok { + return CreateProjectFolderResult{}, fmt.Errorf("created project folder missing from ordered projection") + } + + return CreateProjectFolderResult{ProjectID: project.ID, CreatedFolder: createdFolder, Folders: folders}, nil +} + +func (service *Service) deleteProjectHierarchyFolder( + ctx context.Context, + input DeleteProjectFolderInput, + rootPath func(projectSlug string) string, + deleteOnDisk func(projectSlug, folderPath string) (string, error), +) (DeleteProjectFolderResult, error) { + project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) + if err != nil { + return DeleteProjectFolderResult{}, err + } + + currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) + if err != nil { + return DeleteProjectFolderResult{}, err + } + + deletedFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath)) + if !found { + return DeleteProjectFolderResult{}, ErrProjectFolderNotFound + } + + deletedFolderPath, err := deleteOnDisk(project.Slug, input.FolderPath) + if err != nil { + return DeleteProjectFolderResult{}, err + } + + if err := service.rebuildProjection(ctx); err != nil { + return DeleteProjectFolderResult{}, err + } + + folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) + if err != nil { + return DeleteProjectFolderResult{}, err + } + + if _, found := findProjectHierarchyFolderByPath(folders, deletedFolderPath); found { + return DeleteProjectFolderResult{}, fmt.Errorf("deleted project folder still present in projection") + } + + folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug)) + removeFolderOrder(folderOrder, deletedFolder.ID) + if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil { + return DeleteProjectFolderResult{}, err + } + + folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) + if err != nil { + return DeleteProjectFolderResult{}, err + } + + return DeleteProjectFolderResult{ProjectID: project.ID, DeletedFolderStableID: deletedFolder.ID, DeletedFolderPath: deletedFolderPath, Folders: folders}, nil +} + +func (service *Service) renameProjectHierarchyFolder( + ctx context.Context, + input RenameProjectFolderInput, + rootPath func(projectSlug string) string, + renameOnDisk func(projectSlug, folderPath, name string) (string, string, error), +) (RenameProjectFolderResult, error) { + project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) + if err != nil { + return RenameProjectFolderResult{}, err + } + + previousFolderPath, renamedFolderPath, err := renameOnDisk(project.Slug, input.FolderPath, input.Name) + if err != nil { + return RenameProjectFolderResult{}, err + } + + if err := service.rebuildProjection(ctx); err != nil { + return RenameProjectFolderResult{}, err + } + + folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) + if err != nil { + return RenameProjectFolderResult{}, err + } + + renamedFolder, found := findProjectHierarchyFolderByPath(folders, renamedFolderPath) + if !found { + return RenameProjectFolderResult{}, fmt.Errorf("renamed project folder missing from projection") + } + + if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found { + return RenameProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection") + } + + return RenameProjectFolderResult{ProjectID: project.ID, PreviousFolderStableID: renamedFolder.ID, PreviousFolderPath: previousFolderPath, RenamedFolder: renamedFolder, Folders: folders}, nil +} + +func (service *Service) moveProjectHierarchyFolder( + ctx context.Context, + input MoveProjectFolderInput, + rootPath func(projectSlug string) string, + moveOnDisk func(projectSlug, folderPath, parentFolderPath string) (string, string, error), +) (MoveProjectFolderResult, error) { + project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) + if err != nil { + return MoveProjectFolderResult{}, err + } + + currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) + if err != nil { + return MoveProjectFolderResult{}, err + } + + currentFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath)) + if !found { + return MoveProjectFolderResult{}, ErrProjectFolderNotFound + } + + movedFolderStableID := currentFolder.ID + providedFolderStableID := strings.TrimSpace(input.FolderStableID) + if providedFolderStableID != "" && providedFolderStableID != movedFolderStableID { + return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove + } + + parentOrderID := "" + trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath) + providedParentStableID := strings.TrimSpace(input.ParentStableID) + if trimmedParentFolderPath != "" { + parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath) + if !found { + return MoveProjectFolderResult{}, ErrProjectFolderNotFound + } + parentOrderID = parentFolder.ID + if providedParentStableID != "" && providedParentStableID != parentOrderID { + return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove + } + } else if providedParentStableID != "" { + return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove + } + + previousFolderPath, movedFolderPath, err := moveOnDisk(project.Slug, input.FolderPath, input.ParentFolderPath) + if err != nil { + return MoveProjectFolderResult{}, err + } + + if err := service.rebuildProjection(ctx); err != nil { + return MoveProjectFolderResult{}, err + } + + folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) + if err != nil { + return MoveProjectFolderResult{}, err + } + + movedFolder, found := findProjectHierarchyFolderByPath(folders, movedFolderPath) + if !found { + return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from projection") + } + + if previousFolderPath != movedFolderPath { + if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found { + return MoveProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection") + } + } + + folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug)) + seedFolderOrderParent(folderOrder, currentFolders, parentOrderID) + removeFolderOrderReference(folderOrder, movedFolderStableID) + removeFolderOrderReference(folderOrder, movedFolder.ID) + insertFolderOrder(folderOrder, parentOrderID, movedFolder.ID, input.TargetIndex) + if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil { + return MoveProjectFolderResult{}, err + } + + folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) + if err != nil { + return MoveProjectFolderResult{}, err + } + + movedFolder, found = findProjectHierarchyFolderByPath(folders, movedFolderPath) + if !found { + return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from ordered projection") + } + + return MoveProjectFolderResult{ProjectID: project.ID, PreviousFolderStableID: movedFolder.ID, PreviousFolderPath: previousFolderPath, MovedFolder: movedFolder, Folders: folders}, nil +} + +func (service *Service) createProjectTreeItem(ctx context.Context, input CreateProjectItemInput, rootPath func(projectSlug string) string) (CreateProjectItemResult, error) { + project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) + if err != nil { + return CreateProjectItemResult{}, err + } + + currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath) + if err != nil { + return CreateProjectItemResult{}, err + } + + parentOrderID := "" + trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath) + if trimmedParentFolderPath != "" { + parentFolder, found := findProjectTreeFolderByPath(currentNodes, trimmedParentFolderPath) + if !found { + return CreateProjectItemResult{}, ErrProjectFolderNotFound + } + parentOrderID = parentFolder.ID + } + + createdPath, err := service.createProjectTreeItemOnDisk(project.Slug, trimmedParentFolderPath, input.Name, input.ItemType) + if err != nil { + return CreateProjectItemResult{}, err + } + + if err := service.rebuildProjection(ctx); err != nil { + return CreateProjectItemResult{}, err + } + + nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath) + if err != nil { + return CreateProjectItemResult{}, err + } + + createdItem, ok := findProjectTreeNodeByPath(nodes, createdPath) + if !ok || createdItem.Kind != "item" { + return CreateProjectItemResult{}, fmt.Errorf("created project item missing from projection") + } + + folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug)) + seedProjectTreeOrderParent(folderOrder, currentNodes, parentOrderID) + insertFolderOrder(folderOrder, parentOrderID, createdItem.ID, len(folderOrderChildren(folderOrder, parentOrderID))) + if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil { + return CreateProjectItemResult{}, err + } + + nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath) + if err != nil { + return CreateProjectItemResult{}, err + } + + createdItem, ok = findProjectTreeNodeByPath(nodes, createdPath) + if !ok || createdItem.Kind != "item" { + return CreateProjectItemResult{}, fmt.Errorf("created project item missing from ordered projection") + } + + return CreateProjectItemResult{ProjectID: project.ID, CreatedItem: createdItem, Nodes: nodes}, nil +} + +func (service *Service) deleteProjectTreeItem(ctx context.Context, input DeleteProjectItemInput, rootPath func(projectSlug string) string) (DeleteProjectItemResult, error) { + project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) + if err != nil { + return DeleteProjectItemResult{}, err + } + + currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath) + if err != nil { + return DeleteProjectItemResult{}, err + } + + deletedItem, found := findProjectTreeNodeByPath(currentNodes, strings.TrimSpace(input.ItemPath)) + if !found || deletedItem.Kind != "item" { + return DeleteProjectItemResult{}, ErrProjectItemNotFound + } + + deletedItemPath, err := service.deleteProjectTreeItemOnDisk(project.Slug, input.ItemPath) + if err != nil { + return DeleteProjectItemResult{}, err + } + + if err := service.rebuildProjection(ctx); err != nil { + return DeleteProjectItemResult{}, err + } + + nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath) + if err != nil { + return DeleteProjectItemResult{}, err + } + + if _, found := findProjectTreeNodeByPath(nodes, deletedItemPath); found { + return DeleteProjectItemResult{}, fmt.Errorf("deleted project item still present in projection") + } + + folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug)) + removeFolderOrderReference(folderOrder, deletedItem.ID) + if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil { + return DeleteProjectItemResult{}, err + } + + nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath) + if err != nil { + return DeleteProjectItemResult{}, err + } + + return DeleteProjectItemResult{ProjectID: project.ID, DeletedItemStableID: deletedItem.ID, DeletedItemPath: deletedItemPath, Nodes: nodes}, nil +} + +func (service *Service) moveProjectTreeItem(ctx context.Context, input MoveProjectItemInput, rootPath func(projectSlug string) string) (MoveProjectItemResult, error) { + project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) + if err != nil { + return MoveProjectItemResult{}, err + } + + currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath) + if err != nil { + return MoveProjectItemResult{}, err + } + + currentItem, found := findProjectTreeNodeByPath(currentNodes, strings.TrimSpace(input.ItemPath)) + if !found || currentItem.Kind != "item" { + return MoveProjectItemResult{}, ErrProjectItemNotFound + } + + movedItemStableID := currentItem.ID + providedItemStableID := strings.TrimSpace(input.ItemStableID) + if providedItemStableID != "" && providedItemStableID != movedItemStableID { + return MoveProjectItemResult{}, ErrInvalidProjectItemMove + } + + parentOrderID := "" + trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath) + providedParentStableID := strings.TrimSpace(input.ParentStableID) + if trimmedParentFolderPath != "" { + parentFolder, found := findProjectTreeFolderByPath(currentNodes, trimmedParentFolderPath) + if !found { + return MoveProjectItemResult{}, ErrProjectFolderNotFound + } + parentOrderID = parentFolder.ID + if providedParentStableID != "" && providedParentStableID != parentOrderID { + return MoveProjectItemResult{}, ErrInvalidProjectItemMove + } + } else if providedParentStableID != "" { + return MoveProjectItemResult{}, ErrInvalidProjectItemMove + } + + previousItemPath, movedItemPath, err := service.moveProjectTreeItemOnDisk(project.Slug, input.ItemPath, input.ParentFolderPath) + if err != nil { + return MoveProjectItemResult{}, err + } + + if err := service.rebuildProjection(ctx); err != nil { + return MoveProjectItemResult{}, err + } + + nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath) + if err != nil { + return MoveProjectItemResult{}, err + } + + movedItem, found := findProjectTreeNodeByPath(nodes, movedItemPath) + if !found || movedItem.Kind != "item" { + return MoveProjectItemResult{}, fmt.Errorf("moved project item missing from projection") + } + + if previousItemPath != movedItemPath { + if _, found := findProjectTreeNodeByPath(nodes, previousItemPath); found { + return MoveProjectItemResult{}, fmt.Errorf("previous project item path still present in projection") + } + } + + folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug)) + seedProjectTreeOrderParent(folderOrder, currentNodes, parentOrderID) + removeFolderOrderReference(folderOrder, movedItemStableID) + removeFolderOrderReference(folderOrder, movedItem.ID) + insertFolderOrder(folderOrder, parentOrderID, movedItem.ID, input.TargetIndex) + if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil { + return MoveProjectItemResult{}, err + } + + nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath) + if err != nil { + return MoveProjectItemResult{}, err + } + + movedItem, found = findProjectTreeNodeByPath(nodes, movedItemPath) + if !found || movedItem.Kind != "item" { + return MoveProjectItemResult{}, fmt.Errorf("moved project item missing from ordered projection") + } + + return MoveProjectItemResult{ProjectID: project.ID, PreviousItemStableID: movedItem.ID, PreviousItemPath: previousItemPath, MovedItem: movedItem, Nodes: nodes}, nil +} diff --git a/Backend/internal/bootstrap/project_order.go b/Backend/internal/bootstrap/project_order.go new file mode 100644 index 0000000..3fa13ae --- /dev/null +++ b/Backend/internal/bootstrap/project_order.go @@ -0,0 +1,329 @@ +// Path: Backend/internal/bootstrap/project_order.go + +package bootstrap + +import ( + "fmt" + "path/filepath" + "sort" + "strings" +) + +func (service *Service) readProjectFolderOrder(projectSlug, rootProjectionPath string) map[string][]string { + settingsPath := service.projectSettingsPath(projectSlug) + settingsPayload := readJSONFileMap(settingsPath) + folderOrderPayload, _ := settingsPayload["folderOrder"].(map[string]any) + if folderOrderPayload == nil { return map[string][]string{} } + scopePayload, _ := folderOrderPayload[projectFolderOrderScope(rootProjectionPath)].(map[string]any) + if scopePayload == nil { return map[string][]string{} } + byParentPayload, _ := scopePayload["byParent"].(map[string]any) + if byParentPayload == nil { return map[string][]string{} } + order := make(map[string][]string, len(byParentPayload)) + for key, raw := range byParentPayload { + for _, id := range stringSliceValue(raw) { + trimmedID := strings.TrimSpace(id) + if trimmedID == "" || slicesContains(order[key], trimmedID) { continue } + order[key] = append(order[key], trimmedID) + } + } + return order +} + +func (service *Service) writeProjectFolderOrder(projectSlug, rootProjectionPath string, folderOrder map[string][]string) error { + settingsPath := service.projectSettingsPath(projectSlug) + settingsPayload := readJSONFileMap(settingsPath) + if settingsPayload == nil { settingsPayload = map[string]any{} } + folderOrderPayload, _ := settingsPayload["folderOrder"].(map[string]any) + if folderOrderPayload == nil { folderOrderPayload = map[string]any{} } + scopeKey := projectFolderOrderScope(rootProjectionPath) + scopePayload, _ := folderOrderPayload[scopeKey].(map[string]any) + if scopePayload == nil { scopePayload = map[string]any{} } + byParentPayload := map[string]any{} + for key, ids := range folderOrder { + if len(ids) == 0 { continue } + copied := make([]string, 0, len(ids)) + for _, id := range ids { + trimmedID := strings.TrimSpace(id) + if trimmedID == "" || slicesContains(copied, trimmedID) { continue } + copied = append(copied, trimmedID) + } + if len(copied) > 0 { byParentPayload[key] = copied } + } + scopePayload["byParent"] = byParentPayload + folderOrderPayload[scopeKey] = scopePayload + settingsPayload["folderOrder"] = folderOrderPayload + if err := writeJSONFile(settingsPath, settingsPayload); err != nil { return fmt.Errorf("write project settings.json: %w", err) } + return nil +} + +func (service *Service) projectSettingsPath(projectSlug string) string { + return filepath.Join(strings.TrimSpace(service.posixRoot), "projects", slugDir("project", projectSlug), "settings.json") +} + +func projectFolderOrderScope(rootProjectionPath string) string { + if strings.HasSuffix(rootProjectionPath, "/tree") { return projectFolderOrderTree } + return projectFolderOrderHierarchy +} + +func applyProjectHierarchyFolderOrdering(folders []ProjectHierarchyFolderRecord, folderOrder map[string][]string) []ProjectHierarchyFolderRecord { + return applyProjectHierarchyFolderOrderingForParent(folders, "", folderOrder) +} + +func applyProjectHierarchyFolderOrderingForParent(folders []ProjectHierarchyFolderRecord, parentID string, folderOrder map[string][]string) []ProjectHierarchyFolderRecord { + if len(folders) == 0 { return folders } + nextFolders := make([]ProjectHierarchyFolderRecord, len(folders)) + copy(nextFolders, folders) + for index := range nextFolders { nextFolders[index].Children = applyProjectHierarchyFolderOrderingForParent(nextFolders[index].Children, nextFolders[index].ID, folderOrder) } + orderIDs := folderOrder[projectFolderOrderParentKey(parentID)] + if len(orderIDs) == 0 { return nextFolders } + rankByID := make(map[string]int, len(orderIDs)) + for index, id := range orderIDs { if _, exists := rankByID[id]; !exists { rankByID[id] = index } } + sort.SliceStable(nextFolders, func(left, right int) bool { + leftRank, leftOrdered := rankByID[nextFolders[left].ID] + rightRank, rightOrdered := rankByID[nextFolders[right].ID] + if leftOrdered && rightOrdered { return leftRank < rightRank } + if leftOrdered != rightOrdered { return leftOrdered } + return false + }) + return nextFolders +} + +func removeFolderOrder(folderOrder map[string][]string, folderID string) { + removeFolderOrderReference(folderOrder, folderID) + trimmedFolderID := strings.TrimSpace(folderID) + if trimmedFolderID == "" { return } + delete(folderOrder, projectFolderOrderParentKey(trimmedFolderID)) +} + +func removeFolderOrderReference(folderOrder map[string][]string, folderID string) { + trimmedFolderID := strings.TrimSpace(folderID) + if trimmedFolderID == "" { return } + for key, ids := range folderOrder { + nextIDs := ids[:0] + for _, id := range ids { + if strings.TrimSpace(id) == trimmedFolderID { continue } + nextIDs = append(nextIDs, id) + } + if len(nextIDs) == 0 { delete(folderOrder, key); continue } + folderOrder[key] = append([]string(nil), nextIDs...) + } +} + +func insertFolderOrder(folderOrder map[string][]string, parentID, folderID string, index int) { + trimmedFolderID := strings.TrimSpace(folderID) + if trimmedFolderID == "" { return } + removeFolderOrderReference(folderOrder, trimmedFolderID) + parentKey := projectFolderOrderParentKey(parentID) + children := append([]string(nil), folderOrder[parentKey]...) + if index < 0 { index = 0 } + if index > len(children) { index = len(children) } + children = slicesInsert(children, index, trimmedFolderID) + folderOrder[parentKey] = children +} + +func seedFolderOrderParent(folderOrder map[string][]string, folders []ProjectHierarchyFolderRecord, parentID string) { + children := folders + trimmedParentID := strings.TrimSpace(parentID) + if trimmedParentID != "" { + parent, found := findProjectHierarchyFolder(folders, trimmedParentID) + if !found { return } + children = parent.Children + } + parentKey := projectFolderOrderParentKey(trimmedParentID) + seeded := make([]string, 0, len(children)) + for _, child := range children { + childID := strings.TrimSpace(child.ID) + if childID == "" || slicesContains(seeded, childID) { continue } + seeded = append(seeded, childID) + } + if len(seeded) == 0 { delete(folderOrder, parentKey); return } + folderOrder[parentKey] = seeded +} + +func folderOrderChildren(folderOrder map[string][]string, parentID string) []string { + return append([]string(nil), folderOrder[projectFolderOrderParentKey(parentID)]...) +} + +func projectFolderOrderParentKey(parentID string) string { + trimmedParentID := strings.TrimSpace(parentID) + if trimmedParentID == "" { return projectFolderOrderRootKey } + return trimmedParentID +} + +func stringSliceValue(value any) []string { + items, ok := value.([]any) + if !ok { + if typed, ok := value.([]string); ok { return typed } + return nil + } + result := make([]string, 0, len(items)) + for _, item := range items { if text, ok := item.(string); ok { result = append(result, text) } } + return result +} + +func slicesContains(values []string, value string) bool { + for _, existing := range values { if existing == value { return true } } + return false +} + +func slicesInsert(values []string, index int, value string) []string { + values = append(values, "") + copy(values[index+1:], values[index:]) + values[index] = value + return values +} + +func buildProjectHierarchyFolderTree(rows []projectHierarchyFolderRow, rootParentPath string) []ProjectHierarchyFolderRecord { + if len(rows) == 0 { return nil } + nodesByPath := make(map[string]*ProjectHierarchyFolderRecord, len(rows)) + childrenByParent := make(map[string][]string) + for _, row := range rows { + folderID := strings.TrimSpace(row.ID) + if folderID == "" { folderID = row.Path } + label := strings.TrimSpace(row.Label) + if label == "" { label = fallbackFolderLabel(row.Path) } + nodesByPath[row.Path] = &ProjectHierarchyFolderRecord{ID: folderID, Path: row.Path, Label: label, Children: []ProjectHierarchyFolderRecord{}} + childrenByParent[row.ParentPath] = append(childrenByParent[row.ParentPath], row.Path) + } + var build func(parentPath string) []ProjectHierarchyFolderRecord + build = func(parentPath string) []ProjectHierarchyFolderRecord { + childPaths := childrenByParent[parentPath] + if len(childPaths) == 0 { return nil } + folders := make([]ProjectHierarchyFolderRecord, 0, len(childPaths)) + for _, childPath := range childPaths { + node := nodesByPath[childPath] + if node == nil { continue } + folder := ProjectHierarchyFolderRecord{ID: node.ID, Path: node.Path, Label: node.Label, Children: build(filepath.ToSlash(filepath.Join(childPath, "children")))} + folders = append(folders, folder) + } + return folders + } + return build(rootParentPath) +} + +func buildProjectTreeNodeTree(rows []projectTreeNodeRow, rootParentPath string) []ProjectTreeNodeRecord { + if len(rows) == 0 { return nil } + nodesByPath := make(map[string]*ProjectTreeNodeRecord, len(rows)) + childrenByParent := make(map[string][]string) + for _, row := range rows { + nodeKind := normalizeProjectTreeNodeKind(row.Kind) + nodeID := strings.TrimSpace(row.ID) + if nodeID == "" { nodeID = row.Path } + label := strings.TrimSpace(row.Label) + if label == "" { if nodeKind == "item" { label = fallbackItemLabel(row.Path) } else { label = fallbackFolderLabel(row.Path) } } + nodesByPath[row.Path] = &ProjectTreeNodeRecord{ID: nodeID, Path: row.Path, Label: label, Kind: nodeKind, ItemType: normalizeProjectTreeItemType(row.ItemType), Children: []ProjectTreeNodeRecord{}} + parentKey := normalizeProjectTreeParentPath(nodeKind, row.ParentPath) + childrenByParent[parentKey] = append(childrenByParent[parentKey], row.Path) + } + var build func(parentPath string) []ProjectTreeNodeRecord + build = func(parentPath string) []ProjectTreeNodeRecord { + childPaths := childrenByParent[parentPath] + if len(childPaths) == 0 { return nil } + nodes := make([]ProjectTreeNodeRecord, 0, len(childPaths)) + for _, childPath := range childPaths { + node := nodesByPath[childPath] + if node == nil { continue } + nextNode := ProjectTreeNodeRecord{ID: node.ID, Path: node.Path, Label: node.Label, Kind: node.Kind, ItemType: node.ItemType} + if node.Kind == "folder" { nextNode.Children = build(node.Path) } + nodes = append(nodes, nextNode) + } + return nodes + } + return build(rootParentPath) +} + +func normalizeProjectTreeParentPath(kind, parentPath string) string { + if kind == "folder" && strings.HasSuffix(parentPath, "/children") { return filepath.ToSlash(filepath.Dir(parentPath)) } + return parentPath +} + +func normalizeProjectTreeNodeKind(kind string) string { + switch strings.TrimSpace(kind) { + case "item": + return "item" + case "folder", "hierarchy_folder": + return "folder" + default: + return "folder" + } +} + +func applyProjectTreeNodeOrdering(nodes []ProjectTreeNodeRecord, folderOrder map[string][]string) []ProjectTreeNodeRecord { + return applyProjectTreeNodeOrderingForParent(nodes, "", folderOrder) +} + +func applyProjectTreeNodeOrderingForParent(nodes []ProjectTreeNodeRecord, parentID string, folderOrder map[string][]string) []ProjectTreeNodeRecord { + if len(nodes) == 0 { return nodes } + nextNodes := make([]ProjectTreeNodeRecord, len(nodes)) + copy(nextNodes, nodes) + for index := range nextNodes { if nextNodes[index].Kind == "folder" { nextNodes[index].Children = applyProjectTreeNodeOrderingForParent(nextNodes[index].Children, nextNodes[index].ID, folderOrder) } } + orderIDs := folderOrder[projectFolderOrderParentKey(parentID)] + if len(orderIDs) == 0 { return nextNodes } + rankByID := make(map[string]int, len(orderIDs)) + for index, id := range orderIDs { if _, exists := rankByID[id]; !exists { rankByID[id] = index } } + sort.SliceStable(nextNodes, func(left, right int) bool { + leftRank, leftOrdered := rankByID[nextNodes[left].ID] + rightRank, rightOrdered := rankByID[nextNodes[right].ID] + if leftOrdered && rightOrdered { return leftRank < rightRank } + if leftOrdered != rightOrdered { return leftOrdered } + return false + }) + return nextNodes +} + +func findProjectHierarchyFolder(folders []ProjectHierarchyFolderRecord, folderID string) (ProjectHierarchyFolderRecord, bool) { + for _, folder := range folders { + if folder.ID == folderID { return folder, true } + if child, ok := findProjectHierarchyFolder(folder.Children, folderID); ok { return child, true } + } + return ProjectHierarchyFolderRecord{}, false +} + +func findProjectHierarchyFolderByPath(folders []ProjectHierarchyFolderRecord, folderPath string) (ProjectHierarchyFolderRecord, bool) { + for _, folder := range folders { + if folder.Path == folderPath { return folder, true } + if child, ok := findProjectHierarchyFolderByPath(folder.Children, folderPath); ok { return child, true } + } + return ProjectHierarchyFolderRecord{}, false +} + +func findProjectTreeNode(nodes []ProjectTreeNodeRecord, nodeID string) (ProjectTreeNodeRecord, bool) { + for _, node := range nodes { + if node.ID == nodeID { return node, true } + if child, ok := findProjectTreeNode(node.Children, nodeID); ok { return child, true } + } + return ProjectTreeNodeRecord{}, false +} + +func findProjectTreeNodeByPath(nodes []ProjectTreeNodeRecord, nodePath string) (ProjectTreeNodeRecord, bool) { + for _, node := range nodes { + if node.Path == nodePath { return node, true } + if child, ok := findProjectTreeNodeByPath(node.Children, nodePath); ok { return child, true } + } + return ProjectTreeNodeRecord{}, false +} + +func findProjectTreeFolderByPath(nodes []ProjectTreeNodeRecord, folderPath string) (ProjectTreeNodeRecord, bool) { + node, ok := findProjectTreeNodeByPath(nodes, folderPath) + if !ok || node.Kind != "folder" { return ProjectTreeNodeRecord{}, false } + return node, true +} + +func seedProjectTreeOrderParent(folderOrder map[string][]string, nodes []ProjectTreeNodeRecord, parentID string) { + children := nodes + trimmedParentID := strings.TrimSpace(parentID) + if trimmedParentID != "" { + parent, found := findProjectTreeNode(nodes, trimmedParentID) + if !found || parent.Kind != "folder" { return } + children = parent.Children + } + parentKey := projectFolderOrderParentKey(parentID) + if len(folderOrder[parentKey]) > 0 { return } + orderedIDs := make([]string, 0, len(children)) + for _, child := range children { + trimmedChildID := strings.TrimSpace(child.ID) + if trimmedChildID == "" || slicesContains(orderedIDs, trimmedChildID) { continue } + orderedIDs = append(orderedIDs, trimmedChildID) + } + if len(orderedIDs) > 0 { folderOrder[parentKey] = orderedIDs } +} diff --git a/Backend/internal/bootstrap/project_queries.go b/Backend/internal/bootstrap/project_queries.go new file mode 100644 index 0000000..dd0afd4 --- /dev/null +++ b/Backend/internal/bootstrap/project_queries.go @@ -0,0 +1,347 @@ +// Path: Backend/internal/bootstrap/project_queries.go + +package bootstrap + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" +) + +func (service *Service) loadPrimaryOrganization(ctx context.Context) (*OrganizationRecord, error) { + var record OrganizationRecord + err := service.db.Pool.QueryRow(ctx, ` + SELECT id::text, name, slug + FROM organizations + ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC + LIMIT 1; + `, primaryOrganizationSlug).Scan(&record.ID, &record.Name, &record.Slug) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + + return nil, err + } + + return &record, nil +} + +func (service *Service) loadPrimaryDepartment(ctx context.Context) (*DepartmentRecord, error) { + var record DepartmentRecord + err := service.db.Pool.QueryRow(ctx, ` + SELECT id::text, organization_id::text, name, slug + FROM departments + ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC + LIMIT 1; + `, primaryDepartmentSlug).Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + + return nil, err + } + + return &record, nil +} + +func (service *Service) loadPrimaryTeam(ctx context.Context) (*TeamRecord, error) { + var record TeamRecord + err := service.db.Pool.QueryRow(ctx, ` + SELECT id::text, organization_id::text, department_id::text, name, slug + FROM teams + ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC + LIMIT 1; + `, primaryTeamSlug).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.Name, &record.Slug) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + + return nil, err + } + + return &record, nil +} + +func (service *Service) loadPrimaryProject(ctx context.Context) (*ProjectRecord, error) { + var record ProjectRecord + err := service.db.Pool.QueryRow(ctx, ` + SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug + FROM projects + ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC + LIMIT 1; + `, primaryProjectSlug).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + + return nil, err + } + + return &record, nil +} + +func (service *Service) listOrganizations(ctx context.Context) ([]OrganizationRecord, error) { + rows, err := service.db.Pool.Query(ctx, ` + SELECT id::text, name, slug + FROM organizations + ORDER BY created_at ASC; + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []OrganizationRecord + for rows.Next() { + var record OrganizationRecord + if err := rows.Scan(&record.ID, &record.Name, &record.Slug); err != nil { + return nil, err + } + + records = append(records, record) + } + + return records, rows.Err() +} + +func (service *Service) listDepartments(ctx context.Context) ([]DepartmentRecord, error) { + rows, err := service.db.Pool.Query(ctx, ` + SELECT id::text, organization_id::text, name, slug + FROM departments + ORDER BY created_at ASC; + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []DepartmentRecord + for rows.Next() { + var record DepartmentRecord + if err := rows.Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug); err != nil { + return nil, err + } + + records = append(records, record) + } + + return records, rows.Err() +} + +func (service *Service) listTeams(ctx context.Context) ([]TeamRecord, error) { + rows, err := service.db.Pool.Query(ctx, ` + SELECT id::text, organization_id::text, department_id::text, name, slug + FROM teams + ORDER BY created_at ASC; + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []TeamRecord + for rows.Next() { + var record TeamRecord + if err := rows.Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.Name, &record.Slug); err != nil { + return nil, err + } + + records = append(records, record) + } + + return records, rows.Err() +} + +func (service *Service) listProjects(ctx context.Context) ([]ProjectRecord, error) { + rows, err := service.db.Pool.Query(ctx, ` + SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug + FROM projects + ORDER BY created_at ASC; + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []ProjectRecord + for rows.Next() { + var record ProjectRecord + if err := rows.Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug); err != nil { + return nil, err + } + + records = append(records, record) + } + + return records, rows.Err() +} + +func (service *Service) loadProjectByID(ctx context.Context, projectID string) (*ProjectRecord, error) { + var record ProjectRecord + err := service.db.Pool.QueryRow(ctx, ` + SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug + FROM projects + WHERE id = $1::uuid + LIMIT 1; + `, projectID).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrProjectNotFound + } + + return nil, err + } + + return &record, nil +} + +func (service *Service) GetProjectHierarchyFolders(ctx context.Context, projectID string) ([]ProjectHierarchyFolderRecord, error) { + return service.getProjectHierarchyFoldersByRootPath(ctx, projectID, projectHierarchyRootPath) +} + +func (service *Service) GetProjectTreeFolders(ctx context.Context, projectID string) ([]ProjectHierarchyFolderRecord, error) { + return service.getProjectHierarchyFoldersByRootPath(ctx, projectID, projectTreeRootPath) +} + +func (service *Service) GetProjectTreeNodes(ctx context.Context, projectID string) ([]ProjectTreeNodeRecord, error) { + return service.getProjectTreeNodesByRootPath(ctx, projectID, projectTreeRootPath) +} + +func (service *Service) getProjectHierarchyFoldersByRootPath( + ctx context.Context, + projectID string, + rootPath func(projectSlug string) string, +) ([]ProjectHierarchyFolderRecord, error) { + project, err := service.loadProjectByID(ctx, projectID) + if err != nil { + return nil, err + } + + rootParentPath := rootPath(project.Slug) + + rows, err := service.db.Pool.Query(ctx, ` + SELECT + COALESCE(folder_meta.resource_id, ''), + directories.path, + COALESCE(directories.parent_path, ''), + COALESCE(folder_meta.resource_name, directories.resource_name, '') + FROM posix_nodes AS directories + LEFT JOIN posix_nodes AS folder_meta + ON folder_meta.path = directories.path || '/folder.json' + AND folder_meta.node_kind = 'file'::posix_node_kind + WHERE directories.node_kind = 'directory'::posix_node_kind + AND directories.logical_type = 'hierarchy_folder' + AND directories.project_slug = $1 + AND directories.path LIKE $2 + ORDER BY directories.depth ASC, directories.path ASC; + `, project.Slug, rootParentPath+"/%") + if err != nil { + return nil, err + } + defer rows.Close() + + var folderRows []projectHierarchyFolderRow + for rows.Next() { + var row projectHierarchyFolderRow + if err := rows.Scan(&row.ID, &row.Path, &row.ParentPath, &row.Label); err != nil { + return nil, err + } + folderRows = append(folderRows, row) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + folders := buildProjectHierarchyFolderTree(folderRows, rootParentPath) + folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath) + + return applyProjectHierarchyFolderOrdering(folders, folderOrder), nil +} + +func (service *Service) getProjectTreeNodesByRootPath( + ctx context.Context, + projectID string, + rootPath func(projectSlug string) string, +) ([]ProjectTreeNodeRecord, error) { + project, err := service.loadProjectByID(ctx, projectID) + if err != nil { + return nil, err + } + + rootParentPath := rootPath(project.Slug) + + rows, err := service.db.Pool.Query(ctx, ` + SELECT + COALESCE(node_meta.resource_id, ''), + directories.path, + COALESCE(directories.parent_path, ''), + COALESCE(node_meta.resource_name, directories.resource_name, ''), + directories.logical_type, + COALESCE(node_meta.content_json->>'type', directories.content_json->>'type', '') + FROM posix_nodes AS directories + LEFT JOIN posix_nodes AS node_meta + ON node_meta.path = directories.path || CASE + WHEN directories.logical_type = 'hierarchy_folder' THEN '/folder.json' + WHEN directories.logical_type = 'item' THEN '/item.json' + ELSE '' + END + AND node_meta.node_kind = 'file'::posix_node_kind + WHERE directories.node_kind = 'directory'::posix_node_kind + AND directories.logical_type IN ('hierarchy_folder', 'item') + AND directories.project_slug = $1 + AND directories.path LIKE $2 + ORDER BY directories.depth ASC, directories.path ASC; + `, project.Slug, rootParentPath+"/%") + if err != nil { + return nil, err + } + defer rows.Close() + + var nodeRows []projectTreeNodeRow + for rows.Next() { + var row projectTreeNodeRow + if err := rows.Scan(&row.ID, &row.Path, &row.ParentPath, &row.Label, &row.Kind, &row.ItemType); err != nil { + return nil, err + } + nodeRows = append(nodeRows, row) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + nodes := buildProjectTreeNodeTree(nodeRows, rootParentPath) + folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath) + + return applyProjectTreeNodeOrdering(nodes, folderOrder), nil +} + +func (service *Service) listWorkspaces(ctx context.Context) ([]WorkspaceRecord, error) { + rows, err := service.db.Pool.Query(ctx, ` + SELECT id::text, organization_id::text, name, slug, kind::text, department_id::text, team_id::text, project_id::text + FROM workspaces + ORDER BY created_at ASC; + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []WorkspaceRecord + for rows.Next() { + var record WorkspaceRecord + if err := rows.Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug, &record.Kind, &record.DepartmentID, &record.TeamID, &record.ProjectID); err != nil { + return nil, err + } + + records = append(records, record) + } + + return records, rows.Err() +} diff --git a/Backend/internal/bootstrap/service.go b/Backend/internal/bootstrap/service.go index 1cbad9b..96e3db4 100644 --- a/Backend/internal/bootstrap/service.go +++ b/Backend/internal/bootstrap/service.go @@ -4,325 +4,12 @@ package bootstrap import ( "context" - "encoding/json" "errors" - "fmt" - "os" - "path/filepath" - "sort" "strings" - "unicode" - "github.com/google/uuid" "github.com/jackc/pgx/v5" - - "moku-backend/internal/database" ) -const ( - primaryOrganizationSlug = "primary-organization" - primaryDepartmentSlug = "primary-department" - primaryTeamSlug = "primary-team" - primaryProjectSlug = "primary-project" - organizationWorkspaceSlug = "organization-home" - departmentWorkspaceSlug = "department-home" - teamWorkspaceSlug = "team-home" - projectWorkspaceSlug = "project-home" - defaultInstallationHost = "localhost" - defaultInstallationMode = "personal" - defaultInstallationAccess = "local" - defaultInstallationProtocol = "http" - defaultOrganizationName = "Moku" - defaultPersonalServerSuffix = "Personal" - defaultPersonalDisplayName = "Personal" - bootstrapWorkspaceKindOrg = "organization" - bootstrapWorkspaceKindDept = "department" - bootstrapWorkspaceKindTeam = "team" - bootstrapWorkspaceKindProject = "project" - projectFolderOrderRootKey = "__root__" - projectFolderOrderHierarchy = "hierarchy" - projectFolderOrderTree = "tree" -) - -var ( - ErrInstallationNotConfigured = errors.New("bootstrap installation step has not been completed") - ErrAdminNotConfigured = errors.New("bootstrap admin step has not been completed") - ErrBootstrapStructureMissing = errors.New("bootstrap structure is incomplete") - ErrProjectNotFound = errors.New("project not found") - ErrProjectFolderNotFound = errors.New("project folder not found") - ErrProjectItemNotFound = errors.New("project item not found") - ErrInvalidProjectFolderMove = errors.New("invalid project folder move") - ErrInvalidProjectItemMove = errors.New("invalid project item move") -) - -type Service struct { - db *database.DB - posixRoot string -} - -type SaveInstanceInput struct { - Protocol string - Access string - Host string -} - -type SaveModeInput struct { - Mode string - Name string -} - -type SaveAdminInput struct { - DisplayName string - Email string - Password string -} - -type SaveStructureInput struct { - OrganizationName string - DepartmentName string - TeamName string - ProjectName string -} - -type InstallationRecord struct { - ID string `json:"id"` - Name string `json:"name"` - Mode string `json:"mode"` - Access string `json:"access"` - Protocol string `json:"protocol"` - Host string `json:"host"` - IsBootstrapped bool `json:"isBootstrapped"` - MaterializationStatus string `json:"materializationStatus"` - MaterializationError *string `json:"materializationError,omitempty"` -} - -type AdminRecord struct { - ID string `json:"id"` - Email string `json:"email"` - DisplayName string `json:"displayName"` - IsInstanceAdmin bool `json:"isInstanceAdmin"` - HomeTitle string `json:"homeTitle"` -} - -type OrganizationRecord struct { - ID string `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` -} - -type DepartmentRecord struct { - ID string `json:"id"` - OrganizationID string `json:"organizationId"` - Name string `json:"name"` - Slug string `json:"slug"` -} - -type TeamRecord struct { - ID string `json:"id"` - OrganizationID string `json:"organizationId"` - DepartmentID *string `json:"departmentId,omitempty"` - Name string `json:"name"` - Slug string `json:"slug"` -} - -type ProjectRecord struct { - ID string `json:"id"` - OrganizationID string `json:"organizationId"` - DepartmentID *string `json:"departmentId,omitempty"` - TeamID *string `json:"teamId,omitempty"` - Name string `json:"name"` - Slug string `json:"slug"` -} - -type WorkspaceRecord struct { - ID string `json:"id"` - OrganizationID string `json:"organizationId"` - Name string `json:"name"` - Slug string `json:"slug"` - Kind string `json:"kind"` - DepartmentID *string `json:"departmentId,omitempty"` - TeamID *string `json:"teamId,omitempty"` - ProjectID *string `json:"projectId,omitempty"` -} - -type StructureRecord struct { - Installation InstallationRecord `json:"installation"` - Organization namedRecord `json:"organization"` - Department namedRecord `json:"department"` - Team namedRecord `json:"team"` - Project namedRecord `json:"project"` - Admin AdminSummary `json:"admin"` -} - -type AdminSummary struct { - ID string `json:"id"` - Email string `json:"email"` - DisplayName string `json:"displayName"` -} - -type BootstrapStructureState struct { - Organization *OrganizationRecord `json:"organization,omitempty"` - Department *DepartmentRecord `json:"department,omitempty"` - Team *TeamRecord `json:"team,omitempty"` - Project *ProjectRecord `json:"project,omitempty"` - Workspaces []WorkspaceRecord `json:"workspaces"` -} - -type BootstrapState struct { - Installation *InstallationRecord `json:"installation,omitempty"` - Admin *AdminRecord `json:"admin,omitempty"` - Structure BootstrapStructureState `json:"structure"` -} - -type AppShellState struct { - Installation *InstallationRecord `json:"installation,omitempty"` - Admin *AdminRecord `json:"admin,omitempty"` - Organizations []OrganizationRecord `json:"organizations"` - Departments []DepartmentRecord `json:"departments"` - Teams []TeamRecord `json:"teams"` - Projects []ProjectRecord `json:"projects"` - Workspaces []WorkspaceRecord `json:"workspaces"` -} - -type namedRecord struct { - ID string `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` -} - -type ProjectHierarchyFolderRecord struct { - ID string `json:"id"` - Path string `json:"path"` - Label string `json:"label"` - Children []ProjectHierarchyFolderRecord `json:"children"` -} - -type ProjectTreeNodeRecord struct { - ID string `json:"id"` - Path string `json:"path"` - Label string `json:"label"` - Kind string `json:"kind"` - ItemType string `json:"itemType,omitempty"` - Children []ProjectTreeNodeRecord `json:"children,omitempty"` -} - -type CreateProjectFolderInput struct { - ProjectID string - ParentFolderPath string - Name string -} - -type DeleteProjectFolderInput struct { - ProjectID string - FolderPath string -} - -type RenameProjectFolderInput struct { - ProjectID string - FolderPath string - Name string -} - -type MoveProjectFolderInput struct { - ProjectID string - FolderPath string - FolderStableID string - ParentFolderPath string - ParentStableID string - TargetIndex int -} - -type CreateProjectItemInput struct { - ProjectID string - ParentFolderPath string - Name string - ItemType string -} - -type DeleteProjectItemInput struct { - ProjectID string - ItemPath string -} - -type MoveProjectItemInput struct { - ProjectID string - ItemPath string - ItemStableID string - ParentFolderPath string - ParentStableID string - TargetIndex int -} - -type CreateProjectFolderResult struct { - ProjectID string `json:"projectId"` - CreatedFolder ProjectHierarchyFolderRecord `json:"createdFolder"` - Folders []ProjectHierarchyFolderRecord `json:"folders"` -} - -type DeleteProjectFolderResult struct { - ProjectID string `json:"projectId"` - DeletedFolderStableID string `json:"deletedFolderId"` - DeletedFolderPath string `json:"deletedFolderPath"` - Folders []ProjectHierarchyFolderRecord `json:"folders"` -} - -type RenameProjectFolderResult struct { - ProjectID string `json:"projectId"` - PreviousFolderStableID string `json:"previousFolderId"` - PreviousFolderPath string `json:"previousFolderPath"` - RenamedFolder ProjectHierarchyFolderRecord `json:"renamedFolder"` - Folders []ProjectHierarchyFolderRecord `json:"folders"` -} - -type MoveProjectFolderResult struct { - ProjectID string `json:"projectId"` - PreviousFolderStableID string `json:"previousFolderId"` - PreviousFolderPath string `json:"previousFolderPath"` - MovedFolder ProjectHierarchyFolderRecord `json:"movedFolder"` - Folders []ProjectHierarchyFolderRecord `json:"folders"` -} - -type CreateProjectItemResult struct { - ProjectID string `json:"projectId"` - CreatedItem ProjectTreeNodeRecord `json:"createdItem"` - Nodes []ProjectTreeNodeRecord `json:"nodes"` -} - -type DeleteProjectItemResult struct { - ProjectID string `json:"projectId"` - DeletedItemStableID string `json:"deletedItemId"` - DeletedItemPath string `json:"deletedItemPath"` - Nodes []ProjectTreeNodeRecord `json:"nodes"` -} - -type MoveProjectItemResult struct { - ProjectID string `json:"projectId"` - PreviousItemStableID string `json:"previousItemId"` - PreviousItemPath string `json:"previousItemPath"` - MovedItem ProjectTreeNodeRecord `json:"movedItem"` - Nodes []ProjectTreeNodeRecord `json:"nodes"` -} - -type projectHierarchyFolderRow struct { - ID string - Path string - ParentPath string - Label string -} - -type projectTreeNodeRow struct { - ID string - Path string - ParentPath string - Label string - Kind string - ItemType string -} - -func NewService(db *database.DB, posixRoot string) *Service { - return &Service{db: db, posixRoot: strings.TrimSpace(posixRoot)} -} - func (service *Service) SaveInstance(ctx context.Context, input SaveInstanceInput) (InstallationRecord, error) { row := service.db.Pool.QueryRow(ctx, ` INSERT INTO installations (singleton, name, mode, access, protocol, host) @@ -391,9 +78,7 @@ func (service *Service) SaveAdmin(ctx context.Context, input SaveAdminInput) (Ad if err != nil { return AdminRecord{}, err } - defer func() { - _ = tx.Rollback(ctx) - }() + defer func() { _ = tx.Rollback(ctx) }() if _, err := tx.Exec(ctx, ` UPDATE users @@ -452,9 +137,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn if err != nil { return StructureRecord{}, err } - defer func() { - _ = tx.Rollback(ctx) - }() + defer func() { _ = tx.Rollback(ctx) }() prerequisites, err := service.loadBootstrapStructurePrerequisites(ctx, tx) if err != nil { @@ -545,15 +228,12 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn if err := upsertWorkspace(ctx, tx, organization.ID, organization.Name, organizationWorkspaceSlug, bootstrapWorkspaceKindOrg, prerequisites.admin.ID, nil, nil, nil); err != nil { return StructureRecord{}, err } - if err := upsertWorkspace(ctx, tx, organization.ID, department.Name, departmentWorkspaceSlug, bootstrapWorkspaceKindDept, prerequisites.admin.ID, &department.ID, nil, nil); err != nil { return StructureRecord{}, err } - if err := upsertWorkspace(ctx, tx, organization.ID, team.Name, teamWorkspaceSlug, bootstrapWorkspaceKindTeam, prerequisites.admin.ID, &department.ID, &team.ID, nil); err != nil { return StructureRecord{}, err } - if err := upsertWorkspace(ctx, tx, organization.ID, project.Name, projectWorkspaceSlug, bootstrapWorkspaceKindProject, prerequisites.admin.ID, &department.ID, &team.ID, &project.ID); err != nil { return StructureRecord{}, err } @@ -586,9 +266,7 @@ func (service *Service) ResetDevelopmentState(ctx context.Context) error { if err != nil { return err } - defer func() { - _ = tx.Rollback(ctx) - }() + defer func() { _ = tx.Rollback(ctx) }() if _, err := tx.Exec(ctx, ` TRUNCATE TABLE @@ -637,7 +315,6 @@ func (service *Service) GetAdmin(ctx context.Context) (*AdminRecord, error) { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } - return nil, err } @@ -649,22 +326,18 @@ func (service *Service) GetStructure(ctx context.Context) (BootstrapStructureSta if err != nil { return BootstrapStructureState{}, err } - organization, err := service.loadPrimaryOrganization(ctx) if err != nil { return BootstrapStructureState{}, err } - department, err := service.loadPrimaryDepartment(ctx) if err != nil { return BootstrapStructureState{}, err } - team, err := service.loadPrimaryTeam(ctx) if err != nil { return BootstrapStructureState{}, err } - project, err := service.loadPrimaryProject(ctx) if err != nil { return BootstrapStructureState{}, err @@ -678,2382 +351,3 @@ func (service *Service) GetStructure(ctx context.Context) (BootstrapStructureSta Workspaces: workspaces, }, nil } -func (service *Service) loadPrimaryOrganization(ctx context.Context) (*OrganizationRecord, error) { - var record OrganizationRecord - err := service.db.Pool.QueryRow(ctx, ` - SELECT id::text, name, slug - FROM organizations - ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC - LIMIT 1; - `, primaryOrganizationSlug).Scan(&record.ID, &record.Name, &record.Slug) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil - } - - return nil, err - } - - return &record, nil -} - -func (service *Service) loadPrimaryDepartment(ctx context.Context) (*DepartmentRecord, error) { - var record DepartmentRecord - err := service.db.Pool.QueryRow(ctx, ` - SELECT id::text, organization_id::text, name, slug - FROM departments - ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC - LIMIT 1; - `, primaryDepartmentSlug).Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil - } - - return nil, err - } - - return &record, nil -} - -func (service *Service) loadPrimaryTeam(ctx context.Context) (*TeamRecord, error) { - var record TeamRecord - err := service.db.Pool.QueryRow(ctx, ` - SELECT id::text, organization_id::text, department_id::text, name, slug - FROM teams - ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC - LIMIT 1; - `, primaryTeamSlug).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.Name, &record.Slug) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil - } - - return nil, err - } - - return &record, nil -} - -func (service *Service) loadPrimaryProject(ctx context.Context) (*ProjectRecord, error) { - var record ProjectRecord - err := service.db.Pool.QueryRow(ctx, ` - SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug - FROM projects - ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC - LIMIT 1; - `, primaryProjectSlug).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil - } - - return nil, err - } - - return &record, nil -} - -func (service *Service) listOrganizations(ctx context.Context) ([]OrganizationRecord, error) { - rows, err := service.db.Pool.Query(ctx, ` - SELECT id::text, name, slug - FROM organizations - ORDER BY created_at ASC; - `) - if err != nil { - return nil, err - } - defer rows.Close() - - var records []OrganizationRecord - for rows.Next() { - var record OrganizationRecord - if err := rows.Scan(&record.ID, &record.Name, &record.Slug); err != nil { - return nil, err - } - - records = append(records, record) - } - - return records, rows.Err() -} - -func (service *Service) listDepartments(ctx context.Context) ([]DepartmentRecord, error) { - rows, err := service.db.Pool.Query(ctx, ` - SELECT id::text, organization_id::text, name, slug - FROM departments - ORDER BY created_at ASC; - `) - if err != nil { - return nil, err - } - defer rows.Close() - - var records []DepartmentRecord - for rows.Next() { - var record DepartmentRecord - if err := rows.Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug); err != nil { - return nil, err - } - - records = append(records, record) - } - - return records, rows.Err() -} - -func (service *Service) listTeams(ctx context.Context) ([]TeamRecord, error) { - rows, err := service.db.Pool.Query(ctx, ` - SELECT id::text, organization_id::text, department_id::text, name, slug - FROM teams - ORDER BY created_at ASC; - `) - if err != nil { - return nil, err - } - defer rows.Close() - - var records []TeamRecord - for rows.Next() { - var record TeamRecord - if err := rows.Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.Name, &record.Slug); err != nil { - return nil, err - } - - records = append(records, record) - } - - return records, rows.Err() -} - -func (service *Service) listProjects(ctx context.Context) ([]ProjectRecord, error) { - rows, err := service.db.Pool.Query(ctx, ` - SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug - FROM projects - ORDER BY created_at ASC; - `) - if err != nil { - return nil, err - } - defer rows.Close() - - var records []ProjectRecord - for rows.Next() { - var record ProjectRecord - if err := rows.Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug); err != nil { - return nil, err - } - - records = append(records, record) - } - - return records, rows.Err() -} - -func (service *Service) loadProjectByID(ctx context.Context, projectID string) (*ProjectRecord, error) { - var record ProjectRecord - err := service.db.Pool.QueryRow(ctx, ` - SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug - FROM projects - WHERE id = $1::uuid - LIMIT 1; - `, projectID).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrProjectNotFound - } - - return nil, err - } - - return &record, nil -} - -func (service *Service) GetProjectHierarchyFolders(ctx context.Context, projectID string) ([]ProjectHierarchyFolderRecord, error) { - return service.getProjectHierarchyFoldersByRootPath(ctx, projectID, projectHierarchyRootPath) -} - -func (service *Service) GetProjectTreeFolders(ctx context.Context, projectID string) ([]ProjectHierarchyFolderRecord, error) { - return service.getProjectHierarchyFoldersByRootPath(ctx, projectID, projectTreeRootPath) -} - -func (service *Service) GetProjectTreeNodes(ctx context.Context, projectID string) ([]ProjectTreeNodeRecord, error) { - return service.getProjectTreeNodesByRootPath(ctx, projectID, projectTreeRootPath) -} - -func (service *Service) getProjectHierarchyFoldersByRootPath( - ctx context.Context, - projectID string, - rootPath func(projectSlug string) string, -) ([]ProjectHierarchyFolderRecord, error) { - project, err := service.loadProjectByID(ctx, projectID) - if err != nil { - return nil, err - } - - rootParentPath := rootPath(project.Slug) - - rows, err := service.db.Pool.Query(ctx, ` - SELECT - COALESCE(folder_meta.resource_id, ''), - directories.path, - COALESCE(directories.parent_path, ''), - COALESCE(folder_meta.resource_name, directories.resource_name, '') - FROM posix_nodes AS directories - LEFT JOIN posix_nodes AS folder_meta - ON folder_meta.path = directories.path || '/folder.json' - AND folder_meta.node_kind = 'file'::posix_node_kind - WHERE directories.node_kind = 'directory'::posix_node_kind - AND directories.logical_type = 'hierarchy_folder' - AND directories.project_slug = $1 - AND directories.path LIKE $2 - ORDER BY directories.depth ASC, directories.path ASC; - `, project.Slug, rootParentPath+"/%") - if err != nil { - return nil, err - } - defer rows.Close() - - var folderRows []projectHierarchyFolderRow - for rows.Next() { - var row projectHierarchyFolderRow - if err := rows.Scan(&row.ID, &row.Path, &row.ParentPath, &row.Label); err != nil { - return nil, err - } - folderRows = append(folderRows, row) - } - - if err := rows.Err(); err != nil { - return nil, err - } - - // The projection gives us an unordered tree snapshot. Sibling order is stored in - // the project settings file, so the read path has to rebuild the tree first and - // then apply persisted ordering on top. - folders := buildProjectHierarchyFolderTree(folderRows, rootParentPath) - folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath) - - return applyProjectHierarchyFolderOrdering(folders, folderOrder), nil -} - -func (service *Service) getProjectTreeNodesByRootPath( - ctx context.Context, - projectID string, - rootPath func(projectSlug string) string, -) ([]ProjectTreeNodeRecord, error) { - project, err := service.loadProjectByID(ctx, projectID) - if err != nil { - return nil, err - } - - rootParentPath := rootPath(project.Slug) - - rows, err := service.db.Pool.Query(ctx, ` - SELECT - COALESCE(node_meta.resource_id, ''), - directories.path, - COALESCE(directories.parent_path, ''), - COALESCE(node_meta.resource_name, directories.resource_name, ''), - directories.logical_type, - COALESCE(node_meta.content_json->>'type', directories.content_json->>'type', '') - FROM posix_nodes AS directories - LEFT JOIN posix_nodes AS node_meta - ON node_meta.path = directories.path || CASE - WHEN directories.logical_type = 'hierarchy_folder' THEN '/folder.json' - WHEN directories.logical_type = 'item' THEN '/item.json' - ELSE '' - END - AND node_meta.node_kind = 'file'::posix_node_kind - WHERE directories.node_kind = 'directory'::posix_node_kind - AND directories.logical_type IN ('hierarchy_folder', 'item') - AND directories.project_slug = $1 - AND directories.path LIKE $2 - ORDER BY directories.depth ASC, directories.path ASC; - `, project.Slug, rootParentPath+"/%") - if err != nil { - return nil, err - } - defer rows.Close() - - var nodeRows []projectTreeNodeRow - for rows.Next() { - var row projectTreeNodeRow - if err := rows.Scan(&row.ID, &row.Path, &row.ParentPath, &row.Label, &row.Kind, &row.ItemType); err != nil { - return nil, err - } - nodeRows = append(nodeRows, row) - } - - if err := rows.Err(); err != nil { - return nil, err - } - - nodes := buildProjectTreeNodeTree(nodeRows, rootParentPath) - folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath) - - return applyProjectTreeNodeOrdering(nodes, folderOrder), nil -} - -func (service *Service) CreateProjectFolder(ctx context.Context, input CreateProjectFolderInput) (CreateProjectFolderResult, error) { - return service.createProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.createProjectHierarchyFolderOnDisk) -} - -func (service *Service) CreateProjectTreeFolder(ctx context.Context, input CreateProjectFolderInput) (CreateProjectFolderResult, error) { - return service.createProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.createProjectTreeFolderOnDisk) -} - -func (service *Service) DeleteProjectFolder(ctx context.Context, input DeleteProjectFolderInput) (DeleteProjectFolderResult, error) { - return service.deleteProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.deleteProjectHierarchyFolderOnDisk) -} - -func (service *Service) DeleteProjectTreeFolder(ctx context.Context, input DeleteProjectFolderInput) (DeleteProjectFolderResult, error) { - return service.deleteProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.deleteProjectTreeFolderOnDisk) -} - -func (service *Service) RenameProjectFolder(ctx context.Context, input RenameProjectFolderInput) (RenameProjectFolderResult, error) { - return service.renameProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.renameProjectHierarchyFolderOnDisk) -} - -func (service *Service) RenameProjectTreeFolder(ctx context.Context, input RenameProjectFolderInput) (RenameProjectFolderResult, error) { - return service.renameProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.renameProjectTreeFolderOnDisk) -} - -func (service *Service) MoveProjectFolder(ctx context.Context, input MoveProjectFolderInput) (MoveProjectFolderResult, error) { - return service.moveProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.moveProjectHierarchyFolderOnDisk) -} - -func (service *Service) MoveProjectTreeFolder(ctx context.Context, input MoveProjectFolderInput) (MoveProjectFolderResult, error) { - return service.moveProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.moveProjectTreeFolderOnDisk) -} - -func (service *Service) CreateProjectTreeItem(ctx context.Context, input CreateProjectItemInput) (CreateProjectItemResult, error) { - return service.createProjectTreeItem(ctx, input, projectTreeRootPath) -} - -func (service *Service) DeleteProjectTreeItem(ctx context.Context, input DeleteProjectItemInput) (DeleteProjectItemResult, error) { - return service.deleteProjectTreeItem(ctx, input, projectTreeRootPath) -} - -func (service *Service) MoveProjectTreeItem(ctx context.Context, input MoveProjectItemInput) (MoveProjectItemResult, error) { - return service.moveProjectTreeItem(ctx, input, projectTreeRootPath) -} - -func (service *Service) createProjectHierarchyFolder( - ctx context.Context, - input CreateProjectFolderInput, - rootPath func(projectSlug string) string, - createOnDisk func(projectSlug, parentFolderPath, name string) (string, string, error), -) (CreateProjectFolderResult, error) { - // Folder mutations follow the same pattern: - // 1. validate/resolve against the current ordered tree - // 2. mutate POSIX on disk - // 3. rebuild the projection snapshot - // 4. rewrite sibling ordering metadata - // 5. re-read the ordered tree that the frontend should trust - project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) - if err != nil { - return CreateProjectFolderResult{}, err - } - - currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) - if err != nil { - return CreateProjectFolderResult{}, err - } - - parentOrderID := "" - trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath) - if trimmedParentFolderPath != "" { - parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath) - if !found { - return CreateProjectFolderResult{}, ErrProjectFolderNotFound - } - parentOrderID = parentFolder.ID - } - - createdPath, _, err := createOnDisk(project.Slug, strings.TrimSpace(input.ParentFolderPath), input.Name) - if err != nil { - return CreateProjectFolderResult{}, err - } - - if err := service.rebuildProjection(ctx); err != nil { - return CreateProjectFolderResult{}, err - } - - folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) - if err != nil { - return CreateProjectFolderResult{}, err - } - - createdFolder, ok := findProjectHierarchyFolderByPath(folders, createdPath) - if !ok { - return CreateProjectFolderResult{}, fmt.Errorf("created project folder missing from projection") - } - - folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug)) - seedFolderOrderParent(folderOrder, currentFolders, parentOrderID) - insertFolderOrder(folderOrder, parentOrderID, createdFolder.ID, len(folderOrderChildren(folderOrder, parentOrderID))) - if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil { - return CreateProjectFolderResult{}, err - } - - folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) - if err != nil { - return CreateProjectFolderResult{}, err - } - - createdFolder, ok = findProjectHierarchyFolderByPath(folders, createdPath) - if !ok { - return CreateProjectFolderResult{}, fmt.Errorf("created project folder missing from ordered projection") - } - - return CreateProjectFolderResult{ - ProjectID: project.ID, - CreatedFolder: createdFolder, - Folders: folders, - }, nil -} - -func (service *Service) deleteProjectHierarchyFolder( - ctx context.Context, - input DeleteProjectFolderInput, - rootPath func(projectSlug string) string, - deleteOnDisk func(projectSlug, folderPath string) (string, error), -) (DeleteProjectFolderResult, error) { - project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) - if err != nil { - return DeleteProjectFolderResult{}, err - } - - currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) - if err != nil { - return DeleteProjectFolderResult{}, err - } - - deletedFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath)) - if !found { - return DeleteProjectFolderResult{}, ErrProjectFolderNotFound - } - - deletedFolderPath, err := deleteOnDisk(project.Slug, input.FolderPath) - if err != nil { - return DeleteProjectFolderResult{}, err - } - - if err := service.rebuildProjection(ctx); err != nil { - return DeleteProjectFolderResult{}, err - } - - folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) - if err != nil { - return DeleteProjectFolderResult{}, err - } - - if _, found := findProjectHierarchyFolderByPath(folders, deletedFolderPath); found { - return DeleteProjectFolderResult{}, fmt.Errorf("deleted project folder still present in projection") - } - - folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug)) - removeFolderOrder(folderOrder, deletedFolder.ID) - if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil { - return DeleteProjectFolderResult{}, err - } - - folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) - if err != nil { - return DeleteProjectFolderResult{}, err - } - - return DeleteProjectFolderResult{ - ProjectID: project.ID, - DeletedFolderStableID: deletedFolder.ID, - DeletedFolderPath: deletedFolderPath, - Folders: folders, - }, nil -} - -func (service *Service) renameProjectHierarchyFolder( - ctx context.Context, - input RenameProjectFolderInput, - rootPath func(projectSlug string) string, - renameOnDisk func(projectSlug, folderPath, name string) (string, string, error), -) (RenameProjectFolderResult, error) { - project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) - if err != nil { - return RenameProjectFolderResult{}, err - } - - previousFolderPath, renamedFolderPath, err := renameOnDisk(project.Slug, input.FolderPath, input.Name) - if err != nil { - return RenameProjectFolderResult{}, err - } - - if err := service.rebuildProjection(ctx); err != nil { - return RenameProjectFolderResult{}, err - } - - folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) - if err != nil { - return RenameProjectFolderResult{}, err - } - - renamedFolder, found := findProjectHierarchyFolderByPath(folders, renamedFolderPath) - if !found { - return RenameProjectFolderResult{}, fmt.Errorf("renamed project folder missing from projection") - } - - if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found { - return RenameProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection") - } - - return RenameProjectFolderResult{ - ProjectID: project.ID, - PreviousFolderStableID: renamedFolder.ID, - PreviousFolderPath: previousFolderPath, - RenamedFolder: renamedFolder, - Folders: folders, - }, nil -} - -func (service *Service) moveProjectHierarchyFolder( - ctx context.Context, - input MoveProjectFolderInput, - rootPath func(projectSlug string) string, - moveOnDisk func(projectSlug, folderPath, parentFolderPath string) (string, string, error), -) (MoveProjectFolderResult, error) { - project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) - if err != nil { - return MoveProjectFolderResult{}, err - } - - currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) - if err != nil { - return MoveProjectFolderResult{}, err - } - - currentFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath)) - if !found { - return MoveProjectFolderResult{}, ErrProjectFolderNotFound - } - - movedFolderStableID := currentFolder.ID - providedFolderStableID := strings.TrimSpace(input.FolderStableID) - if providedFolderStableID != "" && providedFolderStableID != movedFolderStableID { - return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove - } - - parentOrderID := "" - trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath) - providedParentStableID := strings.TrimSpace(input.ParentStableID) - if trimmedParentFolderPath != "" { - parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath) - if !found { - return MoveProjectFolderResult{}, ErrProjectFolderNotFound - } - parentOrderID = parentFolder.ID - if providedParentStableID != "" && providedParentStableID != parentOrderID { - return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove - } - } else if providedParentStableID != "" { - return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove - } - - previousFolderPath, movedFolderPath, err := moveOnDisk(project.Slug, input.FolderPath, input.ParentFolderPath) - if err != nil { - return MoveProjectFolderResult{}, err - } - - if err := service.rebuildProjection(ctx); err != nil { - return MoveProjectFolderResult{}, err - } - - folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) - if err != nil { - return MoveProjectFolderResult{}, err - } - - movedFolder, found := findProjectHierarchyFolderByPath(folders, movedFolderPath) - if !found { - return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from projection") - } - - if previousFolderPath != movedFolderPath { - if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found { - return MoveProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection") - } - } - - folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug)) - seedFolderOrderParent(folderOrder, currentFolders, parentOrderID) - removeFolderOrderReference(folderOrder, movedFolderStableID) - removeFolderOrderReference(folderOrder, movedFolder.ID) - insertFolderOrder(folderOrder, parentOrderID, movedFolder.ID, input.TargetIndex) - if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil { - return MoveProjectFolderResult{}, err - } - - folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) - if err != nil { - return MoveProjectFolderResult{}, err - } - - movedFolder, found = findProjectHierarchyFolderByPath(folders, movedFolderPath) - if !found { - return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from ordered projection") - } - - return MoveProjectFolderResult{ - ProjectID: project.ID, - PreviousFolderStableID: movedFolder.ID, - PreviousFolderPath: previousFolderPath, - MovedFolder: movedFolder, - Folders: folders, - }, nil -} - -func (service *Service) createProjectTreeItem( - ctx context.Context, - input CreateProjectItemInput, - rootPath func(projectSlug string) string, -) (CreateProjectItemResult, error) { - project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) - if err != nil { - return CreateProjectItemResult{}, err - } - - currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath) - if err != nil { - return CreateProjectItemResult{}, err - } - - parentOrderID := "" - trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath) - if trimmedParentFolderPath != "" { - parentFolder, found := findProjectTreeFolderByPath(currentNodes, trimmedParentFolderPath) - if !found { - return CreateProjectItemResult{}, ErrProjectFolderNotFound - } - parentOrderID = parentFolder.ID - } - - createdPath, err := service.createProjectTreeItemOnDisk(project.Slug, trimmedParentFolderPath, input.Name, input.ItemType) - if err != nil { - return CreateProjectItemResult{}, err - } - - if err := service.rebuildProjection(ctx); err != nil { - return CreateProjectItemResult{}, err - } - - nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath) - if err != nil { - return CreateProjectItemResult{}, err - } - - createdItem, ok := findProjectTreeNodeByPath(nodes, createdPath) - if !ok || createdItem.Kind != "item" { - return CreateProjectItemResult{}, fmt.Errorf("created project item missing from projection") - } - - folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug)) - seedProjectTreeOrderParent(folderOrder, currentNodes, parentOrderID) - insertFolderOrder(folderOrder, parentOrderID, createdItem.ID, len(folderOrderChildren(folderOrder, parentOrderID))) - if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil { - return CreateProjectItemResult{}, err - } - - nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath) - if err != nil { - return CreateProjectItemResult{}, err - } - - createdItem, ok = findProjectTreeNodeByPath(nodes, createdPath) - if !ok || createdItem.Kind != "item" { - return CreateProjectItemResult{}, fmt.Errorf("created project item missing from ordered projection") - } - - return CreateProjectItemResult{ - ProjectID: project.ID, - CreatedItem: createdItem, - Nodes: nodes, - }, nil -} - -func (service *Service) deleteProjectTreeItem( - ctx context.Context, - input DeleteProjectItemInput, - rootPath func(projectSlug string) string, -) (DeleteProjectItemResult, error) { - project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) - if err != nil { - return DeleteProjectItemResult{}, err - } - - currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath) - if err != nil { - return DeleteProjectItemResult{}, err - } - - deletedItem, found := findProjectTreeNodeByPath(currentNodes, strings.TrimSpace(input.ItemPath)) - if !found || deletedItem.Kind != "item" { - return DeleteProjectItemResult{}, ErrProjectItemNotFound - } - - deletedItemPath, err := service.deleteProjectTreeItemOnDisk(project.Slug, input.ItemPath) - if err != nil { - return DeleteProjectItemResult{}, err - } - - if err := service.rebuildProjection(ctx); err != nil { - return DeleteProjectItemResult{}, err - } - - nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath) - if err != nil { - return DeleteProjectItemResult{}, err - } - - if _, found := findProjectTreeNodeByPath(nodes, deletedItemPath); found { - return DeleteProjectItemResult{}, fmt.Errorf("deleted project item still present in projection") - } - - folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug)) - removeFolderOrderReference(folderOrder, deletedItem.ID) - if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil { - return DeleteProjectItemResult{}, err - } - - nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath) - if err != nil { - return DeleteProjectItemResult{}, err - } - - return DeleteProjectItemResult{ - ProjectID: project.ID, - DeletedItemStableID: deletedItem.ID, - DeletedItemPath: deletedItemPath, - Nodes: nodes, - }, nil -} - -func (service *Service) moveProjectTreeItem( - ctx context.Context, - input MoveProjectItemInput, - rootPath func(projectSlug string) string, -) (MoveProjectItemResult, error) { - project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) - if err != nil { - return MoveProjectItemResult{}, err - } - - currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath) - if err != nil { - return MoveProjectItemResult{}, err - } - - currentItem, found := findProjectTreeNodeByPath(currentNodes, strings.TrimSpace(input.ItemPath)) - if !found || currentItem.Kind != "item" { - return MoveProjectItemResult{}, ErrProjectItemNotFound - } - - movedItemStableID := currentItem.ID - providedItemStableID := strings.TrimSpace(input.ItemStableID) - if providedItemStableID != "" && providedItemStableID != movedItemStableID { - return MoveProjectItemResult{}, ErrInvalidProjectItemMove - } - - parentOrderID := "" - trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath) - providedParentStableID := strings.TrimSpace(input.ParentStableID) - if trimmedParentFolderPath != "" { - parentFolder, found := findProjectTreeFolderByPath(currentNodes, trimmedParentFolderPath) - if !found { - return MoveProjectItemResult{}, ErrProjectFolderNotFound - } - parentOrderID = parentFolder.ID - if providedParentStableID != "" && providedParentStableID != parentOrderID { - return MoveProjectItemResult{}, ErrInvalidProjectItemMove - } - } else if providedParentStableID != "" { - return MoveProjectItemResult{}, ErrInvalidProjectItemMove - } - - previousItemPath, movedItemPath, err := service.moveProjectTreeItemOnDisk(project.Slug, input.ItemPath, input.ParentFolderPath) - if err != nil { - return MoveProjectItemResult{}, err - } - - if err := service.rebuildProjection(ctx); err != nil { - return MoveProjectItemResult{}, err - } - - nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath) - if err != nil { - return MoveProjectItemResult{}, err - } - - movedItem, found := findProjectTreeNodeByPath(nodes, movedItemPath) - if !found || movedItem.Kind != "item" { - return MoveProjectItemResult{}, fmt.Errorf("moved project item missing from projection") - } - - if previousItemPath != movedItemPath { - if _, found := findProjectTreeNodeByPath(nodes, previousItemPath); found { - return MoveProjectItemResult{}, fmt.Errorf("previous project item path still present in projection") - } - } - - folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug)) - seedProjectTreeOrderParent(folderOrder, currentNodes, parentOrderID) - removeFolderOrderReference(folderOrder, movedItemStableID) - removeFolderOrderReference(folderOrder, movedItem.ID) - insertFolderOrder(folderOrder, parentOrderID, movedItem.ID, input.TargetIndex) - if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil { - return MoveProjectItemResult{}, err - } - - nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath) - if err != nil { - return MoveProjectItemResult{}, err - } - - movedItem, found = findProjectTreeNodeByPath(nodes, movedItemPath) - if !found || movedItem.Kind != "item" { - return MoveProjectItemResult{}, fmt.Errorf("moved project item missing from ordered projection") - } - - return MoveProjectItemResult{ - ProjectID: project.ID, - PreviousItemStableID: movedItem.ID, - PreviousItemPath: previousItemPath, - MovedItem: movedItem, - Nodes: nodes, - }, nil -} - -func (service *Service) listWorkspaces(ctx context.Context) ([]WorkspaceRecord, error) { - rows, err := service.db.Pool.Query(ctx, ` - SELECT id::text, organization_id::text, name, slug, kind::text, department_id::text, team_id::text, project_id::text - FROM workspaces - ORDER BY created_at ASC; - `) - if err != nil { - return nil, err - } - defer rows.Close() - - var records []WorkspaceRecord - for rows.Next() { - var record WorkspaceRecord - if err := rows.Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug, &record.Kind, &record.DepartmentID, &record.TeamID, &record.ProjectID); err != nil { - return nil, err - } - - records = append(records, record) - } - - return records, rows.Err() -} - -func upsertNamedRecord(ctx context.Context, tx pgx.Tx, query string, args ...any) (namedRecord, error) { - var record namedRecord - if err := tx.QueryRow(ctx, query, args...).Scan(&record.ID, &record.Name, &record.Slug); err != nil { - return namedRecord{}, err - } - - return record, nil -} - -func upsertWorkspace(ctx context.Context, tx pgx.Tx, organizationID, name, slug, kind, createdByUserID string, departmentID, teamID, projectID *string) error { - _, err := tx.Exec(ctx, ` - INSERT INTO workspaces (organization_id, name, slug, kind, created_by_user_id, department_id, team_id, project_id) - VALUES ($1::uuid, $2, $3, $4::workspace_kind, $5::uuid, $6::uuid, $7::uuid, $8::uuid) - ON CONFLICT (organization_id, slug) DO UPDATE - SET - name = EXCLUDED.name, - kind = EXCLUDED.kind, - created_by_user_id = EXCLUDED.created_by_user_id, - department_id = EXCLUDED.department_id, - team_id = EXCLUDED.team_id, - project_id = EXCLUDED.project_id, - updated_at = NOW(); - `, organizationID, name, slug, kind, createdByUserID, departmentID, teamID, projectID) - - return err -} - -func defaultRootOrganizationName(installationName, mode, host, adminDisplayName string) string { - trimmedInstallationName := strings.TrimSpace(installationName) - trimmedHost := strings.TrimSpace(host) - trimmedAdminDisplayName := strings.TrimSpace(adminDisplayName) - - if trimmedInstallationName != "" { - return trimmedInstallationName - } - - if strings.EqualFold(mode, defaultInstallationMode) { - if trimmedAdminDisplayName != "" { - return fmt.Sprintf("%s %s", trimmedAdminDisplayName, defaultPersonalServerSuffix) - } - - return defaultPersonalDisplayName - } - - if trimmedHost != "" { - return trimmedHost - } - - return defaultOrganizationName -} - -func personalHomeTitle(displayName string) string { - trimmedDisplayName := strings.TrimSpace(displayName) - if trimmedDisplayName == "" { - return "Home" - } - - if strings.HasSuffix(strings.ToLower(trimmedDisplayName), "s") { - return fmt.Sprintf("%s' Home", trimmedDisplayName) - } - - return fmt.Sprintf("%s's Home", trimmedDisplayName) -} - -func (service *Service) ensureBootstrapPOSIXSkeleton( - installation InstallationRecord, - admin AdminSummary, - organization namedRecord, - department namedRecord, - team namedRecord, - project namedRecord, -) error { - rootPath := strings.TrimSpace(service.posixRoot) - if rootPath == "" { - return nil - } - - if err := os.MkdirAll(rootPath, 0o755); err != nil { - return fmt.Errorf("create POSIX root: %w", err) - } - - if err := writeJSONFile(filepath.Join(rootPath, "settings.json"), map[string]any{ - "installation": map[string]any{ - "id": installation.ID, - "name": installation.Name, - "mode": installation.Mode, - "access": installation.Access, - "protocol": installation.Protocol, - "host": installation.Host, - "isBootstrapped": installation.IsBootstrapped, - }, - "organization": map[string]any{ - "id": organization.ID, - "name": organization.Name, - "slug": organization.Slug, - }, - }); err != nil { - return fmt.Errorf("write tenant settings.json: %w", err) - } - - if err := writeJSONFile(filepath.Join(rootPath, "layout.json"), map[string]any{ - "version": 1, - "type": "tenant-layout", - "home": map[string]any{ - "defaultProjectSlug": project.Slug, - }, - }); err != nil { - return fmt.Errorf("write tenant layout.json: %w", err) - } - - if err := os.MkdirAll(filepath.Join(rootPath, "catalog", "packs"), 0o755); err != nil { - return fmt.Errorf("create catalog packs root: %w", err) - } - - if err := os.MkdirAll(filepath.Join(rootPath, "catalog", "standalone"), 0o755); err != nil { - return fmt.Errorf("create catalog standalone root: %w", err) - } - - departmentPath := filepath.Join(rootPath, "departments", slugDir("department", department.Slug)) - teamPath := filepath.Join(departmentPath, "teams", slugDir("team", team.Slug)) - projectPath := filepath.Join(rootPath, "projects", slugDir("project", project.Slug)) - usersPath := filepath.Join(rootPath, "users") - personalName := strings.TrimSpace(admin.DisplayName) - if personalName == "" { - personalName = defaultPersonalDisplayName - } - personalSlug := normalizePOSIXSlug(personalName) - personalHomePath := filepath.Join(usersPath, "personals", slugDir("personal", personalSlug)) - - for _, dirPath := range []string{ - departmentPath, - teamPath, - projectPath, - filepath.Join(projectPath, "children"), - filepath.Join(projectPath, "tree"), - filepath.Join(usersPath, "personals"), - personalHomePath, - filepath.Join(personalHomePath, "tree"), - } { - if err := os.MkdirAll(dirPath, 0o755); err != nil { - return fmt.Errorf("create POSIX directory %s: %w", dirPath, err) - } - } - - if err := writeJSONFile(filepath.Join(departmentPath, "settings.json"), map[string]any{ - "id": department.ID, - "name": department.Name, - "slug": department.Slug, - "type": "department", - }); err != nil { - return fmt.Errorf("write department settings.json: %w", err) - } - - if err := writeJSONFile(filepath.Join(departmentPath, "users.json"), map[string]any{ - "owners": []map[string]string{{ - "id": admin.ID, - "email": admin.Email, - "displayName": admin.DisplayName, - }}, - }); err != nil { - return fmt.Errorf("write department users.json: %w", err) - } - - if err := writeJSONFile(filepath.Join(teamPath, "settings.json"), map[string]any{ - "id": team.ID, - "name": team.Name, - "slug": team.Slug, - "type": "team", - }); err != nil { - return fmt.Errorf("write team settings.json: %w", err) - } - - if err := writeJSONFile(filepath.Join(teamPath, "users.json"), map[string]any{ - "owners": []map[string]string{{ - "id": admin.ID, - "email": admin.Email, - "displayName": admin.DisplayName, - }}, - }); err != nil { - return fmt.Errorf("write team users.json: %w", err) - } - - if err := writeJSONFile(filepath.Join(projectPath, "settings.json"), map[string]any{ - "id": project.ID, - "name": project.Name, - "slug": project.Slug, - "type": "project", - }); err != nil { - return fmt.Errorf("write project settings.json: %w", err) - } - - if err := writeJSONFile(filepath.Join(projectPath, "home.json"), map[string]any{ - "type": "project-home", - "project": project.Slug, - "widgets": []any{}, - }); err != nil { - return fmt.Errorf("write project home.json: %w", err) - } - - if err := writeJSONFile(filepath.Join(projectPath, "acl.json"), map[string]any{ - "version": 1, - "inherits": true, - "rules": []any{}, - }); err != nil { - return fmt.Errorf("write project acl.json: %w", err) - } - - if err := writeJSONFile(filepath.Join(usersPath, "settings.json"), map[string]any{ - "primaryAdminId": admin.ID, - }); err != nil { - return fmt.Errorf("write users settings.json: %w", err) - } - - if err := writeJSONFile(filepath.Join(usersPath, "data.json"), map[string]any{ - "users": []map[string]string{{ - "id": admin.ID, - "email": admin.Email, - "displayName": admin.DisplayName, - }}, - }); err != nil { - return fmt.Errorf("write users data.json: %w", err) - } - - if err := writeJSONFile(filepath.Join(personalHomePath, "settings.json"), map[string]any{ - "id": admin.ID, - "name": personalName, - "slug": personalSlug, - "type": "personal", - "ownerUserId": admin.ID, - "email": admin.Email, - }); err != nil { - return fmt.Errorf("write personal settings.json: %w", err) - } - - if err := writeJSONFile(filepath.Join(personalHomePath, "layout.json"), map[string]any{ - "version": 1, - "type": "personal-layout", - }); err != nil { - return fmt.Errorf("write personal layout.json: %w", err) - } - - if err := writeJSONFile(filepath.Join(personalHomePath, "home.json"), map[string]any{ - "type": "personal-home", - "title": personalHomeTitle(personalName), - "owner": map[string]any{ - "id": admin.ID, - "email": admin.Email, - "displayName": personalName, - }, - "widgets": []any{}, - }); err != nil { - return fmt.Errorf("write personal home.json: %w", err) - } - - return nil -} - -func (service *Service) createProjectHierarchyFolderOnDisk(projectSlug, parentFolderID, name string) (string, string, error) { - return service.createProjectFolderOnDisk(projectSlug, parentFolderID, name, projectHierarchyRootPath) -} - -func (service *Service) createProjectTreeFolderOnDisk(projectSlug, parentFolderID, name string) (string, string, error) { - return service.createProjectFolderOnDisk(projectSlug, parentFolderID, name, projectTreeRootPath) -} - -func (service *Service) deleteProjectHierarchyFolderOnDisk(projectSlug, folderID string) (string, error) { - return service.deleteProjectFolderOnDisk(projectSlug, folderID, projectHierarchyRootPath) -} - -func (service *Service) deleteProjectTreeFolderOnDisk(projectSlug, folderID string) (string, error) { - return service.deleteProjectFolderOnDisk(projectSlug, folderID, projectTreeRootPath) -} - -func (service *Service) renameProjectHierarchyFolderOnDisk(projectSlug, folderID, name string) (string, string, error) { - return service.renameProjectFolderOnDisk(projectSlug, folderID, name, projectHierarchyRootPath) -} - -func (service *Service) renameProjectTreeFolderOnDisk(projectSlug, folderID, name string) (string, string, error) { - return service.renameProjectFolderOnDisk(projectSlug, folderID, name, projectTreeRootPath) -} - -func (service *Service) moveProjectHierarchyFolderOnDisk(projectSlug, folderID, parentFolderID string) (string, string, error) { - return service.moveProjectFolderOnDisk(projectSlug, folderID, parentFolderID, projectHierarchyRootPath) -} - -func (service *Service) moveProjectTreeFolderOnDisk(projectSlug, folderID, parentFolderID string) (string, string, error) { - return service.moveProjectFolderOnDisk(projectSlug, folderID, parentFolderID, projectTreeRootPath) -} - -func (service *Service) createProjectFolderOnDisk( - projectSlug, parentFolderID, name string, - rootPathBuilder func(projectSlug string) string, -) (string, string, error) { - posixRoot := strings.TrimSpace(service.posixRoot) - if posixRoot == "" { - return "", "", fmt.Errorf("POSIX root is not configured") - } - - trimmedName := strings.TrimSpace(name) - if trimmedName == "" { - return "", "", fmt.Errorf("folder name is required") - } - - containerProjectionPath := rootPathBuilder(projectSlug) - parentDir := filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath)) - - if strings.TrimSpace(parentFolderID) != "" { - containerProjectionPath = filepath.ToSlash(filepath.Join(strings.TrimSpace(parentFolderID), "children")) - parentDir = filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath)) - info, err := os.Stat(parentDir) - if err != nil { - if os.IsNotExist(err) { - return "", "", ErrProjectFolderNotFound - } - return "", "", fmt.Errorf("stat parent project folder: %w", err) - } - if !info.IsDir() { - return "", "", ErrProjectFolderNotFound - } - } - - if err := os.MkdirAll(parentDir, 0o755); err != nil { - return "", "", fmt.Errorf("create parent project folder path: %w", err) - } - - baseSlug := normalizePOSIXSlug(trimmedName) - folderName := slugDir("folder", baseSlug) - folderDir := filepath.Join(parentDir, folderName) - folderSlug := baseSlug - - for attempt := 2; ; attempt += 1 { - if _, err := os.Stat(folderDir); os.IsNotExist(err) { - break - } else if err != nil { - return "", "", fmt.Errorf("stat candidate project folder: %w", err) - } - - folderSlug = fmt.Sprintf("%s-%d", baseSlug, attempt) - folderName = slugDir("folder", folderSlug) - folderDir = filepath.Join(parentDir, folderName) - } - - if err := os.MkdirAll(filepath.Join(folderDir, "children"), 0o755); err != nil { - return "", "", fmt.Errorf("create project hierarchy folder: %w", err) - } - - folderID := uuid.NewString() - - if err := writeJSONFile(filepath.Join(folderDir, "folder.json"), map[string]any{ - "id": folderID, - "name": trimmedName, - "slug": folderSlug, - "type": "folder", - }); err != nil { - return "", "", fmt.Errorf("write project folder.json: %w", err) - } - - if err := writeJSONFile(filepath.Join(folderDir, "acl.json"), map[string]any{ - "version": 1, - "inherits": true, - "rules": []any{}, - }); err != nil { - return "", "", fmt.Errorf("write project acl.json: %w", err) - } - - return filepath.ToSlash(filepath.Join(containerProjectionPath, folderName)), folderSlug, nil -} - -func (service *Service) deleteProjectFolderOnDisk( - projectSlug, folderID string, - rootPathBuilder func(projectSlug string) string, -) (string, error) { - posixRoot := strings.TrimSpace(service.posixRoot) - if posixRoot == "" { - return "", fmt.Errorf("POSIX root is not configured") - } - - trimmedFolderID := strings.TrimSpace(folderID) - if trimmedFolderID == "" { - return "", ErrProjectFolderNotFound - } - - rootProjectionPath := rootPathBuilder(projectSlug) - folderProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedFolderID)), "/") - if folderProjectionPath == "." || folderProjectionPath == rootProjectionPath || !strings.HasPrefix(folderProjectionPath, rootProjectionPath+"/") { - return "", ErrProjectFolderNotFound - } - - folderDir := filepath.Join(posixRoot, filepath.FromSlash(folderProjectionPath)) - info, err := os.Stat(folderDir) - if err != nil { - if os.IsNotExist(err) { - return "", ErrProjectFolderNotFound - } - return "", fmt.Errorf("stat project folder: %w", err) - } - if !info.IsDir() { - return "", ErrProjectFolderNotFound - } - - if err := os.RemoveAll(folderDir); err != nil { - return "", fmt.Errorf("delete project folder: %w", err) - } - - return folderProjectionPath, nil -} - -func (service *Service) renameProjectFolderOnDisk( - projectSlug, folderID, name string, - rootPathBuilder func(projectSlug string) string, -) (string, string, error) { - posixRoot := strings.TrimSpace(service.posixRoot) - if posixRoot == "" { - return "", "", fmt.Errorf("POSIX root is not configured") - } - - trimmedFolderID := strings.TrimSpace(folderID) - if trimmedFolderID == "" { - return "", "", ErrProjectFolderNotFound - } - - trimmedName := strings.TrimSpace(name) - if trimmedName == "" { - return "", "", fmt.Errorf("folder name is required") - } - - rootProjectionPath := rootPathBuilder(projectSlug) - folderProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedFolderID)), "/") - if folderProjectionPath == "." || folderProjectionPath == rootProjectionPath || !strings.HasPrefix(folderProjectionPath, rootProjectionPath+"/") { - return "", "", ErrProjectFolderNotFound - } - - folderDir := filepath.Join(posixRoot, filepath.FromSlash(folderProjectionPath)) - info, err := os.Stat(folderDir) - if err != nil { - if os.IsNotExist(err) { - return "", "", ErrProjectFolderNotFound - } - return "", "", fmt.Errorf("stat project folder: %w", err) - } - if !info.IsDir() { - return "", "", ErrProjectFolderNotFound - } - - parentDir := filepath.Dir(folderDir) - baseSlug := normalizePOSIXSlug(trimmedName) - folderName := slugDir("folder", baseSlug) - folderSlug := baseSlug - destinationDir := filepath.Join(parentDir, folderName) - - for attempt := 2; ; attempt += 1 { - if destinationDir == folderDir { - break - } - - if _, err := os.Stat(destinationDir); os.IsNotExist(err) { - break - } else if err != nil { - return "", "", fmt.Errorf("stat candidate renamed project folder: %w", err) - } - - folderSlug = fmt.Sprintf("%s-%d", baseSlug, attempt) - folderName = slugDir("folder", folderSlug) - destinationDir = filepath.Join(parentDir, folderName) - } - - renamedProjectionPath := filepath.ToSlash(filepath.Join(filepath.Dir(folderProjectionPath), folderName)) - if destinationDir != folderDir { - if err := os.Rename(folderDir, destinationDir); err != nil { - return "", "", fmt.Errorf("rename project folder: %w", err) - } - } - - folderPayload := readJSONFileMap(filepath.Join(destinationDir, "folder.json")) - folderMetadataID, _ := folderPayload["id"].(string) - if strings.TrimSpace(folderMetadataID) == "" { - folderMetadataID = uuid.NewString() - } - - if err := writeJSONFile(filepath.Join(destinationDir, "folder.json"), map[string]any{ - "id": folderMetadataID, - "name": trimmedName, - "slug": folderSlug, - "type": "folder", - }); err != nil { - return "", "", fmt.Errorf("write renamed project folder.json: %w", err) - } - - return folderProjectionPath, renamedProjectionPath, nil -} - -func (service *Service) moveProjectFolderOnDisk( - projectSlug, folderID, parentFolderID string, - rootPathBuilder func(projectSlug string) string, -) (string, string, error) { - posixRoot := strings.TrimSpace(service.posixRoot) - if posixRoot == "" { - return "", "", fmt.Errorf("POSIX root is not configured") - } - - rootProjectionPath := rootPathBuilder(projectSlug) - folderProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(folderID))), "/") - if folderProjectionPath == "." || folderProjectionPath == rootProjectionPath || !strings.HasPrefix(folderProjectionPath, rootProjectionPath+"/") { - return "", "", ErrProjectFolderNotFound - } - - folderDir := filepath.Join(posixRoot, filepath.FromSlash(folderProjectionPath)) - info, err := os.Stat(folderDir) - if err != nil { - if os.IsNotExist(err) { - return "", "", ErrProjectFolderNotFound - } - return "", "", fmt.Errorf("stat project folder: %w", err) - } - if !info.IsDir() { - return "", "", ErrProjectFolderNotFound - } - - trimmedParentFolderID := strings.TrimSpace(parentFolderID) - parentChildrenProjectionPath := rootProjectionPath - parentDir := filepath.Join(posixRoot, filepath.FromSlash(rootProjectionPath)) - - if trimmedParentFolderID != "" { - parentProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedParentFolderID)), "/") - if parentProjectionPath == "." || parentProjectionPath == rootProjectionPath || !strings.HasPrefix(parentProjectionPath, rootProjectionPath+"/") { - return "", "", ErrProjectFolderNotFound - } - if parentProjectionPath == folderProjectionPath || strings.HasPrefix(parentProjectionPath, folderProjectionPath+"/children/") { - return "", "", ErrInvalidProjectFolderMove - } - - parentChildrenProjectionPath = filepath.ToSlash(filepath.Join(parentProjectionPath, "children")) - parentDir = filepath.Join(posixRoot, filepath.FromSlash(parentChildrenProjectionPath)) - } - - parentInfo, err := os.Stat(parentDir) - if err != nil { - if os.IsNotExist(err) { - return "", "", ErrProjectFolderNotFound - } - return "", "", fmt.Errorf("stat project folder parent: %w", err) - } - if !parentInfo.IsDir() { - return "", "", ErrProjectFolderNotFound - } - - currentParentDir := filepath.Dir(folderDir) - if samePath(currentParentDir, parentDir) { - return folderProjectionPath, folderProjectionPath, nil - } - - folderPayload := readJSONFileMap(filepath.Join(folderDir, "folder.json")) - folderMetadataID, _ := folderPayload["id"].(string) - if strings.TrimSpace(folderMetadataID) == "" { - folderMetadataID = uuid.NewString() - } - folderName, _ := folderPayload["name"].(string) - if strings.TrimSpace(folderName) == "" { - folderName = fallbackFolderLabel(folderProjectionPath) - } - - currentBase := filepath.Base(folderDir) - baseSlug := strings.TrimPrefix(currentBase, "folder-") - if strings.TrimSpace(baseSlug) == "" { - baseSlug = normalizePOSIXSlug(folderName) - } - - folderSlug := baseSlug - folderDirName := slugDir("folder", folderSlug) - destinationDir := filepath.Join(parentDir, folderDirName) - for attempt := 2; ; attempt += 1 { - if _, err := os.Stat(destinationDir); os.IsNotExist(err) { - break - } else if err != nil { - return "", "", fmt.Errorf("stat candidate moved project folder: %w", err) - } - - folderSlug = fmt.Sprintf("%s-%d", baseSlug, attempt) - folderDirName = slugDir("folder", folderSlug) - destinationDir = filepath.Join(parentDir, folderDirName) - } - - if err := os.Rename(folderDir, destinationDir); err != nil { - return "", "", fmt.Errorf("move project folder: %w", err) - } - - folderPayload["id"] = folderMetadataID - folderPayload["name"] = folderName - folderPayload["slug"] = folderSlug - folderPayload["type"] = "folder" - if err := writeJSONFile(filepath.Join(destinationDir, "folder.json"), folderPayload); err != nil { - return "", "", fmt.Errorf("write moved project folder.json: %w", err) - } - - movedProjectionPath := filepath.ToSlash(filepath.Join(parentChildrenProjectionPath, folderDirName)) - return folderProjectionPath, movedProjectionPath, nil -} - -func (service *Service) createProjectTreeItemOnDisk( - projectSlug, parentFolderPath, name, itemType string, -) (string, error) { - posixRoot := strings.TrimSpace(service.posixRoot) - if posixRoot == "" { - return "", fmt.Errorf("POSIX root is not configured") - } - - trimmedName := strings.TrimSpace(name) - if trimmedName == "" { - return "", fmt.Errorf("item name is required") - } - - canonicalItemType := normalizeProjectTreeItemType(itemType) - containerProjectionPath := projectTreeRootPath(projectSlug) - parentDir := filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath)) - - if trimmedParentFolderPath := strings.TrimSpace(parentFolderPath); trimmedParentFolderPath != "" { - containerProjectionPath = trimmedParentFolderPath - parentDir = filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath)) - info, err := os.Stat(parentDir) - if err != nil { - if os.IsNotExist(err) { - return "", ErrProjectFolderNotFound - } - return "", fmt.Errorf("stat parent project folder: %w", err) - } - if !info.IsDir() { - return "", ErrProjectFolderNotFound - } - } - - if err := os.MkdirAll(parentDir, 0o755); err != nil { - return "", fmt.Errorf("create parent project item path: %w", err) - } - - baseSlug := normalizePOSIXSlug(trimmedName) - itemDirName := slugDir("item", baseSlug) - itemDir := filepath.Join(parentDir, itemDirName) - itemSlug := baseSlug - - for attempt := 2; ; attempt += 1 { - if _, err := os.Stat(itemDir); os.IsNotExist(err) { - break - } else if err != nil { - return "", fmt.Errorf("stat candidate project item: %w", err) - } - - itemSlug = fmt.Sprintf("%s-%d", baseSlug, attempt) - itemDirName = slugDir("item", itemSlug) - itemDir = filepath.Join(parentDir, itemDirName) - } - - if err := os.MkdirAll(itemDir, 0o755); err != nil { - return "", fmt.Errorf("create project item: %w", err) - } - - itemID := uuid.NewString() - if err := writeJSONFile(filepath.Join(itemDir, "item.json"), map[string]any{ - "id": itemID, - "name": trimmedName, - "slug": itemSlug, - "type": canonicalItemType, - }); err != nil { - return "", fmt.Errorf("write project item.json: %w", err) - } - - if err := writeJSONFile(filepath.Join(itemDir, "schema.json"), defaultProjectTreeItemSchema(canonicalItemType)); err != nil { - return "", fmt.Errorf("write project schema.json: %w", err) - } - - if err := writeJSONFile(filepath.Join(itemDir, "data.json"), defaultProjectTreeItemData(canonicalItemType, trimmedName)); err != nil { - return "", fmt.Errorf("write project data.json: %w", err) - } - - return filepath.ToSlash(filepath.Join(containerProjectionPath, itemDirName)), nil -} - -func (service *Service) deleteProjectTreeItemOnDisk(projectSlug, itemPath string) (string, error) { - posixRoot := strings.TrimSpace(service.posixRoot) - if posixRoot == "" { - return "", fmt.Errorf("POSIX root is not configured") - } - - rootProjectionPath := projectTreeRootPath(projectSlug) - itemProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(itemPath))), "/") - if itemProjectionPath == "." || itemProjectionPath == rootProjectionPath || !strings.HasPrefix(itemProjectionPath, rootProjectionPath+"/") { - return "", ErrProjectItemNotFound - } - - itemDir := filepath.Join(posixRoot, filepath.FromSlash(itemProjectionPath)) - info, err := os.Stat(itemDir) - if err != nil { - if os.IsNotExist(err) { - return "", ErrProjectItemNotFound - } - return "", fmt.Errorf("stat project item: %w", err) - } - if !info.IsDir() { - return "", ErrProjectItemNotFound - } - - if err := os.RemoveAll(itemDir); err != nil { - return "", fmt.Errorf("delete project item: %w", err) - } - - return itemProjectionPath, nil -} - -func (service *Service) moveProjectTreeItemOnDisk(projectSlug, itemPath, parentFolderPath string) (string, string, error) { - posixRoot := strings.TrimSpace(service.posixRoot) - if posixRoot == "" { - return "", "", fmt.Errorf("POSIX root is not configured") - } - - rootProjectionPath := projectTreeRootPath(projectSlug) - itemProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(itemPath))), "/") - if itemProjectionPath == "." || itemProjectionPath == rootProjectionPath || !strings.HasPrefix(itemProjectionPath, rootProjectionPath+"/") { - return "", "", ErrProjectItemNotFound - } - - itemDir := filepath.Join(posixRoot, filepath.FromSlash(itemProjectionPath)) - info, err := os.Stat(itemDir) - if err != nil { - if os.IsNotExist(err) { - return "", "", ErrProjectItemNotFound - } - return "", "", fmt.Errorf("stat project item: %w", err) - } - if !info.IsDir() { - return "", "", ErrProjectItemNotFound - } - - trimmedParentFolderPath := strings.TrimSpace(parentFolderPath) - parentProjectionPath := rootProjectionPath - parentDir := filepath.Join(posixRoot, filepath.FromSlash(parentProjectionPath)) - if trimmedParentFolderPath != "" { - parentProjectionPath = strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedParentFolderPath)), "/") - if parentProjectionPath == "." || parentProjectionPath == rootProjectionPath || !strings.HasPrefix(parentProjectionPath, rootProjectionPath+"/") { - return "", "", ErrProjectFolderNotFound - } - parentDir = filepath.Join(posixRoot, filepath.FromSlash(parentProjectionPath)) - } - - parentInfo, err := os.Stat(parentDir) - if err != nil { - if os.IsNotExist(err) { - return "", "", ErrProjectFolderNotFound - } - return "", "", fmt.Errorf("stat project item parent: %w", err) - } - if !parentInfo.IsDir() { - return "", "", ErrProjectFolderNotFound - } - - currentParentDir := filepath.Dir(itemDir) - currentBase := filepath.Base(itemDir) - itemPayload := readJSONFileMap(filepath.Join(itemDir, "item.json")) - itemID, _ := itemPayload["id"].(string) - if strings.TrimSpace(itemID) == "" { - itemID = uuid.NewString() - } - itemName, _ := itemPayload["name"].(string) - if strings.TrimSpace(itemName) == "" { - itemName = fallbackItemLabel(itemProjectionPath) - } - itemType, _ := itemPayload["type"].(string) - canonicalItemType := normalizeProjectTreeItemType(itemType) - - baseSlug := strings.TrimPrefix(currentBase, "item-") - if strings.TrimSpace(baseSlug) == "" { - baseSlug = normalizePOSIXSlug(itemName) - } - - itemSlug := baseSlug - itemDirName := slugDir("item", itemSlug) - destinationDir := filepath.Join(parentDir, itemDirName) - for attempt := 2; ; attempt += 1 { - if samePath(destinationDir, itemDir) { - break - } - if _, err := os.Stat(destinationDir); os.IsNotExist(err) { - break - } else if err != nil { - return "", "", fmt.Errorf("stat candidate moved project item: %w", err) - } - - itemSlug = fmt.Sprintf("%s-%d", baseSlug, attempt) - itemDirName = slugDir("item", itemSlug) - destinationDir = filepath.Join(parentDir, itemDirName) - } - - movedProjectionPath := filepath.ToSlash(filepath.Join(parentProjectionPath, itemDirName)) - if samePath(currentParentDir, parentDir) && currentBase == itemDirName { - return itemProjectionPath, itemProjectionPath, nil - } - - if err := os.Rename(itemDir, destinationDir); err != nil { - return "", "", fmt.Errorf("move project item: %w", err) - } - - itemPayload["id"] = itemID - itemPayload["name"] = itemName - itemPayload["slug"] = itemSlug - itemPayload["type"] = canonicalItemType - if err := writeJSONFile(filepath.Join(destinationDir, "item.json"), itemPayload); err != nil { - return "", "", fmt.Errorf("write moved project item.json: %w", err) - } - - return itemProjectionPath, movedProjectionPath, nil -} - -func samePath(left, right string) bool { - cleanLeft := filepath.Clean(left) - cleanRight := filepath.Clean(right) - if cleanLeft == cleanRight { - return true - } - leftInfo, leftErr := os.Stat(cleanLeft) - rightInfo, rightErr := os.Stat(cleanRight) - if leftErr == nil && rightErr == nil { - return os.SameFile(leftInfo, rightInfo) - } - return false -} - -func (service *Service) readProjectFolderOrder(projectSlug, rootProjectionPath string) map[string][]string { - settingsPath := service.projectSettingsPath(projectSlug) - settingsPayload := readJSONFileMap(settingsPath) - folderOrderPayload, _ := settingsPayload["folderOrder"].(map[string]any) - if folderOrderPayload == nil { - return map[string][]string{} - } - - scopePayload, _ := folderOrderPayload[projectFolderOrderScope(rootProjectionPath)].(map[string]any) - if scopePayload == nil { - return map[string][]string{} - } - - byParentPayload, _ := scopePayload["byParent"].(map[string]any) - if byParentPayload == nil { - return map[string][]string{} - } - - order := make(map[string][]string, len(byParentPayload)) - for key, raw := range byParentPayload { - for _, id := range stringSliceValue(raw) { - trimmedID := strings.TrimSpace(id) - if trimmedID == "" || slicesContains(order[key], trimmedID) { - continue - } - order[key] = append(order[key], trimmedID) - } - } - - return order -} - -func (service *Service) writeProjectFolderOrder(projectSlug, rootProjectionPath string, folderOrder map[string][]string) error { - settingsPath := service.projectSettingsPath(projectSlug) - settingsPayload := readJSONFileMap(settingsPath) - if settingsPayload == nil { - settingsPayload = map[string]any{} - } - - folderOrderPayload, _ := settingsPayload["folderOrder"].(map[string]any) - if folderOrderPayload == nil { - folderOrderPayload = map[string]any{} - } - - scopeKey := projectFolderOrderScope(rootProjectionPath) - scopePayload, _ := folderOrderPayload[scopeKey].(map[string]any) - if scopePayload == nil { - scopePayload = map[string]any{} - } - - byParentPayload := map[string]any{} - for key, ids := range folderOrder { - if len(ids) == 0 { - continue - } - copied := make([]string, 0, len(ids)) - for _, id := range ids { - trimmedID := strings.TrimSpace(id) - if trimmedID == "" || slicesContains(copied, trimmedID) { - continue - } - copied = append(copied, trimmedID) - } - if len(copied) > 0 { - byParentPayload[key] = copied - } - } - - scopePayload["byParent"] = byParentPayload - folderOrderPayload[scopeKey] = scopePayload - settingsPayload["folderOrder"] = folderOrderPayload - - if err := writeJSONFile(settingsPath, settingsPayload); err != nil { - return fmt.Errorf("write project settings.json: %w", err) - } - - return nil -} - -func (service *Service) projectSettingsPath(projectSlug string) string { - return filepath.Join(strings.TrimSpace(service.posixRoot), "projects", slugDir("project", projectSlug), "settings.json") -} - -func projectFolderOrderScope(rootProjectionPath string) string { - if strings.HasSuffix(rootProjectionPath, "/tree") { - return projectFolderOrderTree - } - - return projectFolderOrderHierarchy -} - -func applyProjectHierarchyFolderOrdering(folders []ProjectHierarchyFolderRecord, folderOrder map[string][]string) []ProjectHierarchyFolderRecord { - return applyProjectHierarchyFolderOrderingForParent(folders, "", folderOrder) -} - -func applyProjectHierarchyFolderOrderingForParent(folders []ProjectHierarchyFolderRecord, parentID string, folderOrder map[string][]string) []ProjectHierarchyFolderRecord { - if len(folders) == 0 { - return folders - } - - nextFolders := make([]ProjectHierarchyFolderRecord, len(folders)) - copy(nextFolders, folders) - for index := range nextFolders { - nextFolders[index].Children = applyProjectHierarchyFolderOrderingForParent(nextFolders[index].Children, nextFolders[index].ID, folderOrder) - } - - orderIDs := folderOrder[projectFolderOrderParentKey(parentID)] - if len(orderIDs) == 0 { - return nextFolders - } - - rankByID := make(map[string]int, len(orderIDs)) - for index, id := range orderIDs { - if _, exists := rankByID[id]; !exists { - rankByID[id] = index - } - } - - sort.SliceStable(nextFolders, func(left, right int) bool { - leftRank, leftOrdered := rankByID[nextFolders[left].ID] - rightRank, rightOrdered := rankByID[nextFolders[right].ID] - if leftOrdered && rightOrdered { - return leftRank < rightRank - } - if leftOrdered != rightOrdered { - return leftOrdered - } - return false - }) - - return nextFolders -} - -func removeFolderOrder(folderOrder map[string][]string, folderID string) { - removeFolderOrderReference(folderOrder, folderID) - - trimmedFolderID := strings.TrimSpace(folderID) - if trimmedFolderID == "" { - return - } - - delete(folderOrder, projectFolderOrderParentKey(trimmedFolderID)) -} - -func removeFolderOrderReference(folderOrder map[string][]string, folderID string) { - trimmedFolderID := strings.TrimSpace(folderID) - if trimmedFolderID == "" { - return - } - - for key, ids := range folderOrder { - nextIDs := ids[:0] - for _, id := range ids { - if strings.TrimSpace(id) == trimmedFolderID { - continue - } - nextIDs = append(nextIDs, id) - } - if len(nextIDs) == 0 { - delete(folderOrder, key) - continue - } - folderOrder[key] = append([]string(nil), nextIDs...) - } -} - -func insertFolderOrder(folderOrder map[string][]string, parentID, folderID string, index int) { - trimmedFolderID := strings.TrimSpace(folderID) - if trimmedFolderID == "" { - return - } - - removeFolderOrderReference(folderOrder, trimmedFolderID) - - parentKey := projectFolderOrderParentKey(parentID) - children := append([]string(nil), folderOrder[parentKey]...) - if index < 0 { - index = 0 - } - if index > len(children) { - index = len(children) - } - children = slicesInsert(children, index, trimmedFolderID) - folderOrder[parentKey] = children -} - -func seedFolderOrderParent(folderOrder map[string][]string, folders []ProjectHierarchyFolderRecord, parentID string) { - children := folders - trimmedParentID := strings.TrimSpace(parentID) - if trimmedParentID != "" { - parent, found := findProjectHierarchyFolder(folders, trimmedParentID) - if !found { - return - } - children = parent.Children - } - - parentKey := projectFolderOrderParentKey(trimmedParentID) - seeded := make([]string, 0, len(children)) - for _, child := range children { - childID := strings.TrimSpace(child.ID) - if childID == "" || slicesContains(seeded, childID) { - continue - } - seeded = append(seeded, childID) - } - - if len(seeded) == 0 { - delete(folderOrder, parentKey) - return - } - - folderOrder[parentKey] = seeded -} - -func folderOrderChildren(folderOrder map[string][]string, parentID string) []string { - return append([]string(nil), folderOrder[projectFolderOrderParentKey(parentID)]...) -} - -func projectFolderOrderParentKey(parentID string) string { - trimmedParentID := strings.TrimSpace(parentID) - if trimmedParentID == "" { - return projectFolderOrderRootKey - } - - return trimmedParentID -} - -func stringSliceValue(value any) []string { - items, ok := value.([]any) - if !ok { - if typed, ok := value.([]string); ok { - return typed - } - return nil - } - - result := make([]string, 0, len(items)) - for _, item := range items { - text, ok := item.(string) - if ok { - result = append(result, text) - } - } - - return result -} - -func slicesContains(values []string, value string) bool { - for _, existing := range values { - if existing == value { - return true - } - } - - return false -} - -func slicesInsert(values []string, index int, value string) []string { - values = append(values, "") - copy(values[index+1:], values[index:]) - values[index] = value - return values -} - -func readJSONFileMap(path string) map[string]any { - data, err := os.ReadFile(path) - if err != nil { - return map[string]any{} - } - - var payload map[string]any - if err := json.Unmarshal(data, &payload); err != nil || payload == nil { - return map[string]any{} - } - - return payload -} - -func buildProjectHierarchyFolderTree(rows []projectHierarchyFolderRow, rootParentPath string) []ProjectHierarchyFolderRecord { - if len(rows) == 0 { - return nil - } - - nodesByPath := make(map[string]*ProjectHierarchyFolderRecord, len(rows)) - childrenByParent := make(map[string][]string) - - for _, row := range rows { - folderID := strings.TrimSpace(row.ID) - if folderID == "" { - folderID = row.Path - } - label := strings.TrimSpace(row.Label) - if label == "" { - label = fallbackFolderLabel(row.Path) - } - nodesByPath[row.Path] = &ProjectHierarchyFolderRecord{ - ID: folderID, - Path: row.Path, - Label: label, - Children: []ProjectHierarchyFolderRecord{}, - } - childrenByParent[row.ParentPath] = append(childrenByParent[row.ParentPath], row.Path) - } - - var build func(parentPath string) []ProjectHierarchyFolderRecord - build = func(parentPath string) []ProjectHierarchyFolderRecord { - childPaths := childrenByParent[parentPath] - if len(childPaths) == 0 { - return nil - } - - folders := make([]ProjectHierarchyFolderRecord, 0, len(childPaths)) - for _, childPath := range childPaths { - node := nodesByPath[childPath] - if node == nil { - continue - } - - folder := ProjectHierarchyFolderRecord{ - ID: node.ID, - Path: node.Path, - Label: node.Label, - Children: build(filepath.ToSlash(filepath.Join(childPath, "children"))), - } - folders = append(folders, folder) - } - - return folders - } - - return build(rootParentPath) -} - -func buildProjectTreeNodeTree(rows []projectTreeNodeRow, rootParentPath string) []ProjectTreeNodeRecord { - if len(rows) == 0 { - return nil - } - - nodesByPath := make(map[string]*ProjectTreeNodeRecord, len(rows)) - childrenByParent := make(map[string][]string) - - for _, row := range rows { - nodeKind := normalizeProjectTreeNodeKind(row.Kind) - nodeID := strings.TrimSpace(row.ID) - if nodeID == "" { - nodeID = row.Path - } - label := strings.TrimSpace(row.Label) - if label == "" { - if nodeKind == "item" { - label = fallbackItemLabel(row.Path) - } else { - label = fallbackFolderLabel(row.Path) - } - } - - nodesByPath[row.Path] = &ProjectTreeNodeRecord{ - ID: nodeID, - Path: row.Path, - Label: label, - Kind: nodeKind, - ItemType: normalizeProjectTreeItemType(row.ItemType), - Children: []ProjectTreeNodeRecord{}, - } - childrenByParent[normalizeProjectTreeParentPath(nodeKind, row.ParentPath)] = append(childrenByParent[normalizeProjectTreeParentPath(nodeKind, row.ParentPath)], row.Path) - } - - var build func(parentPath string) []ProjectTreeNodeRecord - build = func(parentPath string) []ProjectTreeNodeRecord { - childPaths := childrenByParent[parentPath] - if len(childPaths) == 0 { - return nil - } - - nodes := make([]ProjectTreeNodeRecord, 0, len(childPaths)) - for _, childPath := range childPaths { - node := nodesByPath[childPath] - if node == nil { - continue - } - - nextNode := ProjectTreeNodeRecord{ - ID: node.ID, - Path: node.Path, - Label: node.Label, - Kind: node.Kind, - ItemType: node.ItemType, - } - if node.Kind == "folder" { - nextNode.Children = build(node.Path) - } - nodes = append(nodes, nextNode) - } - - return nodes - } - - return build(rootParentPath) -} - -func normalizeProjectTreeParentPath(kind, parentPath string) string { - if kind == "folder" && strings.HasSuffix(parentPath, "/children") { - return filepath.ToSlash(filepath.Dir(parentPath)) - } - - return parentPath -} - -func normalizeProjectTreeNodeKind(kind string) string { - switch strings.TrimSpace(kind) { - case "item": - return "item" - case "folder", "hierarchy_folder": - return "folder" - default: - return "folder" - } -} - -func applyProjectTreeNodeOrdering(nodes []ProjectTreeNodeRecord, folderOrder map[string][]string) []ProjectTreeNodeRecord { - return applyProjectTreeNodeOrderingForParent(nodes, "", folderOrder) -} - -func applyProjectTreeNodeOrderingForParent(nodes []ProjectTreeNodeRecord, parentID string, folderOrder map[string][]string) []ProjectTreeNodeRecord { - if len(nodes) == 0 { - return nodes - } - - nextNodes := make([]ProjectTreeNodeRecord, len(nodes)) - copy(nextNodes, nodes) - for index := range nextNodes { - if nextNodes[index].Kind == "folder" { - nextNodes[index].Children = applyProjectTreeNodeOrderingForParent(nextNodes[index].Children, nextNodes[index].ID, folderOrder) - } - } - - orderIDs := folderOrder[projectFolderOrderParentKey(parentID)] - if len(orderIDs) == 0 { - return nextNodes - } - - rankByID := make(map[string]int, len(orderIDs)) - for index, id := range orderIDs { - if _, exists := rankByID[id]; !exists { - rankByID[id] = index - } - } - - sort.SliceStable(nextNodes, func(left, right int) bool { - leftRank, leftOrdered := rankByID[nextNodes[left].ID] - rightRank, rightOrdered := rankByID[nextNodes[right].ID] - if leftOrdered && rightOrdered { - return leftRank < rightRank - } - if leftOrdered != rightOrdered { - return leftOrdered - } - return false - }) - - return nextNodes -} - -func findProjectHierarchyFolder(folders []ProjectHierarchyFolderRecord, folderID string) (ProjectHierarchyFolderRecord, bool) { - for _, folder := range folders { - if folder.ID == folderID { - return folder, true - } - - if child, ok := findProjectHierarchyFolder(folder.Children, folderID); ok { - return child, true - } - } - - return ProjectHierarchyFolderRecord{}, false -} - -func findProjectHierarchyFolderByPath(folders []ProjectHierarchyFolderRecord, folderPath string) (ProjectHierarchyFolderRecord, bool) { - for _, folder := range folders { - if folder.Path == folderPath { - return folder, true - } - - if child, ok := findProjectHierarchyFolderByPath(folder.Children, folderPath); ok { - return child, true - } - } - - return ProjectHierarchyFolderRecord{}, false -} - -func findProjectTreeNode(nodes []ProjectTreeNodeRecord, nodeID string) (ProjectTreeNodeRecord, bool) { - for _, node := range nodes { - if node.ID == nodeID { - return node, true - } - if child, ok := findProjectTreeNode(node.Children, nodeID); ok { - return child, true - } - } - - return ProjectTreeNodeRecord{}, false -} - -func findProjectTreeNodeByPath(nodes []ProjectTreeNodeRecord, nodePath string) (ProjectTreeNodeRecord, bool) { - for _, node := range nodes { - if node.Path == nodePath { - return node, true - } - if child, ok := findProjectTreeNodeByPath(node.Children, nodePath); ok { - return child, true - } - } - - return ProjectTreeNodeRecord{}, false -} - -func findProjectTreeFolderByPath(nodes []ProjectTreeNodeRecord, folderPath string) (ProjectTreeNodeRecord, bool) { - node, ok := findProjectTreeNodeByPath(nodes, folderPath) - if !ok || node.Kind != "folder" { - return ProjectTreeNodeRecord{}, false - } - - return node, true -} - -func projectHierarchyRootPath(projectSlug string) string { - return filepath.ToSlash(filepath.Join("projects", slugDir("project", projectSlug), "children")) -} - -func projectTreeRootPath(projectSlug string) string { - return filepath.ToSlash(filepath.Join("projects", slugDir("project", projectSlug), "tree")) -} - -func normalizePOSIXSlug(value string) string { - trimmed := strings.TrimSpace(strings.ToLower(value)) - if trimmed == "" { - return "untitled" - } - - var builder strings.Builder - lastDash := false - for _, r := range trimmed { - switch { - case unicode.IsLetter(r) || unicode.IsDigit(r): - builder.WriteRune(r) - lastDash = false - case r == '-' || r == '_' || unicode.IsSpace(r): - if !lastDash && builder.Len() > 0 { - builder.WriteByte('-') - lastDash = true - } - } - } - - slug := strings.Trim(builder.String(), "-") - if slug == "" { - return "untitled" - } - - return slug -} - -func fallbackFolderLabel(path string) string { - base := filepath.Base(filepath.FromSlash(path)) - trimmed := strings.TrimPrefix(base, "folder-") - parts := strings.FieldsFunc(trimmed, func(r rune) bool { return r == '-' || r == '_' }) - for index, part := range parts { - if part == "" { - continue - } - parts[index] = strings.ToUpper(part[:1]) + part[1:] - } - label := strings.Join(parts, " ") - if label == "" { - return base - } - return label -} - -func fallbackItemLabel(path string) string { - base := filepath.Base(filepath.FromSlash(path)) - trimmed := strings.TrimPrefix(base, "item-") - parts := strings.FieldsFunc(trimmed, func(r rune) bool { return r == '-' || r == '_' }) - for index, part := range parts { - if part == "" { - continue - } - parts[index] = strings.ToUpper(part[:1]) + part[1:] - } - label := strings.Join(parts, " ") - if label == "" { - return base - } - return label -} - -func normalizeProjectTreeItemType(itemType string) string { - switch strings.TrimSpace(strings.ToLower(itemType)) { - case "", "board", "core.board", "core.board.kanban", "kanban": - return "core.board.kanban" - case "core.doc", "doc", "document": - return "core.doc" - case "core.board.list", "list", "list-board": - return "core.board.list" - default: - return strings.TrimSpace(itemType) - } -} - -func seedProjectTreeOrderParent(folderOrder map[string][]string, nodes []ProjectTreeNodeRecord, parentID string) { - children := nodes - trimmedParentID := strings.TrimSpace(parentID) - if trimmedParentID != "" { - parent, found := findProjectTreeNode(nodes, trimmedParentID) - if !found || parent.Kind != "folder" { - return - } - children = parent.Children - } - - parentKey := projectFolderOrderParentKey(parentID) - if len(folderOrder[parentKey]) > 0 { - return - } - - orderedIDs := make([]string, 0, len(children)) - for _, child := range children { - trimmedChildID := strings.TrimSpace(child.ID) - if trimmedChildID == "" || slicesContains(orderedIDs, trimmedChildID) { - continue - } - orderedIDs = append(orderedIDs, trimmedChildID) - } - - if len(orderedIDs) > 0 { - folderOrder[parentKey] = orderedIDs - } -} - -func defaultProjectTreeItemSchema(itemType string) map[string]any { - return map[string]any{ - "type": "object", - "itemType": normalizeProjectTreeItemType(itemType), - } -} - -func defaultProjectTreeItemData(itemType, name string) map[string]any { - return map[string]any{ - "title": strings.TrimSpace(name), - "itemType": normalizeProjectTreeItemType(itemType), - } -} - -func slugDir(prefix, slug string) string { - trimmedSlug := strings.TrimSpace(slug) - if trimmedSlug == "" { - return prefix - } - - return fmt.Sprintf("%s-%s", prefix, trimmedSlug) -} - -func writeJSONFile(path string, payload any) error { - parentDir := filepath.Dir(path) - if err := os.MkdirAll(parentDir, 0o755); err != nil { - return err - } - - data, err := json.MarshalIndent(payload, "", " ") - if err != nil { - return err - } - - data = append(data, '\n') - - return os.WriteFile(path, data, 0o644) -} diff --git a/Backend/internal/httpx/api_project_decode.go b/Backend/internal/httpx/api_project_decode.go new file mode 100644 index 0000000..b7de3c4 --- /dev/null +++ b/Backend/internal/httpx/api_project_decode.go @@ -0,0 +1,127 @@ +// Path: Backend/internal/httpx/api_project_decode.go + +package httpx + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" +) + +type createProjectFolderRequest struct { + Name string `json:"name"` + ParentFolderPath string `json:"parentFolderId"` +} + +type renameProjectFolderRequest struct { + FolderPath string `json:"folderId"` + Name string `json:"name"` +} + +type deleteProjectFolderRequest struct { + FolderPath string `json:"folderId"` +} + +// Keep the existing JSON contract for the frontend, but use clearer path-vs-stable-ID +// names internally so the move flow is easier to reason about. +type moveProjectFolderRequest struct { + FolderPath string `json:"folderId"` + FolderStableID string `json:"folderNodeId"` + ParentFolderPath string `json:"parentFolderId"` + ParentStableID string `json:"parentNodeId"` + TargetIndex int `json:"targetIndex"` +} + +type createProjectItemRequest struct { + Name string `json:"name"` + ParentFolderPath string `json:"parentFolderId"` + ItemType string `json:"itemType"` +} + +type deleteProjectItemRequest struct { + ItemPath string `json:"itemId"` +} + +type moveProjectItemRequest struct { + ItemPath string `json:"itemId"` + ItemStableID string `json:"itemNodeId"` + ParentFolderPath string `json:"parentFolderId"` + ParentStableID string `json:"parentNodeId"` + TargetIndex int `json:"targetIndex"` +} + +func decodeMoveProjectFolderRequest(w http.ResponseWriter, r *http.Request) (moveProjectFolderRequest, bool) { + var payload moveProjectFolderRequest + if !decodeJSONObjectBody(w, r, &payload) { + return payload, false + } + return payload, true +} + +func decodeDeleteProjectFolderRequest(r *http.Request) deleteProjectFolderRequest { + return deleteProjectFolderRequest{ + FolderPath: strings.TrimSpace(r.URL.Query().Get("folderId")), + } +} + +func decodeDeleteProjectItemRequest(r *http.Request) deleteProjectItemRequest { + return deleteProjectItemRequest{ + ItemPath: strings.TrimSpace(r.URL.Query().Get("itemId")), + } +} + +func decodeRenameProjectFolderRequest(w http.ResponseWriter, r *http.Request) (renameProjectFolderRequest, bool) { + var payload renameProjectFolderRequest + if !decodeJSONObjectBody(w, r, &payload) { + return payload, false + } + return payload, true +} + +func decodeProjectFolderRequest(w http.ResponseWriter, r *http.Request) (createProjectFolderRequest, bool) { + var payload createProjectFolderRequest + if !decodeJSONObjectBody(w, r, &payload) { + return payload, false + } + return payload, true +} + +func decodeProjectItemRequest(w http.ResponseWriter, r *http.Request) (createProjectItemRequest, bool) { + var payload createProjectItemRequest + if !decodeJSONObjectBody(w, r, &payload) { + return payload, false + } + return payload, true +} + +func decodeMoveProjectItemRequest(w http.ResponseWriter, r *http.Request) (moveProjectItemRequest, bool) { + var payload moveProjectItemRequest + if !decodeJSONObjectBody(w, r, &payload) { + return payload, false + } + return payload, true +} + +func decodeJSONObjectBody(w http.ResponseWriter, r *http.Request, target any) bool { + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + if err := decoder.Decode(target); err != nil { + if errors.Is(err, io.EOF) { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.") + return false + } + + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.") + return false + } + + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.") + return false + } + + return true +} diff --git a/Backend/internal/httpx/api_project_errors.go b/Backend/internal/httpx/api_project_errors.go new file mode 100644 index 0000000..064977e --- /dev/null +++ b/Backend/internal/httpx/api_project_errors.go @@ -0,0 +1,42 @@ +// Path: Backend/internal/httpx/api_project_errors.go + +package httpx + +import ( + "errors" + "net/http" + + bootstrapservice "moku-backend/internal/bootstrap" +) + +func (routes apiRoutes) writeProjectFolderError(w http.ResponseWriter, r *http.Request, err error, operation string) { + switch { + case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound): + WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", err.Error()) + case errors.Is(err, bootstrapservice.ErrInvalidProjectFolderMove): + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", err.Error()) + default: + routes.cfg.Logger.Error(operation+" project folder", "error", err, "path", r.URL.Path) + message := "Failed to " + operation + " project folder." + if routes.cfg.Config.IsDevelopment() { + message = message + " " + err.Error() + } + WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "project_folder_"+operation+"_failed", message) + } +} + +func (routes apiRoutes) writeProjectTreeError(w http.ResponseWriter, r *http.Request, err error, operation string) { + switch { + case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound), errors.Is(err, bootstrapservice.ErrProjectItemNotFound): + WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", err.Error()) + case errors.Is(err, bootstrapservice.ErrInvalidProjectFolderMove), errors.Is(err, bootstrapservice.ErrInvalidProjectItemMove): + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", err.Error()) + default: + routes.cfg.Logger.Error(operation+" project tree", "error", err, "path", r.URL.Path) + message := "Failed to " + operation + " project tree." + if routes.cfg.Config.IsDevelopment() { + message = message + " " + err.Error() + } + WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "project_tree_"+operation+"_failed", message) + } +} diff --git a/Backend/internal/httpx/api_project_routes.go b/Backend/internal/httpx/api_project_routes.go index b9a5bd3..9bdf48f 100644 --- a/Backend/internal/httpx/api_project_routes.go +++ b/Backend/internal/httpx/api_project_routes.go @@ -1,9 +1,8 @@ +// Path: Backend/internal/httpx/api_project_routes.go + package httpx import ( - "encoding/json" - "errors" - "io" "net/http" "strings" @@ -12,48 +11,6 @@ import ( "github.com/go-chi/chi/v5" ) -type createProjectFolderRequest struct { - Name string `json:"name"` - ParentFolderPath string `json:"parentFolderId"` -} - -type renameProjectFolderRequest struct { - FolderPath string `json:"folderId"` - Name string `json:"name"` -} - -type deleteProjectFolderRequest struct { - FolderPath string `json:"folderId"` -} - -// Keep the existing JSON contract for the frontend, but use clearer path-vs-stable-ID -// names internally so the move flow is easier to reason about. -type moveProjectFolderRequest struct { - FolderPath string `json:"folderId"` - FolderStableID string `json:"folderNodeId"` - ParentFolderPath string `json:"parentFolderId"` - ParentStableID string `json:"parentNodeId"` - TargetIndex int `json:"targetIndex"` -} - -type createProjectItemRequest struct { - Name string `json:"name"` - ParentFolderPath string `json:"parentFolderId"` - ItemType string `json:"itemType"` -} - -type deleteProjectItemRequest struct { - ItemPath string `json:"itemId"` -} - -type moveProjectItemRequest struct { - ItemPath string `json:"itemId"` - ItemStableID string `json:"itemNodeId"` - ParentFolderPath string `json:"parentFolderId"` - ParentStableID string `json:"parentNodeId"` - TargetIndex int `json:"targetIndex"` -} - func (routes apiRoutes) handleProjectFolders(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) if projectID == "" { @@ -551,167 +508,3 @@ func (routes apiRoutes) handleMoveProjectTreeItem(w http.ResponseWriter, r *http }, }) } - -func (routes apiRoutes) writeProjectFolderError(w http.ResponseWriter, r *http.Request, err error, operation string) { - switch { - case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound): - WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", err.Error()) - case errors.Is(err, bootstrapservice.ErrInvalidProjectFolderMove): - WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", err.Error()) - default: - routes.cfg.Logger.Error(operation+" project folder", "error", err, "path", r.URL.Path) - message := "Failed to " + operation + " project folder." - if routes.cfg.Config.IsDevelopment() { - message = message + " " + err.Error() - } - WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "project_folder_"+operation+"_failed", message) - } -} - -func (routes apiRoutes) writeProjectTreeError(w http.ResponseWriter, r *http.Request, err error, operation string) { - switch { - case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound), errors.Is(err, bootstrapservice.ErrProjectItemNotFound): - WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", err.Error()) - case errors.Is(err, bootstrapservice.ErrInvalidProjectFolderMove), errors.Is(err, bootstrapservice.ErrInvalidProjectItemMove): - WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", err.Error()) - default: - routes.cfg.Logger.Error(operation+" project tree", "error", err, "path", r.URL.Path) - message := "Failed to " + operation + " project tree." - if routes.cfg.Config.IsDevelopment() { - message = message + " " + err.Error() - } - WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "project_tree_"+operation+"_failed", message) - } -} - -func decodeMoveProjectFolderRequest(w http.ResponseWriter, r *http.Request) (moveProjectFolderRequest, bool) { - var payload moveProjectFolderRequest - - decoder := json.NewDecoder(r.Body) - decoder.DisallowUnknownFields() - - if err := decoder.Decode(&payload); err != nil { - if errors.Is(err, io.EOF) { - WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.") - return payload, false - } - - WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.") - return payload, false - } - - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.") - return payload, false - } - - return payload, true -} - -func decodeDeleteProjectFolderRequest(r *http.Request) deleteProjectFolderRequest { - return deleteProjectFolderRequest{ - FolderPath: strings.TrimSpace(r.URL.Query().Get("folderId")), - } -} - -func decodeDeleteProjectItemRequest(r *http.Request) deleteProjectItemRequest { - return deleteProjectItemRequest{ - ItemPath: strings.TrimSpace(r.URL.Query().Get("itemId")), - } -} - -func decodeRenameProjectFolderRequest(w http.ResponseWriter, r *http.Request) (renameProjectFolderRequest, bool) { - var payload renameProjectFolderRequest - - decoder := json.NewDecoder(r.Body) - decoder.DisallowUnknownFields() - - if err := decoder.Decode(&payload); err != nil { - if errors.Is(err, io.EOF) { - WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.") - return payload, false - } - - WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.") - return payload, false - } - - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.") - return payload, false - } - - return payload, true -} - -func decodeProjectFolderRequest(w http.ResponseWriter, r *http.Request) (createProjectFolderRequest, bool) { - var payload createProjectFolderRequest - - decoder := json.NewDecoder(r.Body) - decoder.DisallowUnknownFields() - - if err := decoder.Decode(&payload); err != nil { - if errors.Is(err, io.EOF) { - WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.") - return payload, false - } - - WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.") - return payload, false - } - - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.") - return payload, false - } - - return payload, true -} - -func decodeProjectItemRequest(w http.ResponseWriter, r *http.Request) (createProjectItemRequest, bool) { - var payload createProjectItemRequest - - decoder := json.NewDecoder(r.Body) - decoder.DisallowUnknownFields() - - if err := decoder.Decode(&payload); err != nil { - if errors.Is(err, io.EOF) { - WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.") - return payload, false - } - - WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.") - return payload, false - } - - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.") - return payload, false - } - - return payload, true -} - -func decodeMoveProjectItemRequest(w http.ResponseWriter, r *http.Request) (moveProjectItemRequest, bool) { - var payload moveProjectItemRequest - - decoder := json.NewDecoder(r.Body) - decoder.DisallowUnknownFields() - - if err := decoder.Decode(&payload); err != nil { - if errors.Is(err, io.EOF) { - WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.") - return payload, false - } - - WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.") - return payload, false - } - - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.") - return payload, false - } - - return payload, true -} diff --git a/Backend/internal/jobs/store.go b/Backend/internal/jobs/store.go index 6958cd4..c331ebe 100644 --- a/Backend/internal/jobs/store.go +++ b/Backend/internal/jobs/store.go @@ -1,3 +1,5 @@ +// Path: Backend/internal/jobs/store.go + package jobs import ( diff --git a/Backend/internal/posixproj/projector.go b/Backend/internal/posixproj/projector.go index a2dbfb2..0a5c00b 100644 --- a/Backend/internal/posixproj/projector.go +++ b/Backend/internal/posixproj/projector.go @@ -1,72 +1,15 @@ +// Path: Backend/internal/posixproj/projector.go + package posixproj import ( "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" "fmt" - "io/fs" - "os" - "path/filepath" "strings" "moku-backend/internal/database" ) -const rootProjectionPath = "/" - -type Projector struct { - db *database.DB - root string -} - -type RebuildSummary struct { - TotalNodes int - DirectoryCount int - FileCount int -} - -type NodeKind string - -const ( - NodeKindDirectory NodeKind = "directory" - NodeKindFile NodeKind = "file" -) - -type Scope struct { - InstallationID string - OrganizationID string - OrganizationSlug string - DepartmentSlug string - TeamSlug string - ProjectSlug string - PersonalSlug string -} - -type Node struct { - Path string - ParentPath *string - Name string - Depth int - NodeKind NodeKind - LogicalType string - FileRole string - ResourceID string - ResourceName string - ResourceSlug string - InstallationID string - OrganizationID string - OrganizationSlug string - DepartmentSlug string - TeamSlug string - ProjectSlug string - PersonalSlug string - ContentJSON []byte - SizeBytes int64 - Checksum string -} - func NewProjector(db *database.DB, root string) *Projector { return &Projector{db: db, root: strings.TrimSpace(root)} } @@ -145,9 +88,9 @@ func (projector *Projector) RebuildWithSummary(ctx context.Context) (RebuildSumm node.TeamSlug, node.ProjectSlug, node.PersonalSlug, - node.ContentJSON, - node.SizeBytes, - node.Checksum, + node.ContentJSON, + node.SizeBytes, + node.Checksum, ); err != nil { return RebuildSummary{}, fmt.Errorf("insert posix node %s: %w", node.Path, err) } @@ -159,395 +102,3 @@ func (projector *Projector) RebuildWithSummary(ctx context.Context) (RebuildSumm return summary, nil } - -func ScanRoot(root string) ([]Node, error) { - rootPath := strings.TrimSpace(root) - if rootPath == "" { - return nil, nil - } - - info, err := os.Stat(rootPath) - if err != nil { - return nil, fmt.Errorf("stat POSIX root: %w", err) - } - if !info.IsDir() { - return nil, fmt.Errorf("POSIX root is not a directory: %s", rootPath) - } - - rootScope, err := loadRootScope(rootPath) - if err != nil { - return nil, err - } - - nodes := []Node{{ - Path: rootProjectionPath, - ParentPath: nil, - Name: filepath.Base(rootPath), - Depth: 0, - NodeKind: NodeKindDirectory, - LogicalType: "tenant_root", - InstallationID: rootScope.InstallationID, - OrganizationID: rootScope.OrganizationID, - OrganizationSlug: rootScope.OrganizationSlug, - }} - - err = filepath.WalkDir(rootPath, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if path == rootPath { - return nil - } - - relPath, err := filepath.Rel(rootPath, path) - if err != nil { - return err - } - relPath = filepath.ToSlash(relPath) - if relPath == "." { - return nil - } - - node, err := buildNode(rootPath, relPath, entry, rootScope) - if err != nil { - return err - } - - nodes = append(nodes, node) - return nil - }) - if err != nil { - return nil, fmt.Errorf("scan POSIX root: %w", err) - } - - return nodes, nil -} - -func loadRootScope(rootPath string) (Scope, error) { - settingsPath := filepath.Join(rootPath, "settings.json") - content, err := os.ReadFile(settingsPath) - if err != nil { - if errorsIsNotExist(err) { - return Scope{}, nil - } - return Scope{}, fmt.Errorf("read root settings.json: %w", err) - } - - var payload map[string]any - if err := json.Unmarshal(content, &payload); err != nil { - return Scope{}, fmt.Errorf("decode root settings.json: %w", err) - } - - installation, _ := payload["installation"].(map[string]any) - organization, _ := payload["organization"].(map[string]any) - - return Scope{ - InstallationID: stringValue(installation["id"]), - OrganizationID: stringValue(organization["id"]), - OrganizationSlug: stringValue(organization["slug"]), - }, nil -} - -func buildNode(rootPath, relPath string, entry fs.DirEntry, rootScope Scope) (Node, error) { - scope := deriveScope(relPath, rootScope) - parentPath := projectionParentPath(relPath) - logicalType, fileRole := classifyPath(relPath, entry.IsDir()) - - node := Node{ - Path: relPath, - ParentPath: parentPath, - Name: entry.Name(), - Depth: strings.Count(relPath, "/") + 1, - NodeKind: NodeKindDirectory, - LogicalType: logicalType, - FileRole: fileRole, - InstallationID: scope.InstallationID, - OrganizationID: scope.OrganizationID, - OrganizationSlug: scope.OrganizationSlug, - DepartmentSlug: scope.DepartmentSlug, - TeamSlug: scope.TeamSlug, - ProjectSlug: scope.ProjectSlug, - PersonalSlug: scope.PersonalSlug, - } - - if entry.IsDir() { - return node, nil - } - - absPath := filepath.Join(rootPath, filepath.FromSlash(relPath)) - content, err := os.ReadFile(absPath) - if err != nil { - return Node{}, fmt.Errorf("read POSIX file %s: %w", relPath, err) - } - - hash := sha256.Sum256(content) - node.NodeKind = NodeKindFile - node.SizeBytes = int64(len(content)) - node.Checksum = hex.EncodeToString(hash[:]) - - if strings.EqualFold(filepath.Ext(entry.Name()), ".json") { - var payload map[string]any - if err := json.Unmarshal(content, &payload); err == nil { - jsonContent, err := json.Marshal(payload) - if err != nil { - return Node{}, fmt.Errorf("remarshal POSIX file %s: %w", relPath, err) - } - node.ContentJSON = jsonContent - node.ResourceID = stringValue(payload["id"]) - node.ResourceName = stringValue(payload["name"]) - node.ResourceSlug = stringValue(payload["slug"]) - if node.ResourceID == "" && fileRole == "settings" && logicalType == "tenant" { - installation, _ := payload["installation"].(map[string]any) - organization, _ := payload["organization"].(map[string]any) - node.ResourceID = stringValue(installation["id"]) - node.ResourceName = stringValue(installation["name"]) - node.InstallationID = stringValue(installation["id"]) - node.OrganizationID = stringValue(organization["id"]) - node.OrganizationSlug = firstNonEmpty(node.OrganizationSlug, stringValue(organization["slug"])) - } - if node.ResourceID == "" && fileRole == "users" { - node.ResourceName = firstNonEmpty(node.ResourceName, parentEntityName(logicalType, scope)) - } - } - } - - if node.ResourceSlug == "" { - node.ResourceSlug = inferredResourceSlug(logicalType, scope) - } - - return node, nil -} - -func deriveScope(relPath string, rootScope Scope) Scope { - scope := rootScope - parts := strings.Split(relPath, "/") - for _, part := range parts { - switch { - case strings.HasPrefix(part, "department-"): - scope.DepartmentSlug = strings.TrimPrefix(part, "department-") - case strings.HasPrefix(part, "team-"): - scope.TeamSlug = strings.TrimPrefix(part, "team-") - case strings.HasPrefix(part, "project-"): - scope.ProjectSlug = strings.TrimPrefix(part, "project-") - case strings.HasPrefix(part, "personal-"): - scope.PersonalSlug = strings.TrimPrefix(part, "personal-") - } - } - return scope -} - -func classifyPath(relPath string, isDir bool) (logicalType, fileRole string) { - parts := strings.Split(relPath, "/") - name := parts[len(parts)-1] - if !isDir { - fileRole = strings.TrimSuffix(name, filepath.Ext(name)) - } - - hasChildrenAncestor := pathContainsSegment(parts, "children") - hasTreeAncestor := pathContainsSegment(parts, "tree") - parentName := "" - if len(parts) >= 2 { - parentName = parts[len(parts)-2] - } - - switch { - case relPath == "settings.json": - return "tenant", "settings" - case relPath == "layout.json": - return "tenant", "layout" - case len(parts) >= 1 && parts[0] == "catalog": - if isDir { - if len(parts) == 1 { - return "catalog", "" - } - if len(parts) >= 2 && parts[1] == "packs" { - if len(parts) == 2 { - return "catalog_packs", "" - } - if len(parts) == 3 { - return "catalog_pack", "" - } - if len(parts) >= 4 && parts[3] == "entries" { - return "catalog_pack_entries", "" - } - return "catalog_entry", "" - } - if len(parts) >= 2 && parts[1] == "standalone" { - if len(parts) == 2 { - return "catalog_standalone", "" - } - return "catalog_entry", "" - } - } - return "catalog", fileRole - case len(parts) >= 2 && parts[0] == "departments" && strings.HasPrefix(parts[1], "department-"): - if isDir { - if len(parts) == 2 { - return "department", "" - } - if len(parts) == 3 && parts[2] == "teams" { - return "department_teams", "" - } - if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") { - return "team", "" - } - } - if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") { - return "team", fileRole - } - return "department", fileRole - case len(parts) >= 2 && parts[0] == "projects" && strings.HasPrefix(parts[1], "project-"): - if isDir { - if strings.HasPrefix(name, "project-") { - return "project", "" - } - if name == "children" { - return "project_children", "" - } - if name == "tree" { - return "project_tree", "" - } - if hasChildrenAncestor && strings.HasPrefix(name, "folder-") { - return "hierarchy_folder", "" - } - if hasTreeAncestor && strings.HasPrefix(name, "folder-") { - return "hierarchy_folder", "" - } - if hasTreeAncestor && strings.HasPrefix(name, "item-") { - return "item", "" - } - } - if hasChildrenAncestor && strings.HasPrefix(parentName, "folder-") { - return "hierarchy_folder", fileRole - } - if hasTreeAncestor { - if strings.HasPrefix(parentName, "item-") { - return "item", fileRole - } - if strings.HasPrefix(parentName, "folder-") { - return "hierarchy_folder", fileRole - } - } - return "project", fileRole - case len(parts) >= 1 && parts[0] == "users": - if isDir { - if len(parts) == 1 { - return "users", "" - } - if len(parts) == 2 && parts[1] == "personals" { - return "personals", "" - } - if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") { - return "personal", "" - } - if strings.Contains(relPath, "/tree/") || strings.HasSuffix(relPath, "/tree") { - if strings.HasPrefix(name, "folder-") { - return "folder", "" - } - if strings.HasPrefix(name, "item-") { - return "item", "" - } - } - } - if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") { - if strings.Contains(relPath, "/tree/") { - if strings.HasPrefix(parts[len(parts)-2], "item-") { - return "item", fileRole - } - if strings.HasPrefix(parts[len(parts)-2], "folder-") { - return "folder", fileRole - } - } - return "personal", fileRole - } - return "users", fileRole - default: - if isDir { - return "directory", "" - } - return "file", fileRole - } -} - -func pathContainsSegment(parts []string, target string) bool { - for _, part := range parts { - if part == target { - return true - } - } - return false -} - -func projectionParentPath(relPath string) *string { - if relPath == "" || relPath == rootProjectionPath { - return nil - } - parent := filepath.ToSlash(filepath.Dir(relPath)) - if parent == "." || parent == "" { - root := rootProjectionPath - return &root - } - return &parent -} - -func inferredResourceSlug(logicalType string, scope Scope) string { - switch logicalType { - case "department": - return scope.DepartmentSlug - case "team": - return scope.TeamSlug - case "project": - return scope.ProjectSlug - case "personal": - return scope.PersonalSlug - default: - return "" - } -} - -func parentEntityName(logicalType string, scope Scope) string { - switch logicalType { - case "department": - return scope.DepartmentSlug - case "team": - return scope.TeamSlug - case "project": - return scope.ProjectSlug - case "personal": - return scope.PersonalSlug - default: - return "" - } -} - -func stringValue(value any) string { - stringValue, _ := value.(string) - return strings.TrimSpace(stringValue) -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - trimmed := strings.TrimSpace(value) - if trimmed != "" { - return trimmed - } - } - return "" -} - -func summarizeNodes(nodes []Node) RebuildSummary { - summary := RebuildSummary{TotalNodes: len(nodes)} - for _, node := range nodes { - switch node.NodeKind { - case NodeKindDirectory: - summary.DirectoryCount++ - case NodeKindFile: - summary.FileCount++ - } - } - return summary -} - -func errorsIsNotExist(err error) bool { - return err != nil && os.IsNotExist(err) -} diff --git a/Backend/internal/posixproj/projector_classify.go b/Backend/internal/posixproj/projector_classify.go new file mode 100644 index 0000000..f48e299 --- /dev/null +++ b/Backend/internal/posixproj/projector_classify.go @@ -0,0 +1,175 @@ +// Path: Backend/internal/posixproj/projector_classify.go + +package posixproj + +import ( + "path/filepath" + "strings" +) + +func deriveScope(relPath string, rootScope Scope) Scope { + scope := rootScope + parts := strings.Split(relPath, "/") + for _, part := range parts { + switch { + case strings.HasPrefix(part, "department-"): + scope.DepartmentSlug = strings.TrimPrefix(part, "department-") + case strings.HasPrefix(part, "team-"): + scope.TeamSlug = strings.TrimPrefix(part, "team-") + case strings.HasPrefix(part, "project-"): + scope.ProjectSlug = strings.TrimPrefix(part, "project-") + case strings.HasPrefix(part, "personal-"): + scope.PersonalSlug = strings.TrimPrefix(part, "personal-") + } + } + return scope +} + +func classifyPath(relPath string, isDir bool) (logicalType, fileRole string) { + parts := strings.Split(relPath, "/") + name := parts[len(parts)-1] + if !isDir { + fileRole = strings.TrimSuffix(name, filepath.Ext(name)) + } + + hasChildrenAncestor := pathContainsSegment(parts, "children") + hasTreeAncestor := pathContainsSegment(parts, "tree") + parentName := "" + if len(parts) >= 2 { + parentName = parts[len(parts)-2] + } + + switch { + case relPath == "settings.json": + return "tenant", "settings" + case relPath == "layout.json": + return "tenant", "layout" + case len(parts) >= 1 && parts[0] == "catalog": + if isDir { + if len(parts) == 1 { + return "catalog", "" + } + if len(parts) >= 2 && parts[1] == "packs" { + if len(parts) == 2 { return "catalog_packs", "" } + if len(parts) == 3 { return "catalog_pack", "" } + if len(parts) >= 4 && parts[3] == "entries" { return "catalog_pack_entries", "" } + return "catalog_entry", "" + } + if len(parts) >= 2 && parts[1] == "standalone" { + if len(parts) == 2 { return "catalog_standalone", "" } + return "catalog_entry", "" + } + } + return "catalog", fileRole + case len(parts) >= 2 && parts[0] == "departments" && strings.HasPrefix(parts[1], "department-"): + if isDir { + if len(parts) == 2 { return "department", "" } + if len(parts) == 3 && parts[2] == "teams" { return "department_teams", "" } + if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") { return "team", "" } + } + if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") { return "team", fileRole } + return "department", fileRole + case len(parts) >= 2 && parts[0] == "projects" && strings.HasPrefix(parts[1], "project-"): + if isDir { + if strings.HasPrefix(name, "project-") { return "project", "" } + if name == "children" { return "project_children", "" } + if name == "tree" { return "project_tree", "" } + if hasChildrenAncestor && strings.HasPrefix(name, "folder-") { return "hierarchy_folder", "" } + if hasTreeAncestor && strings.HasPrefix(name, "folder-") { return "hierarchy_folder", "" } + if hasTreeAncestor && strings.HasPrefix(name, "item-") { return "item", "" } + } + if hasChildrenAncestor && strings.HasPrefix(parentName, "folder-") { return "hierarchy_folder", fileRole } + if hasTreeAncestor { + if strings.HasPrefix(parentName, "item-") { return "item", fileRole } + if strings.HasPrefix(parentName, "folder-") { return "hierarchy_folder", fileRole } + } + return "project", fileRole + case len(parts) >= 1 && parts[0] == "users": + if isDir { + if len(parts) == 1 { return "users", "" } + if len(parts) == 2 && parts[1] == "personals" { return "personals", "" } + if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") { return "personal", "" } + if strings.Contains(relPath, "/tree/") || strings.HasSuffix(relPath, "/tree") { + if strings.HasPrefix(name, "folder-") { return "folder", "" } + if strings.HasPrefix(name, "item-") { return "item", "" } + } + } + if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") { + if strings.Contains(relPath, "/tree/") { + if strings.HasPrefix(parts[len(parts)-2], "item-") { return "item", fileRole } + if strings.HasPrefix(parts[len(parts)-2], "folder-") { return "folder", fileRole } + } + return "personal", fileRole + } + return "users", fileRole + default: + if isDir { return "directory", "" } + return "file", fileRole + } +} + +func pathContainsSegment(parts []string, target string) bool { + for _, part := range parts { + if part == target { + return true + } + } + return false +} + +func projectionParentPath(relPath string) *string { + if relPath == "" || relPath == rootProjectionPath { + return nil + } + parent := filepath.ToSlash(filepath.Dir(relPath)) + if parent == "." || parent == "" { + root := rootProjectionPath + return &root + } + return &parent +} + +func inferredResourceSlug(logicalType string, scope Scope) string { + switch logicalType { + case "department": + return scope.DepartmentSlug + case "team": + return scope.TeamSlug + case "project": + return scope.ProjectSlug + case "personal": + return scope.PersonalSlug + default: + return "" + } +} + +func parentEntityName(logicalType string, scope Scope) string { + switch logicalType { + case "department": + return scope.DepartmentSlug + case "team": + return scope.TeamSlug + case "project": + return scope.ProjectSlug + case "personal": + return scope.PersonalSlug + default: + return "" + } +} + +func stringValue(value any) string { + stringValue, _ := value.(string) + return strings.TrimSpace(stringValue) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/Backend/internal/posixproj/projector_scan.go b/Backend/internal/posixproj/projector_scan.go new file mode 100644 index 0000000..d1aa352 --- /dev/null +++ b/Backend/internal/posixproj/projector_scan.go @@ -0,0 +1,189 @@ +// Path: Backend/internal/posixproj/projector_scan.go + +package posixproj + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +func ScanRoot(root string) ([]Node, error) { + rootPath := strings.TrimSpace(root) + if rootPath == "" { + return nil, nil + } + + info, err := os.Stat(rootPath) + if err != nil { + return nil, fmt.Errorf("stat POSIX root: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("POSIX root is not a directory: %s", rootPath) + } + + rootScope, err := loadRootScope(rootPath) + if err != nil { + return nil, err + } + + nodes := []Node{{ + Path: rootProjectionPath, + ParentPath: nil, + Name: filepath.Base(rootPath), + Depth: 0, + NodeKind: NodeKindDirectory, + LogicalType: "tenant_root", + InstallationID: rootScope.InstallationID, + OrganizationID: rootScope.OrganizationID, + OrganizationSlug: rootScope.OrganizationSlug, + }} + + err = filepath.WalkDir(rootPath, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if path == rootPath { + return nil + } + + relPath, err := filepath.Rel(rootPath, path) + if err != nil { + return err + } + relPath = filepath.ToSlash(relPath) + if relPath == "." { + return nil + } + + node, err := buildNode(rootPath, relPath, entry, rootScope) + if err != nil { + return err + } + + nodes = append(nodes, node) + return nil + }) + if err != nil { + return nil, fmt.Errorf("scan POSIX root: %w", err) + } + + return nodes, nil +} + +func loadRootScope(rootPath string) (Scope, error) { + settingsPath := filepath.Join(rootPath, "settings.json") + content, err := os.ReadFile(settingsPath) + if err != nil { + if errorsIsNotExist(err) { + return Scope{}, nil + } + return Scope{}, fmt.Errorf("read root settings.json: %w", err) + } + + var payload map[string]any + if err := json.Unmarshal(content, &payload); err != nil { + return Scope{}, fmt.Errorf("decode root settings.json: %w", err) + } + + installation, _ := payload["installation"].(map[string]any) + organization, _ := payload["organization"].(map[string]any) + + return Scope{ + InstallationID: stringValue(installation["id"]), + OrganizationID: stringValue(organization["id"]), + OrganizationSlug: stringValue(organization["slug"]), + }, nil +} + +func buildNode(rootPath, relPath string, entry fs.DirEntry, rootScope Scope) (Node, error) { + scope := deriveScope(relPath, rootScope) + parentPath := projectionParentPath(relPath) + logicalType, fileRole := classifyPath(relPath, entry.IsDir()) + + node := Node{ + Path: relPath, + ParentPath: parentPath, + Name: entry.Name(), + Depth: strings.Count(relPath, "/") + 1, + NodeKind: NodeKindDirectory, + LogicalType: logicalType, + FileRole: fileRole, + InstallationID: scope.InstallationID, + OrganizationID: scope.OrganizationID, + OrganizationSlug: scope.OrganizationSlug, + DepartmentSlug: scope.DepartmentSlug, + TeamSlug: scope.TeamSlug, + ProjectSlug: scope.ProjectSlug, + PersonalSlug: scope.PersonalSlug, + } + + if entry.IsDir() { + return node, nil + } + + absPath := filepath.Join(rootPath, filepath.FromSlash(relPath)) + content, err := os.ReadFile(absPath) + if err != nil { + return Node{}, fmt.Errorf("read POSIX file %s: %w", relPath, err) + } + + hash := sha256.Sum256(content) + node.NodeKind = NodeKindFile + node.SizeBytes = int64(len(content)) + node.Checksum = hex.EncodeToString(hash[:]) + + if strings.EqualFold(filepath.Ext(entry.Name()), ".json") { + var payload map[string]any + if err := json.Unmarshal(content, &payload); err == nil { + jsonContent, err := json.Marshal(payload) + if err != nil { + return Node{}, fmt.Errorf("remarshal POSIX file %s: %w", relPath, err) + } + node.ContentJSON = jsonContent + node.ResourceID = stringValue(payload["id"]) + node.ResourceName = stringValue(payload["name"]) + node.ResourceSlug = stringValue(payload["slug"]) + if node.ResourceID == "" && fileRole == "settings" && logicalType == "tenant" { + installation, _ := payload["installation"].(map[string]any) + organization, _ := payload["organization"].(map[string]any) + node.ResourceID = stringValue(installation["id"]) + node.ResourceName = stringValue(installation["name"]) + node.InstallationID = stringValue(installation["id"]) + node.OrganizationID = stringValue(organization["id"]) + node.OrganizationSlug = firstNonEmpty(node.OrganizationSlug, stringValue(organization["slug"])) + } + if node.ResourceID == "" && fileRole == "users" { + node.ResourceName = firstNonEmpty(node.ResourceName, parentEntityName(logicalType, scope)) + } + } + } + + if node.ResourceSlug == "" { + node.ResourceSlug = inferredResourceSlug(logicalType, scope) + } + + return node, nil +} + +func summarizeNodes(nodes []Node) RebuildSummary { + summary := RebuildSummary{TotalNodes: len(nodes)} + for _, node := range nodes { + switch node.NodeKind { + case NodeKindDirectory: + summary.DirectoryCount++ + case NodeKindFile: + summary.FileCount++ + } + } + return summary +} + +func errorsIsNotExist(err error) bool { + return err != nil && os.IsNotExist(err) +} diff --git a/Backend/internal/posixproj/projector_test.go b/Backend/internal/posixproj/projector_test.go index 45f3601..788fd81 100644 --- a/Backend/internal/posixproj/projector_test.go +++ b/Backend/internal/posixproj/projector_test.go @@ -1,3 +1,5 @@ +// Path: Backend/internal/posixproj/projector_test.go + package posixproj import ( diff --git a/Backend/internal/posixproj/projector_types.go b/Backend/internal/posixproj/projector_types.go new file mode 100644 index 0000000..ee5adbb --- /dev/null +++ b/Backend/internal/posixproj/projector_types.go @@ -0,0 +1,58 @@ +// Path: Backend/internal/posixproj/projector_types.go + +package posixproj + +import "moku-backend/internal/database" + +const rootProjectionPath = "/" + +type Projector struct { + db *database.DB + root string +} + +type RebuildSummary struct { + TotalNodes int + DirectoryCount int + FileCount int +} + +type NodeKind string + +const ( + NodeKindDirectory NodeKind = "directory" + NodeKindFile NodeKind = "file" +) + +type Scope struct { + InstallationID string + OrganizationID string + OrganizationSlug string + DepartmentSlug string + TeamSlug string + ProjectSlug string + PersonalSlug string +} + +type Node struct { + Path string + ParentPath *string + Name string + Depth int + NodeKind NodeKind + LogicalType string + FileRole string + ResourceID string + ResourceName string + ResourceSlug string + InstallationID string + OrganizationID string + OrganizationSlug string + DepartmentSlug string + TeamSlug string + ProjectSlug string + PersonalSlug string + ContentJSON []byte + SizeBytes int64 + Checksum string +} diff --git a/Backend/internal/worker/runner.go b/Backend/internal/worker/runner.go index bfb8afd..9024869 100644 --- a/Backend/internal/worker/runner.go +++ b/Backend/internal/worker/runner.go @@ -1,3 +1,5 @@ +// Path: Backend/internal/worker/runner.go + package worker import ( diff --git a/Backend/internal/worker/runner_test.go b/Backend/internal/worker/runner_test.go index d67c67c..e60ab4c 100644 --- a/Backend/internal/worker/runner_test.go +++ b/Backend/internal/worker/runner_test.go @@ -1,3 +1,5 @@ +// Path: Backend/internal/worker/runner_test.go + package worker import (