diff --git a/frontend/src/app/recollections/RecollectionLayout.tsx b/frontend/src/app/recollections/RecollectionLayout.tsx index eb17de3..83f79e8 100644 --- a/frontend/src/app/recollections/RecollectionLayout.tsx +++ b/frontend/src/app/recollections/RecollectionLayout.tsx @@ -7,8 +7,7 @@ import { Outlet, useParams, useNavigate } from 'react-router-dom' import { toast } from 'sonner' import { usePlatform } from '@/app/kosmos/KosmosContext' import { RecollectionActionsProvider } from './layout/RecollectionActionsContext' -import { RecollectionSidebarProvider } from './layout/RecollectionSidebarContext' -import { FluxSceneProvider } from './flux/FluxSceneContext' +import { WorkspaceTreeProvider } from './layout/WorkspaceTreeContext' import { RecollectionMenubar } from './layout/RecollectionMenubar' import { RecollectionSidebar } from './layout/RecollectionSidebar' @@ -50,19 +49,17 @@ export function RecollectionLayout() { return ( - - -
- -
- -
- -
+ +
+ +
+ +
+
- - +
+
) } diff --git a/frontend/src/app/recollections/flux/FluxRoute.tsx b/frontend/src/app/recollections/flux/FluxRoute.tsx index ce65580..3a9c4ad 100644 --- a/frontend/src/app/recollections/flux/FluxRoute.tsx +++ b/frontend/src/app/recollections/flux/FluxRoute.tsx @@ -7,7 +7,7 @@ import React, { useEffect, useRef } from 'react' import { useParams, useSearchParams } from 'react-router-dom' import { CanvasPage } from '@/app/canvas/CanvasPage' import { usePlatform } from '@/app/kosmos/KosmosContext' -import { useFluxScenes } from './FluxSceneContext' +import { useFluxScenes } from '../layout/WorkspaceTreeContext' export function FluxRoute() { const { recollectionId } = useParams<{ recollectionId: string }>() diff --git a/frontend/src/app/recollections/layout/RecollectionSidebar.tsx b/frontend/src/app/recollections/layout/RecollectionSidebar.tsx index 10e29f8..f872f6d 100644 --- a/frontend/src/app/recollections/layout/RecollectionSidebar.tsx +++ b/frontend/src/app/recollections/layout/RecollectionSidebar.tsx @@ -1,237 +1,275 @@ /** - * Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes. + * Recollection sidebar: VS Code-style collapsible/resizable sections. + * - Workspace: unified tree (pages, scenes, folders) + * - Katalogos: artifacts & run history + * Horizontally resizable via right-edge drag. Sections vertically resizable via divider drag. */ -import React, { useCallback, useState } from 'react' +import React, { useCallback, useRef, useState } from 'react' import { useParams, useLocation, useNavigate } from 'react-router-dom' -import { Button } from '@/components/ui/button' -import { Plus, FileText, Pencil, Trash2, Check, X } from 'lucide-react' +import { Plus, FileText, Folder, ChevronDown, ChevronRight, Search, X, ChevronsDownUp, ChevronsUpDown } from 'lucide-react' import { FluxIcon } from '@/lib/icons' import { cn } from '@/lib/utils' -import { useRecollectionSidebar } from './RecollectionSidebarContext' -import { useOptionalFluxScenes } from '../flux/FluxSceneContext' -import { TreeBrowser } from './TreeBrowser' +import { useWorkspaceTree } from './WorkspaceTreeContext' +import { TreeBrowser, type TreeBrowserHandle } from './TreeBrowser' +import { Input } from '@/components/ui/input' import { - SidebarContent, - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuItem, - sidebarMenuButtonVariants, -} from '@/components/ui/sidebar' + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' -export function RecollectionSidebar() { - const { recollectionId } = useParams<{ recollectionId: string }>() - const { pathname } = useLocation() - const navigate = useNavigate() - const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar() +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- - const base = recollectionId ? `/recollections/${recollectionId}` : '' - const baseLogos = `${base}/logos` - const isKatalogosView = pathname.endsWith('/logos/katalogos') - const isFluxView = pathname.endsWith('/flux') +const MIN_WIDTH = 180 +const MAX_WIDTH = 480 +const DEFAULT_WIDTH = 240 +const SECTION_HEADER_H = 28 +const MIN_SECTION_H = SECTION_HEADER_H + 32 // header + one row - const handleAddPage = useCallback(() => { - // Add a new top-level page - const maxPos = Math.max(0, ...tree.filter((p) => p.parentId === null).map((p) => p.position), -1) - const newPage = { - id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, - title: 'Untitled', - parentId: null, - position: maxPos + 1, - } - handleTreeChange([...tree, newPage]) - handleSelectPage(newPage.id) - navigate(`${baseLogos}?page=${encodeURIComponent(newPage.id)}`) - }, [tree, handleTreeChange, handleSelectPage, baseLogos, navigate]) +// --------------------------------------------------------------------------- +// Section header (VS Code style: uppercase label, chevron, actions on hover) +// --------------------------------------------------------------------------- +function SectionHeader({ + title, + open, + onToggle, + actions, +}: { + title: string + open: boolean + onToggle: () => void + actions?: React.ReactNode +}) { return (
- - -
- Logos - -
- - - -
- - Katalogos - - - - - - - - - -
+ {open + ? + : } + + {title} + + {actions && ( +
e.stopPropagation()} + > + {actions} +
+ )}
) } // --------------------------------------------------------------------------- -// Flux Scenes Section +// Katalogos section content // --------------------------------------------------------------------------- -function FluxScenesSection({ base, isFluxView }: { base: string; isFluxView: boolean }) { +function KatalogosContent() { + const { recollectionId } = useParams<{ recollectionId: string }>() + const { pathname } = useLocation() const navigate = useNavigate() - const fluxScenes = useOptionalFluxScenes() - const [renamingId, setRenamingId] = useState(null) - const [renameValue, setRenameValue] = useState('') - - // Fallback: single "Canvas" link when provider isn't mounted - if (!fluxScenes) { - return ( - - Flux - - - - - - - - - ) - } - - const { scenes, activeSceneId, handleSelectScene, handleAddScene, handleRenameScene, handleDeleteScene } = fluxScenes - - const startRename = (id: string, currentTitle: string) => { - setRenamingId(id) - setRenameValue(currentTitle) - } - - const commitRename = () => { - if (renamingId && renameValue.trim()) { - handleRenameScene(renamingId, renameValue.trim()) - } - setRenamingId(null) - } - - const cancelRename = () => { - setRenamingId(null) - } + const base = recollectionId ? `/recollections/${recollectionId}` : '' + const isKatalogosView = pathname.endsWith('/logos/katalogos') return ( - -
- Flux - -
- - - {scenes.map((scene) => { - const isActive = isFluxView && scene.id === activeSceneId - - return ( - - {renamingId === scene.id ? ( -
- setRenameValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') commitRename() - if (e.key === 'Escape') cancelRename() - }} - onBlur={commitRename} - /> - - -
- ) : ( -
- -
- - {scenes.length > 1 && ( - - )} -
-
- )} -
- ) - })} -
-
-
+
+ +
+ ) +} + +// --------------------------------------------------------------------------- +// Sidebar +// --------------------------------------------------------------------------- + +export function RecollectionSidebar() { + const { handleAddNode } = useWorkspaceTree() + + // Search & tree ref + const [searchQuery, setSearchQuery] = useState('') + const [searchVisible, setSearchVisible] = useState(false) + const treeBrowserRef = useRef(null) + const searchInputRef = useRef(null) + + // Horizontal resize + const [width, setWidth] = useState(DEFAULT_WIDTH) + const onHResizeStart = useCallback((e: React.PointerEvent) => { + e.preventDefault() + const startX = e.clientX + const startW = width + const onMove = (ev: PointerEvent) => setWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startW + (ev.clientX - startX)))) + const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = '' } + document.addEventListener('pointermove', onMove) + document.addEventListener('pointerup', onUp) + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + }, [width]) + + // Section collapse + const [workspaceOpen, setWorkspaceOpen] = useState(true) + const [katalogosOpen, setKatalogosOpen] = useState(true) + + // Vertical section resize (drag the divider between sections) + // workspaceFlex: fraction of available space for workspace section (0.2–0.8) + const [workspaceFlex, setWorkspaceFlex] = useState(0.75) + const containerRef = useRef(null) + + const onVResizeStart = useCallback((e: React.PointerEvent) => { + e.preventDefault() + const container = containerRef.current + if (!container) return + const startY = e.clientY + const startFlex = workspaceFlex + const containerH = container.clientHeight + + const onMove = (ev: PointerEvent) => { + const delta = ev.clientY - startY + const newFlex = startFlex + delta / containerH + setWorkspaceFlex(Math.min(0.85, Math.max(0.15, newFlex))) + } + const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = '' } + document.addEventListener('pointermove', onMove) + document.addEventListener('pointerup', onUp) + document.body.style.cursor = 'row-resize' + document.body.style.userSelect = 'none' + }, [workspaceFlex]) + + const bothOpen = workspaceOpen && katalogosOpen + + return ( +
+
+ {/* Workspace section */} +
+ setWorkspaceOpen((v) => !v)} + actions={ + <> + + + + + + + + handleAddNode('page')}> + Page + + handleAddNode('scene')}> + Scene + + handleAddNode('folder')}> + Folder + + + + + } + /> + {workspaceOpen && ( +
+ {searchVisible && ( +
+
+ setSearchQuery(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Escape') { setSearchQuery(''); setSearchVisible(false) } }} + className="h-6 text-xs pl-2 pr-6 bg-sidebar-accent/30 border-sidebar-border/50 focus:bg-sidebar-accent/50" + /> + {searchQuery && ( + + )} +
+
+ )} +
+ +
+
+ )} +
+ + {/* Vertical resize divider (only when both sections open) */} + {bothOpen && ( +
+ )} + + {/* Katalogos section */} +
+ setKatalogosOpen((v) => !v)} + /> + {katalogosOpen && ( +
+ +
+ )} +
+
+ + {/* Horizontal resize handle */} +
+
) } diff --git a/frontend/src/app/recollections/layout/TreeBrowser.tsx b/frontend/src/app/recollections/layout/TreeBrowser.tsx index 36466bc..25b35ab 100644 --- a/frontend/src/app/recollections/layout/TreeBrowser.tsx +++ b/frontend/src/app/recollections/layout/TreeBrowser.tsx @@ -1,23 +1,12 @@ /** - * TreeBrowser: A tree browser component using react-arborist for managing - * Logos pages in a hierarchical folder structure. - * - * Features: - * - Drag and drop reordering - * - Folder expansion/collapse - * - Context menu for actions - * - Shadcn theme integration - * - Inline editing for page titles - * - Smooth animations - * - Search functionality - * - Expand/collapse all + * TreeBrowser: Unified workspace tree (pages, scenes, folders). + * Notion/VS Code inspired design. Only folders accept children. */ -import React, { useCallback, useMemo, useRef, useState } from 'react' +import React, { useCallback, useMemo, useRef } from 'react' import { useResizeHeight } from '@/hooks/useResizeHeight' import { Tree, - NodeApi, NodeRendererProps, RowRendererProps, TreeApi, @@ -32,13 +21,10 @@ import { MoreHorizontal, Trash2, Pencil, - Search, - ChevronsDownUp, - ChevronsUpDown, - X, - GripVertical, + Folder, + FolderOpen, } from 'lucide-react' -import { Button } from '@/components/ui/button' +import { FluxIcon } from '@/lib/icons' import { DropdownMenu, DropdownMenuContent, @@ -46,489 +32,332 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { Input } from '@/components/ui/input' -import { useRecollectionSidebar } from './RecollectionSidebarContext' -import { useParams, useNavigate, useLocation } from 'react-router-dom' -import type { LogosPageMeta } from '../state/recollectionStore' -import { removeLogosPageContent } from '../state/recollectionStore' +import { useWorkspaceTree } from './WorkspaceTreeContext' +import type { WorkspaceNodeMeta } from '../state/recollectionStore' -// Tree node: page metadata plus tree shape (react-arborist `node.data` is this whole object). -type TreeNode = LogosPageMeta & { +// --------------------------------------------------------------------------- +// Tree node type +// --------------------------------------------------------------------------- + +type TreeNode = WorkspaceNodeMeta & { children?: TreeNode[] - isFolder: boolean } -// Convert flat tree to hierarchical structure -function buildTree(pages: LogosPageMeta[]): TreeNode[] { - const pageMap = new Map() +function buildTree(nodes: WorkspaceNodeMeta[]): TreeNode[] { + const nodeMap = new Map() const rootNodes: TreeNode[] = [] - // First pass: create all nodes - pages.forEach((page) => { - pageMap.set(page.id, { - ...page, - children: [], - isFolder: false, - }) - }) + for (const n of nodes) nodeMap.set(n.id, { ...n, children: n.kind === 'folder' ? [] : undefined }) - // Second pass: build parent-child relationships - pages.forEach((page) => { - const node = pageMap.get(page.id)! - if (page.parentId === null) { - rootNodes.push(node) + for (const n of nodes) { + const tn = nodeMap.get(n.id)! + if (n.parentId === null) { + rootNodes.push(tn) } else { - const parent = pageMap.get(page.parentId) - if (parent) { - parent.children?.push(node) - parent.isFolder = true - } + const parent = nodeMap.get(n.parentId) + if (parent?.children) parent.children.push(tn) + else rootNodes.push(tn) // orphan fallback } - }) - - // Sort children by position - function sortNodes(nodes: TreeNode[]) { - nodes.sort((a, b) => a.position - b.position) - nodes.forEach((node) => sortNodes(node.children || [])) } - sortNodes(rootNodes) + const sort = (ns: TreeNode[]) => { + ns.sort((a, b) => { + // Folders first, then by position + const aIsFolder = a.kind === 'folder' ? 0 : 1 + const bIsFolder = b.kind === 'folder' ? 0 : 1 + if (aIsFolder !== bIsFolder) return aIsFolder - bIsFolder + return a.position - b.position + }) + ns.forEach((n) => { if (n.children) sort(n.children) }) + } + sort(rootNodes) return rootNodes } -// Memoize the buildTree result to avoid unnecessary re-creation -function useTreeNodes(tree: LogosPageMeta[]) { - return useMemo(() => buildTree(tree), [tree]) -} - -// Inline editable title component -function EditableTitle({ - title, - onSave, - onCancel, -}: { - title: string - onSave: (newTitle: string) => void - onCancel: () => void -}) { - const inputRef = useRef(null) - - React.useEffect(() => { - inputRef.current?.focus() - inputRef.current?.select() - }, []) - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - onSave(inputRef.current?.value || '') - } else if (e.key === 'Escape') { - onCancel() - } - } +// --------------------------------------------------------------------------- +// Inline title editor +// --------------------------------------------------------------------------- +function EditableTitle({ title, onSave, onCancel }: { title: string; onSave: (v: string) => void; onCancel: () => void }) { + const ref = useRef(null) + React.useEffect(() => { ref.current?.focus(); ref.current?.select() }, []) return ( onSave(e.currentTarget.value)} - onKeyDown={handleKeyDown} - className="w-full rounded px-1 py-0.5 text-sm outline-none ring-2 ring-ring focus:ring-2 transition-all duration-200 bg-background" + onKeyDown={(e) => { if (e.key === 'Enter') onSave(ref.current?.value || ''); else if (e.key === 'Escape') onCancel() }} + className="w-full rounded-sm px-1 py-0.5 text-[13px] outline-none ring-1 ring-primary/50 bg-background" onClick={(e) => e.stopPropagation()} /> ) } -const ROW_INDENT_PX = 20 -const ROW_GUTTER_PX = 8 +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- -/** - * Line cursor = “insert as sibling at this indent” (not “drop into folder”). - * Triangle + dashed rail read as a slot between rows; matches sidebar theme. - */ -const LogosDropCursor = React.memo(function LogosDropCursor({ top, left, indent }: CursorProps) { +const INDENT = 10 +const GUTTER = 2 + +// --------------------------------------------------------------------------- +// Drop cursor (line between rows) +// --------------------------------------------------------------------------- + +const DropCursor = React.memo(function DropCursor({ top, left, indent }: CursorProps) { return (
- - - - - -
-
-
+ +
) }) -/** - * Drag ref on a compact handle at the start of the row; handle is a floating pill shown on row hover - * (always visible on coarse pointers). Title/icon strip is click-to-navigate only. - */ -function LogosTreeNode({ node, dragHandle, style }: NodeRendererProps) { - const { recollectionId } = useParams<{ recollectionId: string }>() - const navigate = useNavigate() - const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar() +// --------------------------------------------------------------------------- +// Node icon +// --------------------------------------------------------------------------- - const base = recollectionId ? `/recollections/${recollectionId}` : '' - const baseLogos = `${base}/logos` +function NodeIcon({ kind, isOpen }: { kind: string; isOpen?: boolean }) { + const cls = 'size-[15px] shrink-0 opacity-60' + if (kind === 'scene') return + if (kind === 'folder') return isOpen ? : + return +} - const handleToggle = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation() - node.toggle() - }, - [node] - ) +// --------------------------------------------------------------------------- +// Tree node renderer (Notion/VS Code style) +// --------------------------------------------------------------------------- - const handleSelect = useCallback( - (e: React.MouseEvent) => { - if (!node.state.isEditing && !node.state.isDragging) { - handleSelectPage(node.data.id) - navigate(`${baseLogos}?page=${encodeURIComponent(node.data.id)}`) - } - }, - [handleSelectPage, node.data.id, baseLogos, navigate, node.state.isEditing, node.state.isDragging] - ) +function WorkspaceTreeNode({ node, dragHandle, style }: NodeRendererProps) { + const { handleSelectItem, handleAddNode, handleRenameNode, handleDeleteNode } = useWorkspaceTree() - const handleRename = useCallback(() => { - node.edit() - }, [node]) + const isFolder = node.data.kind === 'folder' + const hasChildren = isFolder && (node.children?.length || 0) > 0 - const handleDelete = useCallback(() => { - const toDelete = new Set() - function collectDescendants(id: string) { - toDelete.add(id) - tree.forEach((p) => { - if (p.parentId === id) collectDescendants(p.id) - }) - } - collectDescendants(node.data.id) - if (recollectionId) { - toDelete.forEach((pageId) => { - removeLogosPageContent(recollectionId, pageId) - }) - } - handleTreeChange(tree.filter((p) => !toDelete.has(p.id))) - }, [tree, handleTreeChange, node.data.id, recollectionId]) - - const handleAddChild = useCallback(() => { - const maxPos = Math.max( - 0, - ...tree.filter((p) => p.parentId === node.data.id).map((p) => p.position), - -1 - ) - const newPage: LogosPageMeta = { - id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, - title: 'Untitled', - parentId: node.data.id, - position: maxPos + 1, - } - handleTreeChange([...tree, newPage]) - if (!node.isOpen) node.open() - }, [tree, handleTreeChange, node]) - - const hasChildren = (node.children?.length || 0) > 0 - const pageTitle = node.data.title || 'Untitled' + const handleClick = useCallback(() => { + if (node.state.isEditing || node.state.isDragging) return + if (isFolder) node.toggle() + else handleSelectItem(node.data.id) + }, [handleSelectItem, node, isFolder]) + // The entire row is the drag handle — Notion style return ( -
- {/* Floating drag pill: expands on row hover; coarse pointers keep it tappable */} -
-
e.stopPropagation()} - > - -
-
- - {hasChildren ? ( +
+ {/* Chevron (folders only) */} + {isFolder ? ( ) : ( -
+
)} -
+ + {/* Title */} +
+ {node.state.isEditing ? ( + { if (v.trim()) handleRenameNode(node.data.id, v.trim()); node.submit(v) }} + onCancel={() => node.reset()} + /> + ) : ( + {node.data.title || 'Untitled'} )} - onClick={handleSelect} - > - - -
- {node.state.isEditing ? ( - { - if (newTitle.trim()) { - handleTreeChange( - tree.map((p) => (p.id === node.data.id ? { ...p, title: newTitle.trim() } : p)) - ) - } - node.submit(newTitle) - }} - onCancel={() => node.reset()} - /> - ) : ( - {pageTitle} - )} -
-
e.stopPropagation()} - > - - - - - - e.stopPropagation()}> - - - Rename - - - - - Delete - - - -
+ {/* Plus — only on folders (add inside) or any node (add sibling) */} + + + + + + { const pid = isFolder ? node.data.id : node.data.parentId; handleAddNode('page', pid); if (isFolder && !node.isOpen) node.open() }}> + Page + + { const pid = isFolder ? node.data.id : node.data.parentId; handleAddNode('scene', pid); if (isFolder && !node.isOpen) node.open() }}> + Scene + + { const pid = isFolder ? node.data.id : node.data.parentId; handleAddNode('folder', pid); if (isFolder && !node.isOpen) node.open() }}> + Folder + + + + + {/* More */} + + + + + + node.edit()}> + Rename + + + handleDeleteNode(node.data.id)} className="text-destructive focus:text-destructive"> + Delete + + + +
+ )}
) } -/** Drop target + layout shell; `children` must be the Node renderer (drag layer). */ +// --------------------------------------------------------------------------- +// Row renderer +// --------------------------------------------------------------------------- + function TreeRow({ node, innerRef, attrs, children }: RowRendererProps) { - const { pathname } = useLocation() - const { activePageId } = useRecollectionSidebar() - const { recollectionId } = useParams<{ recollectionId: string }>() - const base = recollectionId ? `/recollections/${recollectionId}` : '' - const baseLogos = `${base}/logos` - const isActive = pathname.startsWith(baseLogos) && activePageId === node.data.id - const level = node.level - const indentPx = ROW_GUTTER_PX + level * ROW_INDENT_PX + const { activeItemId } = useWorkspaceTree() + const isActive = activeItemId === node.data.id + const indentPx = GUTTER + node.level * INDENT return (
- {level > 0 && ( + {/* VS Code-style indent guides */} + {Array.from({ length: node.level }, (_, i) => (
- )} + ))} {children}
) } -export function TreeBrowser() { - const { tree, handleTreeChange } = useRecollectionSidebar() - const [searchQuery, setSearchQuery] = useState('') +// --------------------------------------------------------------------------- +// TreeBrowser +// --------------------------------------------------------------------------- + +export type TreeBrowserHandle = { + openAll: () => void + closeAll: () => void +} + +export type TreeBrowserProps = { + searchQuery?: string +} + +export const TreeBrowser = React.forwardRef( + function TreeBrowser({ searchQuery = '' }, ref) { + const { tree, handleTreeChange } = useWorkspaceTree() const treeRef = useRef | null>(null) - const [treeContainerHeight, treeContainerRef] = useResizeHeight(600) + const [treeContainerHeight, treeContainerRef] = useResizeHeight(200) - // Build tree from flat structure - const treeNodes = useTreeNodes(tree) + React.useImperativeHandle(ref, () => ({ + openAll: () => treeRef.current?.openAll(), + closeAll: () => treeRef.current?.closeAll(), + }), []) + + const treeNodes = useMemo(() => buildTree(tree), [tree]) - // Filter tree based on search query const filteredTree = useMemo(() => { if (!searchQuery.trim()) return treeNodes - - const searchTerm = searchQuery.toLowerCase() - - function filterNodes(nodes: TreeNode[]): TreeNode[] { - const result: TreeNode[] = [] - for (const node of nodes) { - const matches = node.title.toLowerCase().includes(searchTerm) - const children = filterNodes(node.children || []) - - if (matches || children.length > 0) { - result.push({ - ...node, - children: children.length > 0 ? children : undefined, - }) - } + const term = searchQuery.toLowerCase() + const filter = (nodes: TreeNode[]): TreeNode[] => { + const out: TreeNode[] = [] + for (const n of nodes) { + const matches = n.title.toLowerCase().includes(term) + const kids = n.children ? filter(n.children) : [] + if (matches || kids.length > 0) out.push({ ...n, children: kids.length > 0 ? kids : n.children ? [] : undefined }) } - return result + return out } - - return filterNodes(treeNodes) + return filter(treeNodes) }, [treeNodes, searchQuery]) - // Get all folder IDs for initial open state const initialOpenState = useMemo(() => { - const folderIds: Record = {} - function collectIds(nodes: TreeNode[]) { - nodes.forEach((node) => { - if (node.isFolder) { - folderIds[node.id] = true - collectIds(node.children || []) - } - }) - } - collectIds(treeNodes) - return folderIds + const ids: Record = {} + const collect = (ns: TreeNode[]) => ns.forEach((n) => { if (n.kind === 'folder') { ids[n.id] = true; if (n.children) collect(n.children) } }) + collect(treeNodes) + return ids }, [treeNodes]) - // Handle drag and drop reordering (react-arborist onMove) const handleMove = useCallback( - ({ - dragIds, - parentId: newParentId, - index, - }: { - dragIds: string[] - parentId: string | null - index: number - }) => { + ({ dragIds, parentId: newParentId, index }: { dragIds: string[]; parentId: string | null; index: number }) => { const dragId = dragIds[0] if (!dragId) return - - const dragged = tree.find((p) => p.id === dragId) + if (newParentId !== null) { + const target = tree.find((n) => n.id === newParentId) + if (target && target.kind !== 'folder') return + } + const dragged = tree.find((n) => n.id === dragId) if (!dragged) return const oldParentId = dragged.parentId - const moved: LogosPageMeta = { ...dragged, parentId: newParentId } + const moved: WorkspaceNodeMeta = { ...dragged, parentId: newParentId } const newSiblings = tree - .filter((p) => p.parentId === newParentId && p.id !== dragId) + .filter((n) => n.parentId === newParentId && n.id !== dragId) .sort((a, b) => a.position - b.position) newSiblings.splice(index, 0, moved) - const updates = new Map() - newSiblings.forEach((p, i) => { - updates.set(p.id, { ...p, parentId: newParentId, position: i }) - }) + const updates = new Map() + newSiblings.forEach((n, i) => updates.set(n.id, { ...n, parentId: newParentId, position: i })) if (oldParentId !== newParentId) { tree - .filter((p) => p.parentId === oldParentId && p.id !== dragId) + .filter((n) => n.parentId === oldParentId && n.id !== dragId) .sort((a, b) => a.position - b.position) - .forEach((p, i) => { - updates.set(p.id, { ...p, position: i }) - }) + .forEach((n, i) => updates.set(n.id, { ...n, position: i })) } - handleTreeChange(tree.map((p) => updates.get(p.id) ?? p)) + handleTreeChange(tree.map((n) => updates.get(n.id) ?? n)) }, [tree, handleTreeChange] ) - // Expand all folders - const handleExpandAll = useCallback(() => { - treeRef.current?.openAll() - }, []) - - // Collapse all folders - const handleCollapseAll = useCallback(() => { - treeRef.current?.closeAll() - }, []) - - // Clear search - const handleClearSearch = useCallback(() => { - setSearchQuery('') - }, []) - - // Fixed prop shape: react-arborist TreeProvider uses [...Object.values(treeProps), …] as useMemo deps; - // varying key counts on the props object trigger "dependency array changed size" warnings. const arboristTreeProps = useMemo( () => ({ data: filteredTree, @@ -536,74 +365,23 @@ export function TreeBrowser() { childrenAccessor: 'children' as const, width: '100%' as const, height: Math.max(treeContainerHeight, 100), - rowHeight: 32, - indent: ROW_INDENT_PX, + rowHeight: 30, + indent: INDENT, renderRow: TreeRow, initialOpenState, onMove: handleMove, disableDrag: Boolean(searchQuery), disableDrop: Boolean(searchQuery), className: 'react-arborist-tree', - renderCursor: LogosDropCursor, - children: LogosTreeNode, + renderCursor: DropCursor, + children: WorkspaceTreeNode, }), [filteredTree, initialOpenState, handleMove, searchQuery, treeContainerHeight] ) return ( -
- {/* Search Toolbar */} -
-
- - setSearchQuery(e.target.value)} - className="pl-7 pr-7 h-7 text-xs bg-sidebar-accent/50 border-sidebar-border" - /> - {searchQuery && ( - - )} -
- - - - - - - - Expand all - - - - Collapse all - - - -
- - {/* Tree */} -
- -
+
+
) -} \ No newline at end of file +}) diff --git a/frontend/src/app/recollections/layout/WorkspaceTreeContext.tsx b/frontend/src/app/recollections/layout/WorkspaceTreeContext.tsx new file mode 100644 index 0000000..60c2098 --- /dev/null +++ b/frontend/src/app/recollections/layout/WorkspaceTreeContext.tsx @@ -0,0 +1,265 @@ +/** + * Unified workspace tree context: pages, scenes, and folders in a single tree. + * Replaces both RecollectionSidebarContext and FluxSceneContext. + * Exports backward-compat shim hooks for existing consumers. + */ + +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom' +import { + ensureWorkspaceTree, + setWorkspaceTree, + removeLogosPageContent, + removeSceneGraph, + DEFAULT_SCENE_ID, + type WorkspaceNodeMeta, + type WorkspaceNodeId, + type WorkspaceNodeKind, + type LogosPageMeta, + type FluxSceneMeta, + type FluxSceneId, +} from '../state/recollectionStore' + +// --------------------------------------------------------------------------- +// Context types +// --------------------------------------------------------------------------- + +export type WorkspaceTreeContextValue = { + tree: WorkspaceNodeMeta[] + setTree: React.Dispatch> + + activeItemId: WorkspaceNodeId | null + activeItemKind: WorkspaceNodeKind | null + + handleSelectItem: (id: WorkspaceNodeId) => void + handleTreeChange: (tree: WorkspaceNodeMeta[]) => void + + handleAddNode: (kind: WorkspaceNodeKind, parentId?: WorkspaceNodeId | null, title?: string) => WorkspaceNodeId + handleRenameNode: (id: WorkspaceNodeId, title: string) => void + handleDeleteNode: (id: WorkspaceNodeId) => void +} + +const WorkspaceTreeContext = createContext(null) + +export function useWorkspaceTree(): WorkspaceTreeContextValue { + const ctx = useContext(WorkspaceTreeContext) + if (!ctx) throw new Error('useWorkspaceTree must be used within WorkspaceTreeProvider') + return ctx +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +let nodeCounter = 0 + +export function WorkspaceTreeProvider({ children }: { children: React.ReactNode }) { + const { recollectionId } = useParams<{ recollectionId: string }>() + const [searchParams, setSearchParams] = useSearchParams() + const navigate = useNavigate() + const { pathname } = useLocation() + const [tree, setTree] = useState([]) + const [activeItemId, setActiveItemId] = useState(null) + + // Load tree on recollection change + useEffect(() => { + if (!recollectionId) return + const t = ensureWorkspaceTree(recollectionId) + setTree(t) + }, [recollectionId]) + + // Sync activeItemId from URL + useEffect(() => { + if (!recollectionId || tree.length === 0) return + const pageFromUrl = searchParams.get('page') + const sceneFromUrl = searchParams.get('scene') + setActiveItemId((prev) => { + if (pageFromUrl && tree.some((n) => n.id === pageFromUrl && n.kind === 'page')) return pageFromUrl + if (sceneFromUrl && tree.some((n) => n.id === sceneFromUrl && n.kind === 'scene')) return sceneFromUrl + if (prev != null && tree.some((n) => n.id === prev)) return prev + // Default to first page or scene + const firstContent = tree.find((n) => n.kind === 'page' || n.kind === 'scene') + return firstContent?.id ?? null + }) + }, [recollectionId, searchParams, tree]) + + // Persist tree on changes + useEffect(() => { + if (!recollectionId || tree.length === 0) return + setWorkspaceTree(recollectionId, tree) + }, [recollectionId, tree]) + + const activeItemKind = useMemo(() => { + if (!activeItemId) return null + return tree.find((n) => n.id === activeItemId)?.kind ?? null + }, [activeItemId, tree]) + + const handleSelectItem = useCallback( + (id: WorkspaceNodeId) => { + if (!recollectionId) return + const node = tree.find((n) => n.id === id) + if (!node) return + setActiveItemId(id) + const base = `/recollections/${recollectionId}` + if (node.kind === 'page') { + navigate(`${base}/logos?page=${encodeURIComponent(id)}`) + } else if (node.kind === 'scene') { + navigate(`${base}/flux?scene=${encodeURIComponent(id)}`) + } + // folder: no navigation, just select + }, + [recollectionId, tree, navigate] + ) + + const handleTreeChange = useCallback((newTree: WorkspaceNodeMeta[]) => { + setTree(newTree) + }, []) + + const handleAddNode = useCallback( + (kind: WorkspaceNodeKind, parentId?: WorkspaceNodeId | null, title?: string) => { + nodeCounter += 1 + const prefix = kind === 'page' ? 'page' : kind === 'scene' ? 'scene' : 'folder' + const id = `${prefix}_${Date.now()}_${nodeCounter}` + const siblings = tree.filter((n) => n.parentId === (parentId ?? null)) + const maxPos = siblings.reduce((max, n) => Math.max(max, n.position), -1) + const defaultTitle = kind === 'page' ? 'Untitled' : kind === 'scene' ? `Scene ${tree.filter((n) => n.kind === 'scene').length + 1}` : 'New Folder' + const node: WorkspaceNodeMeta = { + id, + title: title ?? defaultTitle, + kind, + parentId: parentId ?? null, + position: maxPos + 1, + } + setTree((prev) => [...prev, node]) + if (kind !== 'folder') { + handleSelectItem(id) + } + return id + }, + [tree, handleSelectItem] + ) + + const handleRenameNode = useCallback( + (id: WorkspaceNodeId, title: string) => { + setTree((prev) => prev.map((n) => (n.id === id ? { ...n, title } : n))) + }, + [] + ) + + const handleDeleteNode = useCallback( + (id: WorkspaceNodeId) => { + if (!recollectionId) return + // Collect this node and all descendants + const toDelete = new Set() + const collect = (nodeId: string) => { + toDelete.add(nodeId) + for (const child of tree.filter((n) => n.parentId === nodeId)) { + collect(child.id) + } + } + collect(id) + + // Clean up storage for deleted nodes + for (const nodeId of toDelete) { + const node = tree.find((n) => n.id === nodeId) + if (!node) continue + if (node.kind === 'page') removeLogosPageContent(recollectionId, nodeId) + if (node.kind === 'scene') removeSceneGraph(recollectionId, nodeId) + } + + setTree((prev) => { + const next = prev.filter((n) => !toDelete.has(n.id)) + // If we deleted the active item, select the first remaining content node + if (activeItemId && toDelete.has(activeItemId) && next.length > 0) { + const firstContent = next.find((n) => n.kind === 'page' || n.kind === 'scene') + if (firstContent) handleSelectItem(firstContent.id) + } + return next + }) + }, + [recollectionId, tree, activeItemId, handleSelectItem] + ) + + const value: WorkspaceTreeContextValue = { + tree, + setTree, + activeItemId, + activeItemKind, + handleSelectItem, + handleTreeChange, + handleAddNode, + handleRenameNode, + handleDeleteNode, + } + + return {children} +} + +// --------------------------------------------------------------------------- +// Backward-compat shim: useRecollectionSidebar +// --------------------------------------------------------------------------- + +export type RecollectionSidebarContextValue = { + tree: LogosPageMeta[] + activePageId: string | null + setTree: React.Dispatch> + handleSelectPage: (id: string) => void + handleTreeChange: (tree: WorkspaceNodeMeta[]) => void + handleDeletePage: (pageId: string) => void +} + +export function useRecollectionSidebar(): RecollectionSidebarContextValue { + const ctx = useWorkspaceTree() + return useMemo(() => ({ + tree: ctx.tree.filter((n) => n.kind === 'page').map((n) => ({ + id: n.id, + title: n.title, + parentId: n.parentId, + position: n.position, + })), + activePageId: ctx.activeItemKind === 'page' ? ctx.activeItemId : null, + setTree: ctx.setTree, + handleSelectPage: ctx.handleSelectItem, + handleTreeChange: ctx.handleTreeChange, + handleDeletePage: ctx.handleDeleteNode, + }), [ctx]) +} + +// --------------------------------------------------------------------------- +// Backward-compat shim: useFluxScenes +// --------------------------------------------------------------------------- + +export type FluxSceneContextValue = { + scenes: FluxSceneMeta[] + activeSceneId: FluxSceneId + setScenes: React.Dispatch> + handleSelectScene: (id: FluxSceneId) => void + handleAddScene: (title?: string) => FluxSceneId + handleRenameScene: (id: FluxSceneId, title: string) => void + handleDeleteScene: (id: FluxSceneId) => void + handleReorderScenes: (scenes: FluxSceneMeta[]) => void +} + +export function useFluxScenes(): FluxSceneContextValue { + const ctx = useWorkspaceTree() + return useMemo(() => ({ + scenes: ctx.tree.filter((n) => n.kind === 'scene').map((n) => ({ + id: n.id, + title: n.title, + position: n.position, + })), + activeSceneId: ctx.activeItemKind === 'scene' && ctx.activeItemId ? ctx.activeItemId : DEFAULT_SCENE_ID, + setScenes: ctx.setTree, + handleSelectScene: ctx.handleSelectItem, + handleAddScene: (title?: string) => ctx.handleAddNode('scene', null, title), + handleRenameScene: ctx.handleRenameNode, + handleDeleteScene: ctx.handleDeleteNode, + handleReorderScenes: () => {}, + }), [ctx]) +} + +export function useOptionalFluxScenes(): FluxSceneContextValue | null { + const ctx = useContext(WorkspaceTreeContext) + if (!ctx) return null + return useFluxScenes() +} diff --git a/frontend/src/app/recollections/logos/LogosPage.tsx b/frontend/src/app/recollections/logos/LogosPage.tsx index 1ffc169..6fdaab2 100644 --- a/frontend/src/app/recollections/logos/LogosPage.tsx +++ b/frontend/src/app/recollections/logos/LogosPage.tsx @@ -11,7 +11,7 @@ import { useCreateBlockNote, getDefaultReactSlashMenuItems, SuggestionMenuContro import { BlockNoteView } from '@blocknote/shadcn' import { useTheme } from '@/lib/themeContext' import { useRecollectionActions } from '../layout/RecollectionActionsContext' -import { useRecollectionSidebar } from '../layout/RecollectionSidebarContext' +import { useRecollectionSidebar } from '../layout/WorkspaceTreeContext' import { getLogosContentForPage, setLogosContentForPage, diff --git a/frontend/src/app/recollections/state/recollectionStore.ts b/frontend/src/app/recollections/state/recollectionStore.ts index 5e1bfdc..c480400 100644 --- a/frontend/src/app/recollections/state/recollectionStore.ts +++ b/frontend/src/app/recollections/state/recollectionStore.ts @@ -60,9 +60,26 @@ export type FluxSceneMeta = { export const DEFAULT_SCENE_ID: FluxSceneId = '_default' +// --------------------------------------------------------------------------- +// Unified Workspace Tree (pages + scenes + folders in one tree) +// --------------------------------------------------------------------------- + +export type WorkspaceNodeKind = 'page' | 'scene' | 'folder' +export type WorkspaceNodeId = string + +/** Unified node in the workspace tree. Replaces separate LogosPageMeta and FluxSceneMeta. */ +export type WorkspaceNodeMeta = { + id: WorkspaceNodeId + title: string + kind: WorkspaceNodeKind + parentId: WorkspaceNodeId | null + position: number +} + const GRAPH_KEY_PREFIX = 'zui_graph_' const FLUX_SCENE_TREE_PREFIX = 'zui_flux_scenes_' const FLUX_SCENE_GRAPH_PREFIX = 'zui_flux_scene_' +const WORKSPACE_TREE_PREFIX = 'zui_workspace_tree_' const LOGOS_KEY_PREFIX = 'zui_logos_' const LOGOS_PAGE_TREE_PREFIX = 'zui_logos_pagetree_' const LOGOS_PAGE_CONTENT_PREFIX = 'zui_logos_page_' @@ -81,6 +98,10 @@ function getFluxSceneGraphKey(recollectionId: string, sceneId: FluxSceneId): str return `${FLUX_SCENE_GRAPH_PREFIX}${recollectionId}_${sceneId}` } +function getWorkspaceTreeKey(recollectionId: string): string { + return `${WORKSPACE_TREE_PREFIX}${recollectionId}` +} + function getLogosKey(recollectionId: string): string { return `${LOGOS_KEY_PREFIX}${recollectionId}` } @@ -255,6 +276,92 @@ export function ensureFluxSceneTree(recollectionId: string): FluxSceneMeta[] { return tree } +// --------------------------------------------------------------------------- +// Unified Workspace Tree Storage +// --------------------------------------------------------------------------- + +export function getWorkspaceTree(recollectionId: string): WorkspaceNodeMeta[] { + try { + const raw = localStorage.getItem(getWorkspaceTreeKey(recollectionId)) + if (!raw) return [] + const data = JSON.parse(raw) as unknown + if (!Array.isArray(data)) return [] + return data.filter( + (item): item is WorkspaceNodeMeta => + item != null && + typeof item === 'object' && + typeof (item as WorkspaceNodeMeta).id === 'string' && + typeof (item as WorkspaceNodeMeta).title === 'string' && + typeof (item as WorkspaceNodeMeta).kind === 'string' && + ((item as WorkspaceNodeMeta).parentId === null || typeof (item as WorkspaceNodeMeta).parentId === 'string') && + typeof (item as WorkspaceNodeMeta).position === 'number' + ) + } catch { + return [] + } +} + +export function setWorkspaceTree(recollectionId: string, tree: WorkspaceNodeMeta[]): void { + localStorage.setItem(getWorkspaceTreeKey(recollectionId), JSON.stringify(tree)) +} + +/** + * Initialize workspace tree for a recollection. Migrates existing separate + * page tree + scene tree into a unified workspace tree on first access. + */ +export function ensureWorkspaceTree(recollectionId: string): WorkspaceNodeMeta[] { + const existing = getWorkspaceTree(recollectionId) + if (existing.length > 0) return existing + + const merged: WorkspaceNodeMeta[] = [] + + // Migrate Logos pages + let pages = getLogosPageTree(recollectionId) + if (pages.length === 0) { + // Check for legacy single-document Logos content + const legacy = getLogosContent(recollectionId) + const mainPage: LogosPageMeta = { id: 'main', title: 'Main', parentId: null, position: 0 } + setLogosContentForPage(recollectionId, 'main', legacy ?? []) + setLogosPageTree(recollectionId, [mainPage]) + pages = [mainPage] + } + for (const page of pages) { + merged.push({ + id: page.id, + title: page.title, + kind: 'page', + parentId: page.parentId, + position: page.position, + }) + } + + // Migrate Flux scenes + const scenes = getFluxSceneTree(recollectionId) + if (scenes.length === 0) { + // Migrate legacy single graph to default scene + const legacyGraph = getGraph(recollectionId) + const defaultScene: WorkspaceNodeMeta = { id: DEFAULT_SCENE_ID, title: 'Main', kind: 'scene', parentId: null, position: merged.length } + merged.push(defaultScene) + if (legacyGraph) { + setSceneGraph(recollectionId, DEFAULT_SCENE_ID, legacyGraph) + } + } else { + const maxRootPos = merged.filter((n) => n.parentId === null).reduce((max, n) => Math.max(max, n.position), -1) + for (const scene of scenes) { + merged.push({ + id: scene.id, + title: scene.title, + kind: 'scene', + parentId: null, + position: maxRootPos + 1 + scene.position, + }) + } + } + + setWorkspaceTree(recollectionId, merged) + return merged +} + // --------------------------------------------------------------------------- // Logos Storage // --------------------------------------------------------------------------- @@ -387,6 +494,8 @@ export function removeRecollectionData(recollectionId: string): void { removeLogosPageContent(recollectionId, page.id) } localStorage.removeItem(getLogosPageTreeKey(recollectionId)) + // Workspace tree + localStorage.removeItem(getWorkspaceTreeKey(recollectionId)) // Render cache localStorage.removeItem(getRenderCacheKey(recollectionId)) } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index fbde77f..6247add 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -418,6 +418,7 @@ pre { .react-arborist-tree { background: transparent !important; font-size: 0.875rem; + overflow: hidden !important; } .react-arborist-tree * {