import { create } from "zustand" import { demoSnapshot } from "../demo/demoProject" import { buildReferenceIndex, renderTemplate } from "../../core" import type { BlockNode, DataSetDocument, FragmentDocument, InlineNode, ProjectDiagnostic, ProjectResourceDirectory, ProjectResourceFile, ProjectResourceNode, ProjectSnapshot, RenderResult, TemplateDocument } from "../../core/types" import { createBlockId } from "../../utils/id" export type RightPanelTab = "properties" | "data" | "preview" | "debug" | "references" export type SaveStatus = "saved" | "dirty" | "saving" | "error" export type DataEditorStatus = "valid" | "invalid" export type ProjectOpenStatus = "demo" | "opening" | "open" | "error" export type OpenDocument = TemplateDocument | FragmentDocument export type ProjectPersistence = { isAvailable: () => boolean openProjectFromDisk: () => Promise saveProjectChanges: (snapshot: ProjectSnapshot, dirtyDocumentIds: string[]) => Promise } export type WorkbenchState = { snapshot: ProjectSnapshot selectedDocumentId: string selectedResourcePath: string currentDatasetId: string documentRevision: number dataEditorText: string dataEditorStatus: DataEditorStatus dataEditorError: string | null selectedBlockId: string | null rightPanelTab: RightPanelTab saveStatus: SaveStatus projectOpenStatus: ProjectOpenStatus dirtyDocumentIds: string[] lastSaveError: string | null isProjectPersistenceAvailable: boolean openProjectFromDisk: () => Promise saveProjectChanges: () => Promise createFragment: () => void selectResource: (resource: ProjectResourceFile) => void openDocumentById: (documentId: string) => void selectDocument: (documentId: string, resourcePath?: string) => void setCurrentDataset: (datasetId: string) => void setDataEditorText: (text: string) => void updateCurrentDatasetData: (data: Record) => void setSelectedBlockId: (blockId: string | null) => void setRightPanelTab: (tab: RightPanelTab) => void updateOpenDocumentChildren: (children: BlockNode[]) => void expandSelectedFragmentReference: () => void resetDemo: () => void } export const defaultDocumentId = demoSnapshot.project.entryTemplateId ?? Object.keys(demoSnapshot.templates)[0]! export const defaultDatasetId = Object.keys(demoSnapshot.datasets)[0]! export const defaultResourcePath = findResourcePathByDocumentId( demoSnapshot.resourceTree.children, defaultDocumentId ) ?? "templates/main.json" const fallbackDataset: DataSetDocument = { kind: "dataset", id: "dataset_runtime_empty", name: "empty", data: {} } let projectPersistenceOverride: ProjectPersistence | null = null export function setProjectPersistenceForTests(persistence: ProjectPersistence | null): void { projectPersistenceOverride = persistence useWorkbenchStore.setState({ isProjectPersistenceAvailable: getProjectPersistence().isAvailable() }) } export const useWorkbenchStore = create((set) => ({ snapshot: demoSnapshot, selectedDocumentId: defaultDocumentId, selectedResourcePath: defaultResourcePath, currentDatasetId: defaultDatasetId, documentRevision: 0, dataEditorText: formatJson(demoSnapshot.datasets[defaultDatasetId]?.data ?? {}), dataEditorStatus: "valid", dataEditorError: null, selectedBlockId: null, rightPanelTab: "properties", saveStatus: "saved", projectOpenStatus: "demo", dirtyDocumentIds: [], lastSaveError: null, isProjectPersistenceAvailable: getProjectPersistence().isAvailable(), async openProjectFromDisk() { const persistence = getProjectPersistence() if (!persistence.isAvailable()) { set({ projectOpenStatus: "error", saveStatus: "error", lastSaveError: "Desktop file access is only available in the Tauri app." }) return } set({ projectOpenStatus: "opening", lastSaveError: null }) try { const snapshot = await persistence.openProjectFromDisk() if (snapshot === null) { set((state) => ({ projectOpenStatus: state.snapshot === demoSnapshot ? "demo" : "open" })) return } set((state) => ({ ...state, ...stateForSnapshot(snapshot, state), projectOpenStatus: "open", dirtyDocumentIds: [], lastSaveError: null, saveStatus: "saved", documentRevision: state.documentRevision + 1 })) } catch (error) { set({ projectOpenStatus: "error", saveStatus: "error", lastSaveError: formatError(error) }) } }, async saveProjectChanges() { const persistence = getProjectPersistence() if (!persistence.isAvailable()) { set({ saveStatus: "error", lastSaveError: "Desktop file access is only available in the Tauri app." }) return } set((state) => { if (state.dirtyDocumentIds.length === 0) { return state } return { saveStatus: "saving", lastSaveError: null } }) const state = useWorkbenchStore.getState() if (state.dirtyDocumentIds.length === 0) { return } try { const snapshot = await persistence.saveProjectChanges(state.snapshot, state.dirtyDocumentIds) set((current) => ({ ...current, ...stateForSnapshot(snapshot, current), projectOpenStatus: "open", dirtyDocumentIds: [], lastSaveError: null, saveStatus: "saved", documentRevision: current.documentRevision + 1 })) } catch (error) { set({ saveStatus: "error", lastSaveError: formatError(error) }) } }, createFragment() { set((state) => { const next = nextFragmentIdentity(state.snapshot) const now = new Date().toISOString() const fragment: FragmentDocument = { kind: "fragment", id: next.id, name: next.name, filePath: next.path, createdAt: now, updatedAt: now, children: [ { id: createBlockId("paragraph"), type: "paragraph", inlines: [] } ] } const fragments = { ...state.snapshot.fragments, [fragment.id]: fragment } const referenceIndex = buildReferenceIndex([ ...Object.values(state.snapshot.templates), ...Object.values(fragments) ]) const diagnostics = rebuildReferenceDiagnostics( state.snapshot.diagnostics, referenceIndex.missingFragments ) const resourceFile: ProjectResourceFile = { type: "file", name: next.fileName, path: next.path, relativePath: next.relativePath, resourceKind: "fragment", documentId: fragment.id } return { snapshot: { ...state.snapshot, fragments, resourceTree: { ...state.snapshot.resourceTree, children: addResourceToDirectory( state.snapshot.resourceTree.children, "fragments", resourceFile, state.snapshot.project.paths.fragmentsDir ) }, referenceIndex, diagnostics }, selectedDocumentId: fragment.id, selectedResourcePath: next.relativePath, selectedBlockId: null, rightPanelTab: "properties", documentRevision: state.documentRevision + 1, dirtyDocumentIds: addDirtyDocumentId(state.dirtyDocumentIds, fragment.id), lastSaveError: null, saveStatus: "dirty" } }) }, selectResource(resource) { set((state) => { const nextState: Partial = { selectedResourcePath: resource.relativePath } if ( resource.documentId !== undefined && (resource.resourceKind === "template" || resource.resourceKind === "fragment") ) { nextState.selectedDocumentId = resource.documentId } if (resource.documentId !== undefined && resource.resourceKind === "dataset") { nextState.currentDatasetId = resource.documentId nextState.dataEditorText = datasetDataTextFor(state.snapshot, resource.documentId) nextState.dataEditorStatus = "valid" nextState.dataEditorError = null nextState.rightPanelTab = "data" } return { ...state, ...nextState } }) }, openDocumentById(documentId) { set((state) => { const document = state.snapshot.templates[documentId] ?? state.snapshot.fragments[documentId] if (document === undefined) { return state } return { selectedDocumentId: documentId, selectedResourcePath: findResourcePathByDocumentId(state.snapshot.resourceTree.children, documentId) ?? state.selectedResourcePath, selectedBlockId: null, rightPanelTab: document.kind === "fragment" ? "properties" : state.rightPanelTab } }) }, selectDocument(documentId, resourcePath) { set((state) => ({ selectedDocumentId: documentId, selectedResourcePath: resourcePath ?? state.selectedResourcePath })) }, setCurrentDataset(datasetId) { set((state) => ({ currentDatasetId: datasetId, dataEditorText: datasetDataTextFor(state.snapshot, datasetId), dataEditorStatus: "valid", dataEditorError: null })) }, setDataEditorText(text) { const parsed = parseDatasetJson(text) if (!parsed.ok) { set({ dataEditorText: text, dataEditorStatus: "invalid", dataEditorError: parsed.error }) return } set((state) => applyDatasetDataUpdate(state, parsed.data, text)) }, updateCurrentDatasetData(data) { set((state) => applyDatasetDataUpdate(state, data, formatJson(data))) }, setSelectedBlockId(blockId) { set({ selectedBlockId: blockId }) }, setRightPanelTab(tab) { set({ rightPanelTab: tab }) }, updateOpenDocumentChildren(children) { set((state) => { const template = state.snapshot.templates[state.selectedDocumentId] const fragment = state.snapshot.fragments[state.selectedDocumentId] if (template === undefined && fragment === undefined) { return state } const templates = { ...state.snapshot.templates } const fragments = { ...state.snapshot.fragments } if (template !== undefined) { templates[template.id] = { ...template, children } } else if (fragment !== undefined) { fragments[fragment.id] = { ...fragment, children } } const referenceIndex = buildReferenceIndex([ ...Object.values(templates), ...Object.values(fragments) ]) const diagnostics = rebuildReferenceDiagnostics( state.snapshot.diagnostics, referenceIndex.missingFragments ) return { snapshot: { ...state.snapshot, templates, fragments, referenceIndex, diagnostics }, selectedBlockId: blockListContainsId(children, state.selectedBlockId) ? state.selectedBlockId : null, dirtyDocumentIds: addDirtyDocumentId(state.dirtyDocumentIds, state.selectedDocumentId), lastSaveError: null, saveStatus: "dirty" } }) }, expandSelectedFragmentReference() { set((state) => { if (state.selectedBlockId === null) { return state } const document = getCurrentDocument(state) const selectedBlock = findBlockById(document.children, state.selectedBlockId) if (selectedBlock?.type !== "fragmentRef") { return state } const fragment = state.snapshot.fragments[selectedBlock.fragmentId] if (fragment === undefined) { return state } const replacement = cloneBlocks(fragment.children) const replaced = replaceBlockById(document.children, selectedBlock.id, replacement) if (!replaced.replaced) { return state } const templates = { ...state.snapshot.templates } const fragments = { ...state.snapshot.fragments } if (document.kind === "template") { templates[document.id] = { ...document, children: replaced.blocks } } else { fragments[document.id] = { ...document, children: replaced.blocks } } const referenceIndex = buildReferenceIndex([ ...Object.values(templates), ...Object.values(fragments) ]) const diagnostics = rebuildReferenceDiagnostics( state.snapshot.diagnostics, referenceIndex.missingFragments ) return { snapshot: { ...state.snapshot, templates, fragments, referenceIndex, diagnostics }, selectedBlockId: replacement[0]?.id ?? null, documentRevision: state.documentRevision + 1, dirtyDocumentIds: addDirtyDocumentId(state.dirtyDocumentIds, document.id), lastSaveError: null, saveStatus: "dirty" } }) }, resetDemo() { set({ snapshot: demoSnapshot, selectedDocumentId: defaultDocumentId, selectedResourcePath: defaultResourcePath, currentDatasetId: defaultDatasetId, documentRevision: 0, dataEditorText: formatJson(demoSnapshot.datasets[defaultDatasetId]?.data ?? {}), dataEditorStatus: "valid", dataEditorError: null, selectedBlockId: null, rightPanelTab: "properties", saveStatus: "saved", projectOpenStatus: "demo", dirtyDocumentIds: [], lastSaveError: null, isProjectPersistenceAvailable: getProjectPersistence().isAvailable() }) } })) export function getCurrentDocument(state: Pick): OpenDocument { const template = state.snapshot.templates[state.selectedDocumentId] if (template !== undefined) { return template } const fragment = state.snapshot.fragments[state.selectedDocumentId] if (fragment !== undefined) { return fragment } return state.snapshot.templates[defaultDocumentId]! } export function getCurrentDataset(state: Pick): DataSetDocument { return state.snapshot.datasets[state.currentDatasetId] ?? state.snapshot.datasets[defaultDatasetId] ?? fallbackDataset } export function getCurrentRenderResult( state: Pick, options: { targetBlockId?: string | null } = {} ): RenderResult { const document = getCurrentDocument(state) const dataset = getCurrentDataset(state) const renderOptions = options.targetBlockId == null ? undefined : { targetBlockId: options.targetBlockId } if (document.kind === "fragment") { return renderTemplate({ template: { kind: "template", id: `preview_${document.id}`, name: document.name, children: document.children }, fragments: state.snapshot.fragments, dataset, ...(renderOptions === undefined ? {} : { options: renderOptions }) }) } return renderTemplate({ template: document, fragments: state.snapshot.fragments, dataset, ...(renderOptions === undefined ? {} : { options: renderOptions }) }) } export function getCurrentBlocks(state: Pick): BlockNode[] { return getCurrentDocument(state).children } export function getSelectedBlock( state: Pick ): BlockNode | undefined { if (state.selectedBlockId === null) { return undefined } return findBlockById(getCurrentDocument(state).children, state.selectedBlockId) } function findResourcePathByDocumentId( nodes: ProjectResourceNode[], documentId: string ): string | undefined { for (const node of nodes) { if (node.type === "file" && node.documentId === documentId) { return node.relativePath } if (node.type === "directory") { const found = findResourcePathByDocumentId(node.children, documentId) if (found !== undefined) { return found } } } return undefined } function getProjectPersistence(): ProjectPersistence { if (projectPersistenceOverride !== null) { return projectPersistenceOverride } return { isAvailable: isTauriRuntime, async openProjectFromDisk() { const { createTauriProjectPersistence } = await import("../desktop/projectPersistence") return createTauriProjectPersistence().openProjectFromDisk() }, async saveProjectChanges(snapshot, dirtyDocumentIds) { const { createTauriProjectPersistence } = await import("../desktop/projectPersistence") return createTauriProjectPersistence().saveProjectChanges(snapshot, dirtyDocumentIds) } } } function isTauriRuntime(): boolean { if (typeof window === "undefined") { return false } const runtime = window as Window & { __TAURI__?: unknown __TAURI_INTERNALS__?: unknown } return runtime.__TAURI__ !== undefined || runtime.__TAURI_INTERNALS__ !== undefined } function stateForSnapshot( snapshot: ProjectSnapshot, previous: Pick ): Pick< WorkbenchState, | "snapshot" | "selectedDocumentId" | "selectedResourcePath" | "currentDatasetId" | "dataEditorText" | "dataEditorStatus" | "dataEditorError" | "selectedBlockId" | "rightPanelTab" > { const selectedDocumentId = documentExists(snapshot, previous.selectedDocumentId) ? previous.selectedDocumentId : defaultDocumentIdForSnapshot(snapshot) const currentDatasetId = snapshot.datasets[previous.currentDatasetId] !== undefined ? previous.currentDatasetId : defaultDatasetIdForSnapshot(snapshot) return { snapshot, selectedDocumentId, selectedResourcePath: findResourcePathByDocumentId(snapshot.resourceTree.children, selectedDocumentId) ?? defaultResourcePath, currentDatasetId, dataEditorText: datasetDataTextFor(snapshot, currentDatasetId), dataEditorStatus: "valid", dataEditorError: null, selectedBlockId: null, rightPanelTab: "properties" } } function documentExists(snapshot: ProjectSnapshot, documentId: string): boolean { return snapshot.templates[documentId] !== undefined || snapshot.fragments[documentId] !== undefined } function defaultDocumentIdForSnapshot(snapshot: ProjectSnapshot): string { if ( snapshot.project.entryTemplateId !== undefined && snapshot.templates[snapshot.project.entryTemplateId] !== undefined ) { return snapshot.project.entryTemplateId } return Object.keys(snapshot.templates)[0] ?? Object.keys(snapshot.fragments)[0] ?? defaultDocumentId } function defaultDatasetIdForSnapshot(snapshot: ProjectSnapshot): string { return Object.keys(snapshot.datasets)[0] ?? fallbackDataset.id } function addDirtyDocumentId(dirtyDocumentIds: string[], documentId: string): string[] { return dirtyDocumentIds.includes(documentId) ? dirtyDocumentIds : [...dirtyDocumentIds, documentId] } function formatError(error: unknown): string { return error instanceof Error ? error.message : String(error) } function nextFragmentIdentity(snapshot: ProjectSnapshot): { id: string name: string fileName: string relativePath: string path: string } { let index = Object.keys(snapshot.fragments).length + 1 let id = `fragment_${index}` while (snapshot.fragments[id] !== undefined) { index += 1 id = `fragment_${index}` } const fileName = `fragment-${index}.json` const relativePath = `fragments/${fileName}` const fragmentsDir = snapshot.project.paths.fragmentsDir.replace(/\\/g, "/") return { id, name: `Fragment ${index}`, fileName, relativePath, path: `${fragmentsDir}/${fileName}` } } function addResourceToDirectory( nodes: ProjectResourceNode[], directoryRelativePath: string, file: ProjectResourceFile, directoryPath: string ): ProjectResourceNode[] { const result = addResourceToDirectoryRecursive(nodes, directoryRelativePath, file) if (result.inserted) { return result.nodes } const directory: ProjectResourceDirectory = { type: "directory", name: directoryRelativePath, path: directoryPath, relativePath: directoryRelativePath, children: [file] } return [...result.nodes, directory] } function addResourceToDirectoryRecursive( nodes: ProjectResourceNode[], directoryRelativePath: string, file: ProjectResourceFile ): { nodes: ProjectResourceNode[]; inserted: boolean } { let inserted = false const nextNodes = nodes.map((node): ProjectResourceNode => { if (node.type === "directory" && node.relativePath === directoryRelativePath) { inserted = true return { ...node, children: [...node.children.filter((child) => child.relativePath !== file.relativePath), file] } } if (node.type === "directory") { const result = addResourceToDirectoryRecursive(node.children, directoryRelativePath, file) inserted ||= result.inserted return { ...node, children: result.nodes } } return node }) return { nodes: nextNodes, inserted } } function rebuildReferenceDiagnostics( existing: ProjectDiagnostic[], missingFragments: ProjectSnapshot["referenceIndex"]["missingFragments"] ): ProjectDiagnostic[] { const nonReferenceDiagnostics = existing.filter((diagnostic) => diagnostic.code !== "MISSING_FRAGMENT") const missingDiagnostics = missingFragments.map((missing) => { const diagnostic: ProjectDiagnostic = { code: "MISSING_FRAGMENT", severity: "error", message: `Missing fragment reference: ${missing.fragmentId}`, documentId: missing.fromId, fragmentId: missing.fragmentId } if (missing.fromPath !== undefined) { diagnostic.filePath = missing.fromPath } return diagnostic }) return [...nonReferenceDiagnostics, ...missingDiagnostics] } function applyDatasetDataUpdate( state: WorkbenchState, data: Record, dataEditorText: string ): Partial { const dataset = getCurrentDataset(state) return { snapshot: { ...state.snapshot, datasets: { ...state.snapshot.datasets, [dataset.id]: { ...dataset, data } } }, dataEditorText, dataEditorStatus: "valid", dataEditorError: null, dirtyDocumentIds: addDirtyDocumentId(state.dirtyDocumentIds, dataset.id), lastSaveError: null, saveStatus: "dirty" } } function datasetDataTextFor(snapshot: ProjectSnapshot, datasetId: string): string { const dataset = snapshot.datasets[datasetId] ?? snapshot.datasets[defaultDatasetId] return formatJson(dataset?.data ?? {}) } function parseDatasetJson(text: string): { ok: true; data: Record } | { ok: false; error: string } { try { const parsed = JSON.parse(text) as unknown if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { return { ok: false, error: "Dataset JSON must be an object." } } return { ok: true, data: parsed as Record } } catch (error) { return { ok: false, error: error instanceof Error ? error.message : "Invalid JSON." } } } function formatJson(value: unknown): string { return `${JSON.stringify(value, null, 2)}\n` } function blockListContainsId(blocks: BlockNode[], blockId: string | null): boolean { if (blockId === null) { return false } return findBlockById(blocks, blockId) !== undefined } function findBlockById(blocks: BlockNode[], blockId: string): BlockNode | undefined { for (const block of blocks) { if (block.id === blockId) { return block } const children = getNestedBlocks(block) if (children.length > 0) { const found = findBlockById(children, blockId) if (found !== undefined) { return found } } } return undefined } function getNestedBlocks(block: BlockNode): BlockNode[] { switch (block.type) { case "container": return block.children case "condition": return [...block.children, ...(block.elseChildren ?? [])] case "loop": return [...block.children, ...(block.emptyChildren ?? [])] case "slot": return block.fallbackChildren ?? [] default: return [] } } function cloneBlocks(blocks: BlockNode[]): BlockNode[] { return blocks.map(cloneBlock) } function cloneBlock(block: BlockNode): BlockNode { switch (block.type) { case "paragraph": return { ...block, id: createBlockId("paragraph"), inlines: cloneInlines(block.inlines) } case "container": return { ...block, id: createBlockId("container"), children: cloneBlocks(block.children) } case "condition": { const cloned: BlockNode = { ...block, id: createBlockId("condition"), children: cloneBlocks(block.children) } if (block.elseChildren !== undefined) { cloned.elseChildren = cloneBlocks(block.elseChildren) } return cloned } case "loop": { const cloned: BlockNode = { ...block, id: createBlockId("loop"), children: cloneBlocks(block.children) } if (block.emptyChildren !== undefined) { cloned.emptyChildren = cloneBlocks(block.emptyChildren) } return cloned } case "fragmentRef": return { ...block, id: createBlockId("fragment") } case "slot": { const cloned: BlockNode = { ...block, id: createBlockId("slot") } if (block.fallbackChildren !== undefined) { cloned.fallbackChildren = cloneBlocks(block.fallbackChildren) } return cloned } case "comment": return { ...block, id: createBlockId("comment") } case "output": return { ...block, id: createBlockId("output"), config: { ...block.config } } } } function cloneInlines(inlines: InlineNode[]): InlineNode[] { return inlines.map((inline) => ({ ...inline })) } function replaceBlockById( blocks: BlockNode[], blockId: string, replacement: BlockNode[] ): { blocks: BlockNode[]; replaced: boolean } { let replaced = false const nextBlocks: BlockNode[] = [] for (const block of blocks) { if (block.id === blockId) { nextBlocks.push(...replacement) replaced = true continue } const nested = replaceNestedBlockById(block, blockId, replacement) nextBlocks.push(nested.block) replaced ||= nested.replaced } return { blocks: nextBlocks, replaced } } function replaceNestedBlockById( block: BlockNode, blockId: string, replacement: BlockNode[] ): { block: BlockNode; replaced: boolean } { switch (block.type) { case "container": { const result = replaceBlockById(block.children, blockId, replacement) return result.replaced ? { block: { ...block, children: result.blocks }, replaced: true } : { block, replaced: false } } case "condition": { const children = replaceBlockById(block.children, blockId, replacement) const elseChildren = block.elseChildren === undefined ? { blocks: undefined, replaced: false } : replaceBlockById(block.elseChildren, blockId, replacement) if (!children.replaced && !elseChildren.replaced) { return { block, replaced: false } } const next: BlockNode = { ...block, children: children.blocks } if (elseChildren.blocks !== undefined) { next.elseChildren = elseChildren.blocks } return { block: next, replaced: true } } case "loop": { const children = replaceBlockById(block.children, blockId, replacement) const emptyChildren = block.emptyChildren === undefined ? { blocks: undefined, replaced: false } : replaceBlockById(block.emptyChildren, blockId, replacement) if (!children.replaced && !emptyChildren.replaced) { return { block, replaced: false } } const next: BlockNode = { ...block, children: children.blocks } if (emptyChildren.blocks !== undefined) { next.emptyChildren = emptyChildren.blocks } return { block: next, replaced: true } } case "slot": { if (block.fallbackChildren === undefined) { return { block, replaced: false } } const fallbackChildren = replaceBlockById(block.fallbackChildren, blockId, replacement) return fallbackChildren.replaced ? { block: { ...block, fallbackChildren: fallbackChildren.blocks }, replaced: true } : { block, replaced: false } } default: return { block, replaced: false } } }