feat: add dual-mode tree editor with Code Mode, variables, and markdown sync
Implements the full dual-mode tree editor (Plan Phases 1-5): Backend: - JSONB↔Markdown bidirectional serializer/parser with mistune - Markdown validator with line/column error reporting - 3 API endpoints: export-markdown, import-markdown, validate-markdown - Variable extraction/resolution service ([USER_INPUT], [VAR], [SAVE_AS]) - Session variables JSONB column (migration 028) - 39 tree markdown tests + variable service tests (403 total passing) Frontend: - Monaco-based Code Mode with custom Monarch tokenizer and dark theme - Autocomplete for @node_id refs, type values, variable names - Debounced validation (800ms) with inline Monaco error markers - Syntax help panel (absolute overlay, toggleable) - Starter template for new trees with valid cross-references - Bidirectional metadata sync (name/description/category/tags frontmatter) - Synchronous tree→markdown serializer (fixes async race condition) - Pre-save validation blocks save on broken refs or missing tree name - Mode-aware undo/redo: Monaco native in Code Mode, throttled zundo in Flow Mode - Variable prompt modal and frontend resolver for session navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,31 @@
|
||||
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 } from '@/types'
|
||||
import type { Tree, TreeStructure, TreeCreate, TreeUpdate, NodeType, TreeMarkdownValidationError, TreeMarkdownValidation } from '@/types'
|
||||
import { treeStructureToMarkdownPreview } from '@/lib/treeMarkdownSync'
|
||||
|
||||
// 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 {
|
||||
@@ -14,6 +38,71 @@ export interface ValidationError {
|
||||
// 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()
|
||||
|
||||
@@ -114,6 +203,14 @@ interface TreeEditorState {
|
||||
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
|
||||
@@ -159,6 +256,13 @@ interface TreeEditorState {
|
||||
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
|
||||
@@ -188,6 +292,12 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
validationErrors: [],
|
||||
editorMode: 'form',
|
||||
markdownSource: null,
|
||||
markdownValidationErrors: [],
|
||||
isMarkdownValid: true,
|
||||
isValidating: false,
|
||||
lastValidTreeFromMarkdown: null,
|
||||
lastSavedAt: null,
|
||||
draftSavedAt: null,
|
||||
hasDraft: false,
|
||||
@@ -216,6 +326,12 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
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
|
||||
@@ -292,6 +408,12 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
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
|
||||
@@ -773,6 +895,96 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
}
|
||||
},
|
||||
|
||||
// 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 })
|
||||
},
|
||||
@@ -839,7 +1051,15 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
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),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { persist } from 'zustand/middleware'
|
||||
type ExportFormat = 'markdown' | 'text' | 'html' | 'psa'
|
||||
type TreeLibraryView = 'grid' | 'list' | 'table'
|
||||
type TreeSortBy = 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'
|
||||
type EditorMode = 'form' | 'code'
|
||||
|
||||
interface UserPreferencesState {
|
||||
defaultExportFormat: ExportFormat
|
||||
@@ -12,6 +13,8 @@ interface UserPreferencesState {
|
||||
setTreeLibraryView: (view: TreeLibraryView) => void
|
||||
treeLibrarySortBy: TreeSortBy
|
||||
setTreeLibrarySortBy: (sortBy: TreeSortBy) => void
|
||||
preferredEditorMode: EditorMode
|
||||
setPreferredEditorMode: (mode: EditorMode) => void
|
||||
}
|
||||
|
||||
export const useUserPreferencesStore = create<UserPreferencesState>()(
|
||||
@@ -23,6 +26,8 @@ export const useUserPreferencesStore = create<UserPreferencesState>()(
|
||||
setTreeLibraryView: (view) => set({ treeLibraryView: view }),
|
||||
treeLibrarySortBy: 'usage_count',
|
||||
setTreeLibrarySortBy: (sortBy) => set({ treeLibrarySortBy: sortBy }),
|
||||
preferredEditorMode: 'form',
|
||||
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),
|
||||
}),
|
||||
{
|
||||
name: 'user-preferences-storage',
|
||||
|
||||
Reference in New Issue
Block a user