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:
chihlasm
2026-02-10 09:45:26 -05:00
parent 2bd47004e7
commit eac6e184ec
32 changed files with 3369 additions and 52 deletions

View File

@@ -1,8 +1,9 @@
import { useEffect, useState, useCallback } from 'react'
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
import { useStore } from 'zustand'
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText } from 'lucide-react'
import { treesApi } from '@/api'
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList } from 'lucide-react'
import { getMonacoEditor } from '@/components/tree-editor/code-mode'
import { treesApi, treeMarkdownApi } from '@/api'
import type { TreeCreate, TreeUpdate, TreeStatus } from '@/types'
import { useTreeEditorStore, useTreeEditorTemporal } from '@/store/treeEditorStore'
import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout'
@@ -24,6 +25,7 @@ export function TreeEditorPage() {
isLoading,
isSaving,
validationErrors,
editorMode,
initNewTree,
loadTree,
loadDraft,
@@ -34,7 +36,8 @@ export function TreeEditorPage() {
markSaved,
setLoading,
setSaving,
selectNode
selectNode,
setEditorMode,
} = useTreeEditorStore()
// Access undo/redo from temporal store
@@ -61,22 +64,50 @@ export function TreeEditorPage() {
isDirty && currentLocation.pathname !== nextLocation.pathname
)
const handleUndo = useCallback(() => {
if (editorMode === 'code') {
// In Code Mode, use Monaco's native undo (word-level, like VS Code)
const editor = getMonacoEditor()
if (editor) {
editor.trigger('toolbar', 'undo', null)
editor.focus()
return
}
}
if (pastStates.length > 0) {
undo()
toast.info('Undone')
}
}, [editorMode, pastStates.length, undo])
const handleRedo = useCallback(() => {
if (editorMode === 'code') {
// In Code Mode, use Monaco's native redo (word-level, like VS Code)
const editor = getMonacoEditor()
if (editor) {
editor.trigger('toolbar', 'redo', null)
editor.focus()
return
}
}
if (futureStates.length > 0) {
redo()
toast.info('Redone')
}
}, [editorMode, futureStates.length, redo])
// Keyboard shortcuts for undo/redo/save
useKeyboardShortcuts([
{
key: 'z',
ctrl: true,
handler: () => {
if (pastStates.length > 0) undo()
}
handler: handleUndo
},
{
key: 'z',
ctrl: true,
shift: true,
handler: () => {
if (futureStates.length > 0) redo()
}
handler: handleRedo
},
{
key: 's',
@@ -84,6 +115,14 @@ export function TreeEditorPage() {
handler: () => {
handleSave()
}
},
{
key: 'm',
ctrl: true,
shift: true,
handler: () => {
setEditorMode(editorMode === 'form' ? 'code' : 'form')
}
}
])
@@ -165,6 +204,29 @@ export function TreeEditorPage() {
const handleSaveDraft = useCallback(async () => {
setSaving(true)
try {
// In Code Mode, run fresh validation on current markdown before saving
if (editorMode === 'code') {
const { markdownSource, setMarkdownValidationResult } = useTreeEditorStore.getState()
if (markdownSource) {
const result = await treeMarkdownApi.validateMarkdown(markdownSource)
setMarkdownValidationResult(result) // applies tree_structure + metadata to store
if (!result.valid) {
const errorCount = result.errors.filter(e => e.severity === 'error').length
toast.error(`Cannot save: ${errorCount} markdown error${errorCount !== 1 ? 's' : ''} — fix them in the editor first`)
setSaving(false)
return
}
}
}
// Check tree name is set (metadata may come from Code Mode markdown)
const currentState = useTreeEditorStore.getState()
if (!currentState.name.trim()) {
toast.error('Tree name is required. Add a "name:" field in the metadata block or switch to Flow Mode.')
setSaving(false)
return
}
const treeData = { ...getTreeForSave(), status: 'draft' as TreeStatus }
if (isEditMode) {
await treesApi.update(id!, treeData as TreeUpdate)
@@ -174,31 +236,67 @@ export function TreeEditorPage() {
} else {
const newTree = await treesApi.create(treeData as TreeCreate)
setTreeStatus('draft')
// Mark saved BEFORE navigating to avoid triggering the blocker
markSaved()
toast.success('Draft created successfully')
// Navigate to edit mode with the new ID
navigate(`/trees/${newTree.id}/edit`, { replace: true })
}
} catch (err) {
} catch (err: unknown) {
console.error('Failed to save draft:', err)
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { status?: number; data?: { detail?: string | { message?: string; errors?: string[] } } } }
if (axiosErr.response?.status === 422) {
const detail = axiosErr.response.data?.detail
if (typeof detail === 'object' && detail?.errors) {
toast.error(`Validation failed: ${detail.errors.join(', ')}`)
} else if (typeof detail === 'string') {
toast.error(`Validation failed: ${detail}`)
} else {
toast.error('Tree has validation errors. Fix them before saving.')
}
return
}
}
toast.error('Failed to save draft. Please try again.')
} finally {
setSaving(false)
}
}, [isEditMode, id, getTreeForSave, markSaved, navigate])
}, [isEditMode, id, editorMode, getTreeForSave, markSaved, navigate])
const handlePublish = useCallback(async () => {
// Validate first
const errors = validate()
const hasErrors = errors.some(e => e.severity === 'error')
if (hasErrors) {
toast.error('Please fix validation errors before publishing')
return
}
setSaving(true)
try {
// In Code Mode, run fresh validation on current markdown before publishing
if (editorMode === 'code') {
const { markdownSource, setMarkdownValidationResult } = useTreeEditorStore.getState()
if (markdownSource) {
const result = await treeMarkdownApi.validateMarkdown(markdownSource)
setMarkdownValidationResult(result)
if (!result.valid) {
const errorCount = result.errors.filter(e => e.severity === 'error').length
toast.error(`Cannot publish: ${errorCount} markdown error${errorCount !== 1 ? 's' : ''} — fix them in the editor first`)
setSaving(false)
return
}
}
}
// Check tree name is set
const currentState = useTreeEditorStore.getState()
if (!currentState.name.trim()) {
toast.error('Tree name is required. Add a "name:" field in the metadata block or switch to Flow Mode.')
setSaving(false)
return
}
// Validate tree structure
const errors = validate()
const hasErrors = errors.some(e => e.severity === 'error')
if (hasErrors) {
toast.error('Please fix validation errors before publishing')
setSaving(false)
return
}
const treeData = { ...getTreeForSave(), status: 'published' as TreeStatus }
if (isEditMode) {
await treesApi.update(id!, treeData as TreeUpdate)
@@ -208,10 +306,8 @@ export function TreeEditorPage() {
} else {
const newTree = await treesApi.create(treeData as TreeCreate)
setTreeStatus('published')
// Mark saved BEFORE navigating to avoid triggering the blocker
markSaved()
toast.success('Tree published successfully')
// Navigate to edit mode with the new ID
navigate(`/trees/${newTree.id}/edit`, { replace: true })
}
} catch (err) {
@@ -220,7 +316,7 @@ export function TreeEditorPage() {
} finally {
setSaving(false)
}
}, [isEditMode, id, validate, getTreeForSave, markSaved, navigate])
}, [isEditMode, id, editorMode, validate, getTreeForSave, markSaved, navigate])
// Keep handleSave for backward compatibility (Ctrl+S shortcut)
const handleSave = useCallback(async () => {
@@ -371,17 +467,52 @@ export function TreeEditorPage() {
</div>
<div className="flex items-center gap-2">
{/* Mode Toggle */}
<div className="flex items-center rounded-md border border-white/[0.06]">
<button
type="button"
onClick={() => setEditorMode('form')}
title="Flow Mode — form-based editing"
className={cn(
'flex items-center gap-1.5 rounded-l-md px-3 py-1.5 text-xs font-medium transition-colors',
editorMode === 'form'
? 'bg-white/10 text-white'
: 'text-white/40 hover:bg-white/[0.06] hover:text-white/60'
)}
>
<LayoutList className="h-3.5 w-3.5" />
Flow
</button>
<div className="h-5 w-px bg-white/[0.06]" />
<button
type="button"
onClick={() => setEditorMode('code')}
title="Code Mode — markdown editing (Ctrl+Shift+M)"
className={cn(
'flex items-center gap-1.5 rounded-r-md px-3 py-1.5 text-xs font-medium transition-colors',
editorMode === 'code'
? 'bg-white/10 text-white'
: 'text-white/40 hover:bg-white/[0.06] hover:text-white/60'
)}
>
<Code2 className="h-3.5 w-3.5" />
Code
</button>
</div>
<div className="mx-1 h-6 w-px bg-white/[0.06]" />
{/* Undo/Redo */}
<div className="flex items-center rounded-md border border-white/[0.06]">
<button
type="button"
onClick={() => undo()}
onClick={handleUndo}
disabled={pastStates.length === 0}
title={pastStates.length > 0 ? `Undo (Ctrl+Z) - ${pastStates.length} step${pastStates.length !== 1 ? 's' : ''} available` : 'Nothing to undo'}
className={cn(
'rounded-l-md p-2 transition-colors',
pastStates.length > 0
? 'text-white hover:bg-white/[0.06]'
? 'text-white hover:bg-white/[0.06] active:bg-white/[0.12]'
: 'text-white/20 cursor-not-allowed'
)}
>
@@ -390,13 +521,13 @@ export function TreeEditorPage() {
<div className="h-6 w-px bg-white/[0.06]" />
<button
type="button"
onClick={() => redo()}
onClick={handleRedo}
disabled={futureStates.length === 0}
title={futureStates.length > 0 ? `Redo (Ctrl+Shift+Z) - ${futureStates.length} step${futureStates.length !== 1 ? 's' : ''} available` : 'Nothing to redo'}
className={cn(
'rounded-r-md p-2 transition-colors',
futureStates.length > 0
? 'text-white hover:bg-white/[0.06]'
? 'text-white hover:bg-white/[0.06] active:bg-white/[0.12]'
: 'text-white/20 cursor-not-allowed'
)}
>