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 => (
    )}
    ); };