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,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'
|
||||
)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user