Files
resolutionflow/frontend/src/store/treeEditorStore.ts
chihlasm f8867d83f8 fix: use actual root node ID in orphan validation check
AI-generated trees use descriptive IDs (e.g., "verify-account-exists")
instead of "root", causing the root node to be falsely flagged as orphaned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:40:36 -05:00

1102 lines
34 KiB
TypeScript

import { create } from 'zustand'
import { temporal } from 'zundo'
import { shallow } from 'zustand/shallow'
import { immer } from 'zustand/middleware/immer'
import type { Tree, TreeStructure, TreeCreate, TreeUpdate, NodeType, TreeMarkdownValidationError, TreeMarkdownValidation } from '@/types'
import { treeStructureToMarkdownPreview } from '@/lib/treeMarkdownSync'
import { safeGetItem, safeSetItem, safeRemoveItem } from '@/lib/utils'
// Throttle helper: captures first call immediately, then throttles subsequent calls
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function throttle<T extends (...args: any[]) => void>(fn: T, ms: number): T {
let lastCall = 0
let timeout: ReturnType<typeof setTimeout> | null = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return ((...args: any[]) => {
const now = Date.now()
if (now - lastCall >= ms) {
lastCall = now
fn(...args)
} else {
// Schedule a trailing call to capture the final state
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
lastCall = Date.now()
fn(...args)
}, ms - (now - lastCall))
}
}) as T
}
// Validation error interface
export interface ValidationError {
nodeId?: string
field?: string
message: string
severity: 'error' | 'warning'
}
// Draft storage key
const DRAFT_STORAGE_KEY = 'tree-editor-draft'
// Check if a tree is effectively empty (just initialized, no real content)
const isEmptyTree = (tree: TreeStructure | null): boolean => {
if (!tree) return true
if (tree.question && tree.question.trim()) return false
if (tree.children && tree.children.length > 0) return false
return true
}
// Starter template for new trees in Code Mode — all @refs are valid within the template
const CODE_MODE_STARTER_TEMPLATE = `---
name: My New Tree
description: A troubleshooting decision tree
---
---
id: root
type: decision
---
# What type of issue is the user experiencing?
> Select the category that best matches the reported problem
- [A] Option A \u2192 @option_a_action
- [B] Option B \u2192 @option_b_action
---
id: option_a_action
type: action
parent: root
---
## Investigate Option A
Describe the investigation steps here.
\`\`\`commands
example-command --flag
\`\`\`
**Expected:** Describe expected results here
\u2192 @resolution
---
id: option_b_action
type: action
parent: root
---
## Investigate Option B
Describe the investigation steps here.
\u2192 @resolution
---
id: resolution
type: solution
parent: root
---
## Resolution
1. Document findings
2. Apply the fix
3. Verify the issue is resolved
`
// Helper to generate unique IDs
const generateId = () => crypto.randomUUID()
// Helper to find a node in the tree structure (exported for drag validation)
export const findNodeInTree = (
nodeId: string,
structure: TreeStructure | null
): TreeStructure | null => {
if (!structure) return null
if (structure.id === nodeId) return structure
if (structure.children) {
for (const child of structure.children) {
const found = findNodeInTree(nodeId, child)
if (found) return found
}
}
return null
}
/** Collect all nodes in the tree as a flat list with depth info. */
export function collectAllNodesFlat(
root: TreeStructure | null
): Array<{ id: string; label: string; type: string; depth: number }> {
if (!root) return []
const result: Array<{ id: string; label: string; type: string; depth: number }> = []
function walk(node: TreeStructure, depth: number) {
const label = node.type === 'decision'
? (node.question || 'Untitled Decision')
: (node.title || `Untitled ${node.type}`)
result.push({ id: node.id, label, type: node.type, depth })
node.children?.forEach(child => walk(child, depth + 1))
}
walk(root, 0)
return result
}
// Helper to find parent of a node
const findParentNode = (
nodeId: string,
structure: TreeStructure | null,
parent: TreeStructure | null = null
): TreeStructure | null => {
if (!structure) return null
if (structure.id === nodeId) return parent
if (structure.children) {
for (const child of structure.children) {
const found = findParentNode(nodeId, child, structure)
if (found) return found
}
}
return null
}
// Helper to get all node IDs
const getAllNodeIds = (structure: TreeStructure | null): string[] => {
if (!structure) return []
const ids = [structure.id]
if (structure.children) {
for (const child of structure.children) {
ids.push(...getAllNodeIds(child))
}
}
return ids
}
// Helper to deep clone a node
const deepCloneNode = (node: TreeStructure): TreeStructure => {
const clone: TreeStructure = { ...node, id: generateId() }
// Update title/question to indicate it's a copy
if (clone.question) {
clone.question = `${clone.question} (Copy)`
} else if (clone.title) {
clone.title = `${clone.title} (Copy)`
}
// Clone options with new IDs
if (clone.options) {
clone.options = clone.options.map(opt => ({
...opt,
id: generateId(),
next_node_id: '' // Clear references - user must reassign
}))
}
// Clear next_node_id - user must reassign
if (clone.next_node_id) {
clone.next_node_id = ''
}
// Clone children recursively
if (clone.children) {
clone.children = clone.children.map(child => deepCloneNode(child))
}
return clone
}
interface TreeEditorState {
// Tree data
treeId: string | null // null for new tree
name: string
description: string
category: string
categoryId: string | null
tags: string[]
isPublic: boolean
treeStructure: TreeStructure | null
originalTree: Tree | null // For comparison in edit mode
// UI state
selectedNodeId: string | null
isDirty: boolean
isLoading: boolean
isSaving: boolean
validationErrors: ValidationError[]
// Code Mode state
editorMode: 'form' | 'code'
markdownSource: string | null
markdownValidationErrors: TreeMarkdownValidationError[]
isMarkdownValid: boolean
isValidating: boolean
lastValidTreeFromMarkdown: TreeStructure | null
// Auto-save state
lastSavedAt: Date | null
draftSavedAt: Date | null
hasDraft: boolean
// Actions - Initialization
initNewTree: () => void
loadTree: (tree: Tree) => void
loadDraft: () => boolean
discardDraft: () => void
reset: () => void
// Actions - Metadata
setName: (name: string) => void
setDescription: (description: string) => void
setCategory: (category: string) => void
setCategoryId: (categoryId: string | null) => void
setTags: (tags: string[]) => void
addTag: (tag: string) => void
removeTag: (tag: string) => void
setIsPublic: (isPublic: boolean) => void
// Actions - Node CRUD
addNode: (parentId: string | null, type: NodeType, insertIndex?: number) => string
updateNode: (nodeId: string, updates: Partial<TreeStructure>) => void
deleteNode: (nodeId: string) => void
duplicateNode: (nodeId: string) => string | null
// Actions - Node ordering
reorderNodes: (parentId: string, fromIndex: number, toIndex: number) => void
moveNode: (nodeId: string, targetParentId: string, targetIndex: number) => void
reorderOptions: (nodeId: string, fromIndex: number, toIndex: number) => void
// Actions - Selection
selectNode: (nodeId: string | null) => void
// Actions - Validation
validate: () => ValidationError[]
clearValidation: () => void
// Actions - Save/Draft
autoSaveDraft: () => void
markSaved: () => void
getTreeForSave: () => TreeCreate | TreeUpdate
// Actions - Code Mode
setEditorMode: (mode: 'form' | 'code') => void
setMarkdownSource: (markdown: string) => void
setMarkdownValidationResult: (result: TreeMarkdownValidation) => void
syncMarkdownToTree: () => void
syncTreeToMarkdown: () => void
// Actions - State
setLoading: (loading: boolean) => void
setSaving: (saving: boolean) => void
// Helpers
findNode: (nodeId: string) => TreeStructure | null
getAllNodeIds: () => string[]
getAvailableTargetNodes: (excludeNodeId?: string) => Array<{ id: string; label: string; type: NodeType }>
}
// Create store with immer and temporal (undo/redo) middleware
export const useTreeEditorStore = create<TreeEditorState>()(
temporal(
immer((set, get) => ({
// Initial state
treeId: null,
name: '',
description: '',
category: '',
categoryId: null,
tags: [],
isPublic: false,
treeStructure: null,
originalTree: null,
selectedNodeId: null,
isDirty: false,
isLoading: false,
isSaving: false,
validationErrors: [],
editorMode: 'form',
markdownSource: null,
markdownValidationErrors: [],
isMarkdownValid: true,
isValidating: false,
lastValidTreeFromMarkdown: null,
lastSavedAt: null,
draftSavedAt: null,
hasDraft: false,
// Check for existing draft on init
initNewTree: () => {
const hasDraft = safeGetItem(DRAFT_STORAGE_KEY) !== null
set((state) => {
state.treeId = null
state.name = ''
state.description = ''
state.category = ''
state.categoryId = null
state.tags = []
state.isPublic = false
state.treeStructure = {
id: 'root',
type: 'decision',
question: '',
options: [],
children: []
}
state.originalTree = null
state.selectedNodeId = 'root'
state.isDirty = false
state.isLoading = false
state.isSaving = false
state.validationErrors = []
state.editorMode = 'form'
state.markdownSource = null
state.markdownValidationErrors = []
state.isMarkdownValid = true
state.isValidating = false
state.lastValidTreeFromMarkdown = null
state.lastSavedAt = null
state.draftSavedAt = null
state.hasDraft = hasDraft
})
},
loadTree: (tree: Tree) => {
set((state) => {
state.treeId = tree.id
state.name = tree.name
state.description = tree.description || ''
state.category = tree.category || ''
state.categoryId = tree.category_id || null
state.tags = tree.tags || []
state.isPublic = tree.is_public || false
state.treeStructure = tree.tree_structure
state.originalTree = tree
state.selectedNodeId = tree.tree_structure?.id || null
state.isDirty = false
state.isLoading = false
state.validationErrors = []
state.lastSavedAt = new Date()
state.draftSavedAt = null
state.hasDraft = false
})
},
loadDraft: () => {
const draftJson = safeGetItem(DRAFT_STORAGE_KEY)
if (!draftJson) return false
try {
const draft = JSON.parse(draftJson)
set((state) => {
state.treeId = draft.treeId || null
state.name = draft.name || ''
state.description = draft.description || ''
state.category = draft.category || ''
state.categoryId = draft.categoryId || null
state.tags = draft.tags || []
state.isPublic = draft.isPublic || false
state.treeStructure = draft.treeStructure || null
state.isDirty = true
state.draftSavedAt = draft.savedAt ? new Date(draft.savedAt) : null
state.hasDraft = false
})
return true
} catch {
safeRemoveItem(DRAFT_STORAGE_KEY)
return false
}
},
discardDraft: () => {
safeRemoveItem(DRAFT_STORAGE_KEY)
set((state) => {
state.hasDraft = false
})
},
reset: () => {
set((state) => {
state.treeId = null
state.name = ''
state.description = ''
state.category = ''
state.categoryId = null
state.tags = []
state.isPublic = false
state.treeStructure = null
state.originalTree = null
state.selectedNodeId = null
state.isDirty = false
state.isLoading = false
state.isSaving = false
state.validationErrors = []
state.editorMode = 'form'
state.markdownSource = null
state.markdownValidationErrors = []
state.isMarkdownValid = true
state.isValidating = false
state.lastValidTreeFromMarkdown = null
state.lastSavedAt = null
state.draftSavedAt = null
state.hasDraft = false
})
},
// Metadata actions
setName: (name: string) => {
set((state) => {
state.name = name
state.isDirty = true
})
get().autoSaveDraft()
},
setDescription: (description: string) => {
set((state) => {
state.description = description
state.isDirty = true
})
get().autoSaveDraft()
},
setCategory: (category: string) => {
set((state) => {
state.category = category
state.isDirty = true
})
get().autoSaveDraft()
},
setCategoryId: (categoryId: string | null) => {
set((state) => {
state.categoryId = categoryId
state.isDirty = true
})
get().autoSaveDraft()
},
setTags: (tags: string[]) => {
set((state) => {
state.tags = tags
state.isDirty = true
})
get().autoSaveDraft()
},
addTag: (tag: string) => {
set((state) => {
if (!state.tags.includes(tag)) {
state.tags.push(tag)
state.isDirty = true
}
})
get().autoSaveDraft()
},
removeTag: (tag: string) => {
set((state) => {
state.tags = state.tags.filter(t => t !== tag)
state.isDirty = true
})
get().autoSaveDraft()
},
setIsPublic: (isPublic: boolean) => {
set((state) => {
state.isPublic = isPublic
state.isDirty = true
})
get().autoSaveDraft()
},
// Node CRUD
addNode: (parentId: string | null, type: NodeType, insertIndex?: number) => {
const newId = generateId()
const newNode: TreeStructure = {
id: newId,
type,
...(type === 'decision' && {
question: '',
options: [],
children: []
}),
...(type === 'action' && {
title: '',
description: ''
}),
...(type === 'solution' && {
title: '',
description: ''
})
}
set((state) => {
if (!parentId) {
// Adding as root
state.treeStructure = newNode
} else {
// Find parent and add to children
const parent = findNodeInTree(parentId, state.treeStructure)
if (parent) {
if (!parent.children) {
parent.children = []
}
if (insertIndex !== undefined && insertIndex >= 0) {
parent.children.splice(insertIndex, 0, newNode)
} else {
parent.children.push(newNode)
}
}
}
state.selectedNodeId = newId
state.isDirty = true
})
get().autoSaveDraft()
return newId
},
updateNode: (nodeId: string, updates: Partial<TreeStructure>) => {
set((state) => {
const node = findNodeInTree(nodeId, state.treeStructure)
if (node) {
Object.assign(node, updates)
state.isDirty = true
}
})
get().autoSaveDraft()
},
deleteNode: (nodeId: string) => {
if (nodeId === 'root') {
// Don't allow deleting root, just clear it
set((state) => {
if (state.treeStructure) {
state.treeStructure.question = ''
state.treeStructure.options = []
state.treeStructure.children = []
}
state.isDirty = true
})
get().autoSaveDraft()
return
}
set((state) => {
const parent = findParentNode(nodeId, state.treeStructure)
if (parent && parent.children) {
const index = parent.children.findIndex(c => c.id === nodeId)
if (index !== -1) {
parent.children.splice(index, 1)
}
}
// Clear selection if deleted node was selected
if (state.selectedNodeId === nodeId) {
state.selectedNodeId = parent?.id || 'root'
}
state.isDirty = true
})
get().autoSaveDraft()
},
duplicateNode: (nodeId: string) => {
const state = get()
const node = findNodeInTree(nodeId, state.treeStructure)
if (!node) return null
const clonedNode = deepCloneNode(node)
// Find parent and add cloned node as sibling
const parent = findParentNode(nodeId, state.treeStructure)
set((s) => {
if (parent && parent.children) {
const index = parent.children.findIndex(c => c.id === nodeId)
parent.children.splice(index + 1, 0, clonedNode)
} else if (nodeId === 'root') {
// Can't duplicate root - just select it
return
}
s.selectedNodeId = clonedNode.id
s.isDirty = true
})
get().autoSaveDraft()
return clonedNode.id
},
// Reordering
reorderNodes: (parentId: string, fromIndex: number, toIndex: number) => {
set((state) => {
const parent = findNodeInTree(parentId, state.treeStructure)
if (parent && parent.children && parent.children.length > 0) {
const [moved] = parent.children.splice(fromIndex, 1)
parent.children.splice(toIndex, 0, moved)
state.isDirty = true
}
})
get().autoSaveDraft()
},
moveNode: (nodeId: string, targetParentId: string, targetIndex: number) => {
set((state) => {
// Find and remove from current parent
const currentParent = findParentNode(nodeId, state.treeStructure)
if (!currentParent?.children) return
const sourceIndex = currentParent.children.findIndex(c => c.id === nodeId)
if (sourceIndex === -1) return
const [movedNode] = currentParent.children.splice(sourceIndex, 1)
// Find target parent and insert
const targetParent = findNodeInTree(targetParentId, state.treeStructure)
if (!targetParent) return
if (!targetParent.children) {
targetParent.children = []
}
// Adjust index if moving within same parent and source was before target
let adjustedIndex = targetIndex
if (currentParent.id === targetParent.id && sourceIndex < targetIndex) {
adjustedIndex = targetIndex - 1
}
targetParent.children.splice(adjustedIndex, 0, movedNode)
state.isDirty = true
})
get().autoSaveDraft()
},
reorderOptions: (nodeId: string, fromIndex: number, toIndex: number) => {
set((state) => {
const node = findNodeInTree(nodeId, state.treeStructure)
if (node && node.options && node.options.length > 0) {
const [moved] = node.options.splice(fromIndex, 1)
node.options.splice(toIndex, 0, moved)
state.isDirty = true
}
})
get().autoSaveDraft()
},
// Selection
selectNode: (nodeId: string | null) => {
set((state) => {
state.selectedNodeId = nodeId
})
},
// Validation
validate: () => {
const state = get()
const errors: ValidationError[] = []
// Check tree name
if (!state.name.trim()) {
errors.push({ message: 'Tree name is required', severity: 'error' })
}
// Check tree structure exists
if (!state.treeStructure) {
errors.push({ message: 'Tree must have at least one node', severity: 'error' })
set((s) => { s.validationErrors = errors })
return errors
}
const allNodeIds = getAllNodeIds(state.treeStructure)
const referencedIds = new Set<string>()
let hasSolution = false
// Traverse and validate all nodes
const validateNode = (node: TreeStructure) => {
// Check type-specific required fields
if (node.type === 'decision') {
if (!node.question?.trim()) {
errors.push({
nodeId: node.id,
field: 'question',
message: `Decision node "${node.id}" requires a question`,
severity: 'error'
})
}
if (!node.options || node.options.length === 0) {
errors.push({
nodeId: node.id,
field: 'options',
message: `Decision node "${node.id}" requires at least one option`,
severity: 'error'
})
}
// Decision nodes with children must have at least 2 branches
if (node.children && node.children.length > 0 && node.children.length < 2) {
errors.push({
nodeId: node.id,
field: 'children',
message: `Decision node "${node.id}" must have at least 2 branches`,
severity: 'error'
})
}
if (node.options && node.options.length > 0) {
// Validate options
node.options.forEach((opt, i) => {
if (!opt.label?.trim()) {
errors.push({
nodeId: node.id,
field: `options[${i}].label`,
message: `Option ${i + 1} in "${node.id}" requires a label`,
severity: 'error'
})
}
if (opt.next_node_id) {
referencedIds.add(opt.next_node_id)
if (!allNodeIds.includes(opt.next_node_id)) {
errors.push({
nodeId: node.id,
field: `options[${i}].next_node_id`,
message: `Option "${opt.label}" references non-existent node "${opt.next_node_id}"`,
severity: 'error'
})
}
}
})
}
}
if (node.type === 'action') {
if (!node.title?.trim()) {
errors.push({
nodeId: node.id,
field: 'title',
message: `Action node "${node.id}" requires a title`,
severity: 'error'
})
}
if (node.next_node_id) {
referencedIds.add(node.next_node_id)
if (!allNodeIds.includes(node.next_node_id)) {
errors.push({
nodeId: node.id,
field: 'next_node_id',
message: `Action "${node.title}" references non-existent node "${node.next_node_id}"`,
severity: 'error'
})
}
}
}
if (node.type === 'solution') {
hasSolution = true
if (!node.title?.trim()) {
errors.push({
nodeId: node.id,
field: 'title',
message: `Solution node "${node.id}" requires a title`,
severity: 'error'
})
}
}
// Validate children
if (node.children) {
node.children.forEach(child => validateNode(child))
}
}
validateNode(state.treeStructure)
// Check for circular references in next_node_id chains
const detectCircularRefs = (startId: string, visited: Set<string> = new Set()): boolean => {
if (visited.has(startId)) return true
visited.add(startId)
const node = findNodeInTree(startId, state.treeStructure)
if (!node) return false
// Check options
if (node.options) {
for (const opt of node.options) {
if (opt.next_node_id && detectCircularRefs(opt.next_node_id, new Set(visited))) {
errors.push({
nodeId: node.id,
message: `This path loops back to an earlier node via "${opt.label}"`,
severity: 'warning'
})
return true
}
}
}
// Check next_node_id
if (node.next_node_id && detectCircularRefs(node.next_node_id, new Set(visited))) {
errors.push({
nodeId: node.id,
message: `This node loops back to an earlier node ("${node.title || node.id}")`,
severity: 'warning'
})
return true
}
return false
}
// Run from root
detectCircularRefs('root')
// Check for at least one solution
if (!hasSolution) {
errors.push({
message: 'Tree must have at least one solution (terminal) node',
severity: 'error'
})
}
// Check for orphaned nodes (not root and not referenced)
allNodeIds.forEach(id => {
if (id !== state.treeStructure?.id && !referencedIds.has(id)) {
// Check if it's a direct child of another node (via children array)
let isChildOfAny = false
const checkIfChild = (node: TreeStructure) => {
if (node.children?.some(c => c.id === id)) {
isChildOfAny = true
}
node.children?.forEach(checkIfChild)
}
checkIfChild(state.treeStructure!)
if (!isChildOfAny) {
errors.push({
nodeId: id,
message: `Node "${id}" is orphaned (not reachable from root)`,
severity: 'warning'
})
}
}
})
set((s) => { s.validationErrors = errors })
return errors
},
clearValidation: () => {
set((state) => { state.validationErrors = [] })
},
// Auto-save draft (debounced externally, called after each change)
autoSaveDraft: () => {
const state = get()
const draft = {
treeId: state.treeId,
name: state.name,
description: state.description,
category: state.category,
categoryId: state.categoryId,
tags: state.tags,
isPublic: state.isPublic,
treeStructure: state.treeStructure,
savedAt: new Date().toISOString()
}
safeSetItem(DRAFT_STORAGE_KEY, JSON.stringify(draft))
set((s) => { s.draftSavedAt = new Date() })
},
markSaved: () => {
safeRemoveItem(DRAFT_STORAGE_KEY)
set((state) => {
state.isDirty = false
state.lastSavedAt = new Date()
state.draftSavedAt = null
state.hasDraft = false
})
},
getTreeForSave: (): TreeCreate | TreeUpdate => {
const state = get()
return {
name: state.name,
description: state.description || undefined,
category: state.category || undefined,
category_id: state.categoryId || undefined,
tags: state.tags.length > 0 ? state.tags : undefined,
is_public: state.isPublic,
tree_structure: state.treeStructure!
}
},
// Code Mode actions
setEditorMode: (mode: 'form' | 'code') => {
const current = get()
if (mode === current.editorMode) return
if (mode === 'code') {
// Form → Code: generate markdown from tree structure (synchronous)
const { treeStructure, name, description, category, tags } = current
if (isEmptyTree(treeStructure) && !name.trim()) {
// New empty tree: use starter template
set((state) => {
state.markdownSource = CODE_MODE_STARTER_TEMPLATE
state.markdownValidationErrors = []
state.isMarkdownValid = false // needs validation
state.isValidating = false
state.editorMode = mode
})
} else if (treeStructure) {
const metadata = { name, description, category, tags }
const md = treeStructureToMarkdownPreview(treeStructure, metadata)
set((state) => {
state.markdownSource = md
state.markdownValidationErrors = []
state.isMarkdownValid = true
state.isValidating = false
state.editorMode = mode
})
} else {
set((state) => { state.editorMode = mode })
}
} else {
// Code → Form: apply last valid parse to tree structure
get().syncMarkdownToTree()
set((state) => { state.editorMode = mode })
}
},
setMarkdownSource: (markdown: string) => {
set((state) => {
state.markdownSource = markdown
state.isDirty = true
state.isValidating = true
})
},
setMarkdownValidationResult: (result: TreeMarkdownValidation) => {
set((state) => {
state.markdownValidationErrors = result.errors
state.isMarkdownValid = result.valid
state.isValidating = false
if (result.valid && result.tree_structure) {
state.lastValidTreeFromMarkdown = result.tree_structure as TreeStructure
// Live sync: apply valid parsed tree to treeStructure immediately
state.treeStructure = result.tree_structure as TreeStructure
}
// Apply metadata from parsed markdown (bidirectional sync)
if (result.metadata) {
if (result.metadata.name !== undefined) state.name = result.metadata.name
if (result.metadata.description !== undefined) state.description = result.metadata.description
if (result.metadata.category !== undefined) state.category = result.metadata.category
if (result.metadata.tags !== undefined) state.tags = result.metadata.tags
}
})
},
syncMarkdownToTree: () => {
const { lastValidTreeFromMarkdown, isMarkdownValid } = get()
if (isMarkdownValid && lastValidTreeFromMarkdown) {
set((state) => {
state.treeStructure = lastValidTreeFromMarkdown
state.markdownSource = null
state.isDirty = true
})
}
},
syncTreeToMarkdown: () => {
const { treeStructure, name, description, category, tags } = get()
if (treeStructure) {
const metadata = { name, description, category, tags }
const md = treeStructureToMarkdownPreview(treeStructure, metadata)
set((state) => {
state.markdownSource = md
state.markdownValidationErrors = []
state.isMarkdownValid = true
state.isValidating = false
})
}
},
setLoading: (loading: boolean) => {
set((state) => { state.isLoading = loading })
},
setSaving: (saving: boolean) => {
set((state) => { state.isSaving = saving })
},
// Helpers
findNode: (nodeId: string) => {
return findNodeInTree(nodeId, get().treeStructure)
},
getAllNodeIds: () => {
return getAllNodeIds(get().treeStructure)
},
getAvailableTargetNodes: (excludeNodeId?: string) => {
const state = get()
const allIds = getAllNodeIds(state.treeStructure)
return allIds
.filter(id => id !== excludeNodeId)
.map(id => {
const node = findNodeInTree(id, state.treeStructure)
let type: NodeType = 'decision'
let title = ''
if (node) {
type = node.type
if (node.question) title = node.question.slice(0, 50)
else if (node.title) title = node.title.slice(0, 50)
}
// Use short ID format, but 'root' stays as-is
const shortId = id === 'root' ? 'root' : id.slice(0, 8) + '...'
// Format: "Title (shortId)" or just "(shortId)" if no title
// For root without a question, show "Root Question (root)"
let label: string
if (id === 'root') {
label = title ? `${title} (root)` : 'Root Question (root)'
} else if (title) {
label = `${title} (${shortId})`
} else {
// No title yet - show placeholder with ID
const typeName = type === 'decision' ? 'Untitled Question' : `Untitled ${type}`
label = `${typeName} (${shortId})`
}
return { id, label, type }
})
}
})),
{
// Zundo options for undo/redo
limit: 50, // Keep last 50 states
partialize: (state) => ({
// Only track these fields in history
name: state.name,
description: state.description,
category: state.category,
categoryId: state.categoryId,
tags: state.tags,
isPublic: state.isPublic,
treeStructure: state.treeStructure
}),
// Skip no-op entries where partialized fields haven't changed
equality: (pastState, currentState) => shallow(pastState, currentState),
// Throttle history captures: collapse rapid changes (typing, validation) into ~one entry per 3s
// This makes Flow Mode undo revert meaningful chunks (whole field edits) instead of single characters
handleSet: (handleSet) =>
throttle<typeof handleSet>((state) => {
handleSet(state)
}, 3000),
}
)
)
// Export temporal store for undo/redo access
// Use with: useStore(useTreeEditorStore.temporal, selector)
export const useTreeEditorTemporal = useTreeEditorStore.temporal
export default useTreeEditorStore