feat: sidebar

This commit is contained in:
2026-03-29 09:13:18 +02:00
parent 7ced2172af
commit 56fa8126f3
8 changed files with 869 additions and 681 deletions

View File

@@ -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 (
<RecollectionActionsProvider>
<RecollectionSidebarProvider>
<FluxSceneProvider>
<div className="flex min-h-0 flex-1 flex-col">
<RecollectionMenubar />
<div className="flex min-h-0 flex-1 flex-row overflow-hidden">
<RecollectionSidebar />
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<Outlet />
</div>
<WorkspaceTreeProvider>
<div className="flex min-h-0 flex-1 flex-col">
<RecollectionMenubar />
<div className="flex min-h-0 flex-1 flex-row overflow-hidden">
<RecollectionSidebar />
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<Outlet />
</div>
</div>
</FluxSceneProvider>
</RecollectionSidebarProvider>
</div>
</WorkspaceTreeProvider>
</RecollectionActionsProvider>
)
}

View File

@@ -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 }>()

View File

@@ -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 (
<div
className="flex h-full w-[var(--sidebar-width)] shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground"
style={{ '--sidebar-width': '16rem' } as React.CSSProperties}
className="group/header flex h-7 shrink-0 items-center gap-1 px-2 select-none cursor-pointer border-b border-sidebar-border/50 bg-sidebar hover:bg-sidebar-accent/40 transition-colors"
onClick={onToggle}
>
<SidebarContent className="flex-1 overflow-y-auto border-0 bg-transparent">
<SidebarGroup>
<div className="flex items-center justify-between gap-2 py-1.5">
<SidebarGroupLabel className="py-0">Logos</SidebarGroupLabel>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 gap-1 px-1.5 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
onClick={handleAddPage}
>
<Plus className="size-3.5" />
New page
</Button>
</div>
<SidebarGroupContent>
<TreeBrowser />
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Katalogos</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<button
type="button"
data-active={isKatalogosView}
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
onClick={() => navigate(`${base}/logos/katalogos`)}
>
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
<span className="truncate">Artifacts</span>
</button>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<FluxScenesSection base={base} isFluxView={isFluxView} />
</SidebarContent>
{open
? <ChevronDown className="size-3 shrink-0 opacity-50" />
: <ChevronRight className="size-3 shrink-0 opacity-50" />}
<span className="flex-1 min-w-0 truncate text-[11px] font-semibold uppercase tracking-wider opacity-70">
{title}
</span>
{actions && (
<div
className="flex shrink-0 items-center opacity-0 group-hover/header:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
{actions}
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// 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<string | null>(null)
const [renameValue, setRenameValue] = useState('')
// Fallback: single "Canvas" link when provider isn't mounted
if (!fluxScenes) {
return (
<SidebarGroup>
<SidebarGroupLabel>Flux</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<button
type="button"
data-active={isFluxView}
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
onClick={() => navigate(`${base}/flux`)}
>
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" />
<span className="truncate">Canvas</span>
</button>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}
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 (
<SidebarGroup>
<div className="flex items-center justify-between gap-2 py-1.5">
<SidebarGroupLabel className="py-0">Flux</SidebarGroupLabel>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 gap-1 px-1.5 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
onClick={() => {
const id = handleAddScene()
navigate(`${base}/flux?scene=${encodeURIComponent(id)}`)
}}
>
<Plus className="size-3.5" />
New scene
</Button>
</div>
<SidebarGroupContent>
<SidebarMenu>
{scenes.map((scene) => {
const isActive = isFluxView && scene.id === activeSceneId
return (
<SidebarMenuItem key={scene.id}>
{renamingId === scene.id ? (
<div className="flex w-full items-center gap-1 px-2 py-1">
<input
autoFocus
className="h-6 flex-1 min-w-0 rounded border border-sidebar-border bg-sidebar px-1.5 text-xs outline-none focus:ring-1 focus:ring-sidebar-ring"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commitRename()
if (e.key === 'Escape') cancelRename()
}}
onBlur={commitRename}
/>
<button type="button" onClick={commitRename} className="shrink-0 text-sidebar-foreground/70 hover:text-sidebar-foreground">
<Check className="size-3.5" />
</button>
<button type="button" onClick={cancelRename} className="shrink-0 text-sidebar-foreground/70 hover:text-sidebar-foreground">
<X className="size-3.5" />
</button>
</div>
) : (
<div className="group flex w-full items-center">
<button
type="button"
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'flex-1 min-w-0')}
onClick={() => {
handleSelectScene(scene.id)
navigate(`${base}/flux?scene=${encodeURIComponent(scene.id)}`)
}}
>
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" />
<span className="truncate">{scene.title}</span>
</button>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity pr-1">
<button
type="button"
onClick={(e) => { e.stopPropagation(); startRename(scene.id, scene.title) }}
className="rounded p-0.5 text-sidebar-foreground/50 hover:text-sidebar-foreground hover:bg-sidebar-accent"
title="Rename scene"
>
<Pencil className="size-3" />
</button>
{scenes.length > 1 && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); handleDeleteScene(scene.id) }}
className="rounded p-0.5 text-sidebar-foreground/50 hover:text-destructive hover:bg-sidebar-accent"
title="Delete scene"
>
<Trash2 className="size-3" />
</button>
)}
</div>
</div>
)}
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<div className="flex flex-col gap-0.5 p-1 overflow-y-auto">
<button
type="button"
data-active={isKatalogosView || undefined}
className={cn(
'flex items-center gap-2 rounded-sm px-2 h-[30px] text-[13px] w-full text-left transition-colors',
'hover:bg-sidebar-accent/60',
isKatalogosView && 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
)}
onClick={() => navigate(`${base}/logos/katalogos`)}
>
<FileText className="size-[15px] shrink-0 opacity-60" />
<span className="truncate">Artifacts</span>
</button>
</div>
)
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
export function RecollectionSidebar() {
const { handleAddNode } = useWorkspaceTree()
// Search & tree ref
const [searchQuery, setSearchQuery] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const treeBrowserRef = useRef<TreeBrowserHandle>(null)
const searchInputRef = useRef<HTMLInputElement>(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.20.8)
const [workspaceFlex, setWorkspaceFlex] = useState(0.75)
const containerRef = useRef<HTMLDivElement>(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 (
<div
className="relative flex h-full shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground overflow-hidden"
style={{ width }}
>
<div ref={containerRef} className="flex min-h-0 flex-1 flex-col overflow-hidden">
{/* Workspace section */}
<div
className="flex flex-col overflow-hidden"
style={workspaceOpen && bothOpen ? { flex: `${workspaceFlex} 1 0%`, minHeight: MIN_SECTION_H } : workspaceOpen ? { flex: '1 1 0%', minHeight: MIN_SECTION_H } : undefined}
>
<SectionHeader
title="Workspace"
open={workspaceOpen}
onToggle={() => setWorkspaceOpen((v) => !v)}
actions={
<>
<button
type="button"
className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors"
title="Search"
onClick={() => { setSearchVisible((v) => { if (!v) setTimeout(() => searchInputRef.current?.focus(), 0); return !v }); if (searchVisible) setSearchQuery('') }}
>
<Search className="size-3 opacity-70" />
</button>
<button
type="button"
className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors"
title="Collapse all"
onClick={() => treeBrowserRef.current?.closeAll()}
>
<ChevronsDownUp className="size-3 opacity-70" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors">
<Plus className="size-3 opacity-70" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onClick={() => handleAddNode('page')}>
<FileText className="mr-2 size-3.5" /> Page
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAddNode('scene')}>
<FluxIcon className="mr-2 size-3.5" /> Scene
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAddNode('folder')}>
<Folder className="mr-2 size-3.5" /> Folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
}
/>
{workspaceOpen && (
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
{searchVisible && (
<div className="flex items-center gap-1 px-1.5 py-1 shrink-0">
<div className="relative flex-1 min-w-0">
<Input
ref={searchInputRef}
type="text"
placeholder="Filter..."
value={searchQuery}
onChange={(e) => 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 && (
<button type="button" className="absolute right-1 top-1/2 -translate-y-1/2 flex size-4 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent" onClick={() => setSearchQuery('')}>
<X className="size-2.5 opacity-60" />
</button>
)}
</div>
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
<TreeBrowser ref={treeBrowserRef} searchQuery={searchQuery} />
</div>
</div>
)}
</div>
{/* Vertical resize divider (only when both sections open) */}
{bothOpen && (
<div
className="h-px shrink-0 cursor-row-resize bg-sidebar-border/50 hover:bg-primary/30 active:bg-primary/40 transition-colors"
onPointerDown={onVResizeStart}
/>
)}
{/* Katalogos section */}
<div
className="flex flex-col overflow-hidden"
style={katalogosOpen && bothOpen ? { flex: `${1 - workspaceFlex} 1 0%`, minHeight: MIN_SECTION_H } : katalogosOpen ? { flex: '1 1 0%', minHeight: MIN_SECTION_H } : undefined}
>
<SectionHeader
title="Katalogos"
open={katalogosOpen}
onToggle={() => setKatalogosOpen((v) => !v)}
/>
{katalogosOpen && (
<div className="min-h-0 flex-1 overflow-y-auto">
<KatalogosContent />
</div>
)}
</div>
</div>
{/* Horizontal resize handle */}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/20 active:bg-primary/30 transition-colors z-10"
onPointerDown={onHResizeStart}
/>
</div>
)
}

View File

@@ -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<string, TreeNode>()
function buildTree(nodes: WorkspaceNodeMeta[]): TreeNode[] {
const nodeMap = new Map<string, TreeNode>()
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<HTMLInputElement>(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<HTMLInputElement>(null)
React.useEffect(() => { ref.current?.focus(); ref.current?.select() }, [])
return (
<input
ref={inputRef}
ref={ref}
type="text"
defaultValue={title}
onBlur={(e) => 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 (
<div
role="presentation"
aria-hidden
className="pointer-events-none absolute z-20 flex items-center gap-1.5"
style={{
top: top - 5,
left: left + ROW_GUTTER_PX,
right: Math.max(indent, ROW_GUTTER_PX),
}}
className="pointer-events-none absolute z-20 flex items-center"
style={{ top: top - 1, left: left + GUTTER + 4, right: GUTTER + 4 }}
>
<span className="flex h-3 w-2.5 shrink-0 items-center justify-center text-primary drop-shadow-sm">
<svg width="9" height="10" viewBox="0 0 9 10" fill="currentColor" aria-hidden>
<path d="M0 5 L9 1.5 L9 8.5 Z" />
</svg>
</span>
<div className="flex h-3 min-w-[2rem] flex-1 items-center">
<div
className={cn(
'h-0 w-full border-t-2 border-dashed border-primary',
'shadow-[0_1px_0_0_hsl(var(--primary)/0.35)]',
'motion-safe:animate-[tree-drop-line-pulse_1.8s_ease-in-out_infinite]'
)}
/>
</div>
<span className="size-1.5 rounded-full bg-primary shrink-0" />
<div className="h-0.5 flex-1 bg-primary rounded-full" />
</div>
)
})
/**
* 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<TreeNode>) {
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 <FluxIcon className={cls} />
if (kind === 'folder') return isOpen ? <FolderOpen className={cls} /> : <Folder className={cls} />
return <FileText className={cls} />
}
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<TreeNode>) {
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<string>()
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 (
<div className="flex min-w-0 flex-1 items-center gap-1">
{/* Floating drag pill: expands on row hover; coarse pointers keep it tappable */}
<div
className={cn(
'flex shrink-0 items-center justify-center overflow-hidden',
'transition-[width,opacity] duration-200 ease-out',
'w-0 opacity-0 group-hover:w-4 group-hover:opacity-100',
'[@media(pointer:coarse)]:w-5 [@media(pointer:coarse)]:opacity-100',
node.state.isDragging && 'w-4 opacity-100 [@media(pointer:coarse)]:w-5',
node.state.isEditing && 'pointer-events-none w-0 opacity-0'
)}
>
<div
ref={dragHandle}
style={{ ...style, paddingLeft: 0 }}
title={node.state.isEditing ? undefined : 'Drag to reorder'}
className={cn(
'logos-tree-drag-handle group/drag',
'relative flex h-6 w-4 shrink-0 touch-none items-center justify-center rounded-md',
'[@media(pointer:coarse)]:h-7 [@media(pointer:coarse)]:w-5',
'border border-sidebar-border/50 bg-sidebar-accent/40',
'text-sidebar-foreground/70',
'transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out',
'hover:border-sidebar-border hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
'hover:shadow-sm',
'active:scale-[0.97] active:cursor-grabbing',
'cursor-grab outline-none',
'dark:border-sidebar-border/55 dark:bg-sidebar-accent/35 dark:text-sidebar-foreground/75',
'dark:hover:bg-sidebar-accent dark:hover:text-sidebar-accent-foreground',
node.state.isDragging &&
'cursor-grabbing border-primary/55 bg-primary/18 text-primary shadow-sm ring-1 ring-primary/30 dark:border-primary/50 dark:bg-primary/22 dark:text-primary'
)}
onClick={(e) => e.stopPropagation()}
>
<GripVertical
className={cn(
'size-3 shrink-0 text-current transition-opacity duration-150 opacity-85 group-hover/drag:opacity-100',
node.state.isDragging && 'opacity-100'
)}
strokeWidth={2}
/>
</div>
</div>
{hasChildren ? (
<div
ref={dragHandle}
style={{ ...style, paddingLeft: 0 }}
className={cn(
'flex min-w-0 flex-1 items-center gap-0.5 select-none overflow-hidden',
node.state.isEditing ? 'cursor-text' : node.state.isDragging ? 'cursor-grabbing' : 'cursor-default',
node.state.isDragging && 'opacity-40'
)}
onClick={handleClick}
>
{/* Chevron (folders only) */}
{isFolder ? (
<button
type="button"
className="shrink-0 p-0.5 hover:bg-sidebar-accent rounded transition-colors"
onClick={handleToggle}
className="flex size-5 shrink-0 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors"
onClick={(e) => { e.stopPropagation(); node.toggle() }}
>
{node.isOpen ? (
<ChevronDown className="size-3.5 text-sidebar-foreground/70" />
) : (
<ChevronRight className="size-3.5 text-sidebar-foreground/70" />
)}
{node.isOpen
? <ChevronDown className="size-3 opacity-50" />
: <ChevronRight className="size-3 opacity-50" />}
</button>
) : (
<div className="w-4 shrink-0" />
<div className="w-5 shrink-0" />
)}
<div
className={cn(
'flex min-h-[28px] min-w-0 flex-1 items-center gap-1 rounded-md px-0.5 -mx-0.5',
node.state.isEditing ? 'cursor-text' : 'cursor-pointer hover:bg-sidebar-accent/40'
{/* Icon */}
<NodeIcon kind={node.data.kind} isOpen={node.isOpen} />
{/* Title */}
<div className="min-w-0 flex-1 overflow-hidden pl-1.5">
{node.state.isEditing ? (
<EditableTitle
title={node.data.title}
onSave={(v) => { if (v.trim()) handleRenameNode(node.data.id, v.trim()); node.submit(v) }}
onCancel={() => node.reset()}
/>
) : (
<span className="block truncate text-[13px] leading-tight cursor-pointer" title={node.data.title}>{node.data.title || 'Untitled'}</span>
)}
onClick={handleSelect}
>
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
<div className="min-w-0 flex-1">
{node.state.isEditing ? (
<EditableTitle
title={node.data.title}
onSave={(newTitle) => {
if (newTitle.trim()) {
handleTreeChange(
tree.map((p) => (p.id === node.data.id ? { ...p, title: newTitle.trim() } : p))
)
}
node.submit(newTitle)
}}
onCancel={() => node.reset()}
/>
) : (
<span className="block truncate select-none">{pageTitle}</span>
)}
</div>
</div>
<div
className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
onPointerDown={(e) => e.stopPropagation()}
>
<button
type="button"
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
onClick={(e) => {
e.stopPropagation()
handleAddChild()
}}
title="Add child page"
{/* Hover actions */}
{!node.state.isEditing && (
<div
className="flex shrink-0 items-center cursor-default opacity-0 group-hover:opacity-100 transition-opacity"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5 text-sidebar-foreground/70" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-3.5 text-sidebar-foreground/70" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={handleRename}>
<Pencil className="mr-2 size-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDelete} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Plus — only on folders (add inside) or any node (add sibling) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors">
<Plus className="size-3 opacity-60" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onClick={() => { const pid = isFolder ? node.data.id : node.data.parentId; handleAddNode('page', pid); if (isFolder && !node.isOpen) node.open() }}>
<FileText className="mr-2 size-3.5" /> Page
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { const pid = isFolder ? node.data.id : node.data.parentId; handleAddNode('scene', pid); if (isFolder && !node.isOpen) node.open() }}>
<FluxIcon className="mr-2 size-3.5" /> Scene
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { const pid = isFolder ? node.data.id : node.data.parentId; handleAddNode('folder', pid); if (isFolder && !node.isOpen) node.open() }}>
<Folder className="mr-2 size-3.5" /> Folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* More */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors">
<MoreHorizontal className="size-3 opacity-60" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onClick={() => node.edit()}>
<Pencil className="mr-2 size-3.5" /> Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleDeleteNode(node.data.id)} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-3.5" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
)
}
/** Drop target + layout shell; `children` must be the Node renderer (drag layer). */
// ---------------------------------------------------------------------------
// Row renderer
// ---------------------------------------------------------------------------
function TreeRow({ node, innerRef, attrs, children }: RowRendererProps<TreeNode>) {
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 (
<div
ref={innerRef}
{...attrs}
style={{
...attrs.style,
paddingLeft: `${indentPx}px`,
}}
style={{ ...attrs.style, paddingLeft: `${indentPx}px` }}
className={cn(
'group relative flex items-center gap-1 py-1 pr-2 text-sm rounded-md transition-[box-shadow,background-color,ring-color] duration-150',
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',
node.state.isDragging && 'cursor-grabbing opacity-45',
// Folder drop target: index === null in library — reads as “open container”, not a line between rows
node.state.willReceiveDrop &&
'cursor-copy bg-sidebar-accent/30 ring-2 ring-inset ring-dashed ring-sidebar-ring/80 shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border))]'
'group relative flex items-center pr-1 h-[30px] text-[13px] rounded-sm mx-1',
'transition-colors duration-100',
'hover:bg-sidebar-accent/60',
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground font-medium',
node.state.isDragging && 'opacity-40',
node.state.willReceiveDrop && node.data.kind === 'folder' &&
'bg-sidebar-accent/40 ring-1 ring-primary/40 cursor-copy'
)}
title={node.state.willReceiveDrop ? 'Release to drop inside this folder' : undefined}
>
{level > 0 && (
{/* VS Code-style indent guides */}
{Array.from({ length: node.level }, (_, i) => (
<div
className="pointer-events-none absolute bottom-0 top-0 border-l-2 border-sidebar-border/45"
style={{ left: `${ROW_GUTTER_PX + (level - 1) * ROW_INDENT_PX + ROW_INDENT_PX / 2}px` }}
key={i}
className="pointer-events-none absolute top-0 bottom-0 w-px bg-sidebar-border/40"
style={{ left: `${GUTTER + i * INDENT + INDENT / 2}px` }}
/>
)}
))}
{children}
</div>
)
}
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<TreeBrowserHandle, TreeBrowserProps>(
function TreeBrowser({ searchQuery = '' }, ref) {
const { tree, handleTreeChange } = useWorkspaceTree()
const treeRef = useRef<TreeApi<TreeNode> | 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<string, boolean> = {}
function collectIds(nodes: TreeNode[]) {
nodes.forEach((node) => {
if (node.isFolder) {
folderIds[node.id] = true
collectIds(node.children || [])
}
})
}
collectIds(treeNodes)
return folderIds
const ids: Record<string, boolean> = {}
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<string, LogosPageMeta>()
newSiblings.forEach((p, i) => {
updates.set(p.id, { ...p, parentId: newParentId, position: i })
})
const updates = new Map<string, WorkspaceNodeMeta>()
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 (
<div className="flex flex-col gap-2 h-full">
{/* Search Toolbar */}
<div className="flex items-center gap-1.5">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-sidebar-foreground/50 pointer-events-none" />
<Input
type="text"
placeholder="Search pages..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-7 pr-7 h-7 text-xs bg-sidebar-accent/50 border-sidebar-border"
/>
{searchQuery && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-1/2 -translate-y-1/2 h-7 w-7"
onClick={handleClearSearch}
>
<X className="size-3" />
</Button>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
title="Expand/Collapse options"
>
<ChevronsUpDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleExpandAll}>
<ChevronsDownUp className="mr-2 size-3.5" />
Expand all
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCollapseAll}>
<ChevronsUpDown className="mr-2 size-3.5" />
Collapse all
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Tree */}
<div ref={treeContainerRef} className="flex-1 min-h-0 overflow-hidden">
<Tree ref={treeRef} {...arboristTreeProps} />
</div>
<div ref={treeContainerRef} className="h-full min-h-0 overflow-hidden">
<Tree ref={treeRef} {...arboristTreeProps} />
</div>
)
}
})

View File

@@ -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<React.SetStateAction<WorkspaceNodeMeta[]>>
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<WorkspaceTreeContextValue | null>(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<WorkspaceNodeMeta[]>([])
const [activeItemId, setActiveItemId] = useState<WorkspaceNodeId | null>(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<string>()
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 <WorkspaceTreeContext.Provider value={value}>{children}</WorkspaceTreeContext.Provider>
}
// ---------------------------------------------------------------------------
// Backward-compat shim: useRecollectionSidebar
// ---------------------------------------------------------------------------
export type RecollectionSidebarContextValue = {
tree: LogosPageMeta[]
activePageId: string | null
setTree: React.Dispatch<React.SetStateAction<WorkspaceNodeMeta[]>>
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<React.SetStateAction<WorkspaceNodeMeta[]>>
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()
}

View File

@@ -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,

View File

@@ -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))
}

View File

@@ -418,6 +418,7 @@ pre {
.react-arborist-tree {
background: transparent !important;
font-size: 0.875rem;
overflow: hidden !important;
}
.react-arborist-tree * {