// Column-stack workspace store (Finder-style miller columns). // // State is a single ordered list `columns: Column[]` rendered left-to-right // in the main workspace. The first column is always a top-level section // (Concepts / Model / Requirements / Assumptions / Risks / Inconsistencies); // subsequent columns are entity details that drill down from the previous // column's selection. // // Invariants: // - Only one top-level section is open at a time. Clicking a different // section in the LeftSidebar replaces the entire stack. // - Pushing a child at parentIndex truncates everything to its right first. // - Resizing a column persists per (kind, id) so the user's chosen widths // stick across pushes. // // localStorage persistence keeps just the open section + per-column widths. // Detail children are deliberately ephemeral — they don't survive a reload. "use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; export type SectionPaneId = | "concepts" | "model" | "requirements" | "assumptions" | "risks" | "inconsistencies"; export const SECTION_TITLES: Record = { concepts: "Concepts", model: "Model", requirements: "Requirements", assumptions: "Assumptions", risks: "Risks", inconsistencies: "Inconsistencies", }; export type Column = | { kind: "section"; id: SectionPaneId } | { kind: "term"; id: string } | { kind: "block"; id: string } | { kind: "association"; id: string } | { kind: "constraint"; id: string } | { kind: "requirement"; id: string } | { kind: "finding"; id: string }; export function columnKey(c: Column): string { return `${c.kind}:${c.id}`; } export const PANE_MIN_WIDTH = 240; export const PANE_MAX_WIDTH = 720; export const PANE_DEFAULT_WIDTH = 340; interface OpenPanesValue { /** Ordered chain of columns rendered in the main workspace. */ columns: Column[]; /** True when the first column is this section (the chain is rooted here). */ isSectionOpen(id: SectionPaneId): boolean; /** Sidebar click: open this section as the chain root, or close everything * if it's already the active root. */ toggleSection(id: SectionPaneId): void; /** Force-open a section (no-op if already root). */ openSection(id: SectionPaneId): void; /** Truncate everything past `parentIndex`, then push `child`. If the same * child already sits at parentIndex+1, this is a no-op (clicking the * active item shouldn't flicker). */ pushFrom(parentIndex: number, child: Column): void; /** Drop columns from `index` onward. */ closeFrom(index: number): void; /** Replace the entire stack (used for cross-section jumps, e.g. a chip * click that wants to land in Concepts → term). */ setStack(columns: Column[]): void; /** Per-column width, keyed by columnKey. */ widthFor(c: Column): number; setWidth(c: Column, width: number): void; } const Ctx = createContext(null); interface PersistShape { rootSection?: SectionPaneId | null; widths?: Record; /** Legacy field from the pre-stack store. Hydrated and discarded. */ open?: string[]; } interface ProviderProps { projectId: string; initial?: SectionPaneId | null; children: React.ReactNode; } export function OpenPanesProvider({ projectId, initial = null, children }: ProviderProps) { const storageKey = `socrata.workspace.${projectId}`; const [columns, setColumns] = useState(initial ? [{ kind: "section", id: initial }] : []); const [widths, setWidths] = useState>({}); const [hydrated, setHydrated] = useState(false); // Hydrate from localStorage on mount. useEffect(() => { try { const raw = window.localStorage.getItem(storageKey); if (raw) { const parsed = JSON.parse(raw) as PersistShape | string[]; let nextRoot: SectionPaneId | null = null; const cleanedWidths: Record = {}; if (Array.isArray(parsed)) { // Very old format: array of section ids. Take the first valid one. for (const id of parsed.map(migrateLegacyId).filter(isSectionPaneId)) { nextRoot = id; break; } } else if (parsed && typeof parsed === "object") { if (parsed.rootSection && isSectionPaneId(parsed.rootSection)) { nextRoot = parsed.rootSection; } else if (Array.isArray(parsed.open)) { // Pre-stack store kept multiple sections open; pick the first valid one. for (const id of parsed.open.map(migrateLegacyId).filter(isSectionPaneId)) { nextRoot = id; break; } } if (parsed.widths && typeof parsed.widths === "object") { for (const [k, v] of Object.entries(parsed.widths)) { if (typeof v === "number" && Number.isFinite(v)) cleanedWidths[k] = clamp(v); } } } if (nextRoot) setColumns([{ kind: "section", id: nextRoot }]); setWidths(cleanedWidths); } } catch { /* ignore */ } setHydrated(true); }, [storageKey]); // Persist root section + widths (NOT child columns — those are ephemeral). useEffect(() => { if (!hydrated) return; try { const root = columns[0]?.kind === "section" ? columns[0].id : null; const payload: PersistShape = { rootSection: root, widths }; window.localStorage.setItem(storageKey, JSON.stringify(payload)); } catch { /* ignore */ } }, [columns, widths, storageKey, hydrated]); const isSectionOpen = useCallback( (id: SectionPaneId) => columns[0]?.kind === "section" && columns[0].id === id, [columns] ); const toggleSection = useCallback((id: SectionPaneId) => { setColumns(cur => { if (cur[0]?.kind === "section" && cur[0].id === id) return []; return [{ kind: "section", id }]; }); }, []); const openSection = useCallback((id: SectionPaneId) => { setColumns(cur => { if (cur[0]?.kind === "section" && cur[0].id === id) return cur; return [{ kind: "section", id }]; }); }, []); const pushFrom = useCallback((parentIndex: number, child: Column) => { setColumns(cur => { const head = cur.slice(0, parentIndex + 1); const existing = cur[parentIndex + 1]; // Same child already there → keep it (no flicker, preserves any state). if (existing && existing.kind === child.kind && existing.id === child.id) { return cur.slice(0, parentIndex + 2); } return [...head, child]; }); }, []); const closeFrom = useCallback((index: number) => { setColumns(cur => cur.slice(0, Math.max(0, index))); }, []); const setStack = useCallback((next: Column[]) => { setColumns(next); }, []); const widthFor = useCallback( (c: Column) => widths[columnKey(c)] ?? PANE_DEFAULT_WIDTH, [widths] ); const setWidth = useCallback((c: Column, width: number) => { setWidths(prev => ({ ...prev, [columnKey(c)]: clamp(width) })); }, []); const value = useMemo( () => ({ columns, isSectionOpen, toggleSection, openSection, pushFrom, closeFrom, setStack, widthFor, setWidth, }), [columns, isSectionOpen, toggleSection, openSection, pushFrom, closeFrom, setStack, widthFor, setWidth] ); return {children}; } export function useOpenPanes(): OpenPanesValue { const ctx = useContext(Ctx); if (!ctx) throw new Error("useOpenPanes must be inside "); return ctx; } function isSectionPaneId(x: unknown): x is SectionPaneId { return typeof x === "string" && x in SECTION_TITLES; } function migrateLegacyId(id: string): string { if (id === "taxonomy" || id === "glossary") return "concepts"; return id; } function clamp(w: number): number { return Math.max(PANE_MIN_WIDTH, Math.min(PANE_MAX_WIDTH, Math.round(w))); }