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

@@ -0,0 +1,201 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import Editor, { type OnMount, type BeforeMount } from '@monaco-editor/react'
import type { editor as MonacoEditor } from 'monaco-editor'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { treeMarkdownApi } from '@/api/treeMarkdown'
import { resolutionFlowLanguage, LANGUAGE_ID } from './resolutionFlowLanguage'
import { resolutionFlowTheme, THEME_ID } from './resolutionFlowTheme'
import { createCompletionProvider } from './resolutionFlowCompletions'
import { CodeModeToolbar } from './CodeModeToolbar'
import { SyntaxHelpPanel } from './SyntaxHelpPanel'
// Module-level ref so TreeEditorPage can trigger Monaco undo/redo from toolbar buttons
let _monacoEditor: MonacoEditor.IStandaloneCodeEditor | null = null
export function getMonacoEditor() { return _monacoEditor }
export function CodeModeEditor() {
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null)
const validationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const {
markdownSource,
markdownValidationErrors,
isMarkdownValid,
isValidating,
setMarkdownSource,
setMarkdownValidationResult,
getAvailableTargetNodes,
} = useTreeEditorStore()
const [syntaxHelpOpen, setSyntaxHelpOpen] = useState(false)
// Register language and theme before editor mounts
const handleEditorWillMount: BeforeMount = useCallback((monaco) => {
// Register language if not already registered
const langs = monaco.languages.getLanguages()
if (!langs.some((l: { id: string }) => l.id === LANGUAGE_ID)) {
monaco.languages.register({ id: LANGUAGE_ID })
monaco.languages.setMonarchTokensProvider(LANGUAGE_ID, resolutionFlowLanguage)
}
// Register theme
monaco.editor.defineTheme(THEME_ID, resolutionFlowTheme)
// Register completion provider
monaco.languages.registerCompletionItemProvider(
LANGUAGE_ID,
createCompletionProvider(() =>
getAvailableTargetNodes().map(n => ({
id: n.id,
label: n.label,
type: n.type,
}))
)
)
}, [getAvailableTargetNodes])
// Editor mounted
const handleEditorDidMount: OnMount = useCallback((editor) => {
editorRef.current = editor
_monacoEditor = editor
editor.focus()
}, [])
// Debounced validation on change
const handleEditorChange = useCallback((value: string | undefined) => {
const md = value ?? ''
setMarkdownSource(md)
// Cancel pending validation
if (validationTimeoutRef.current) {
clearTimeout(validationTimeoutRef.current)
}
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
// Debounce 800ms
validationTimeoutRef.current = setTimeout(async () => {
abortControllerRef.current = new AbortController()
try {
const result = await treeMarkdownApi.validateMarkdown(md)
setMarkdownValidationResult(result)
// Set Monaco markers
if (editorRef.current) {
const monaco = (await import('monaco-editor')).default ?? await import('monaco-editor')
const model = editorRef.current.getModel()
if (model && monaco.editor?.setModelMarkers) {
const markers = result.errors.map((err) => ({
startLineNumber: err.line,
startColumn: err.column,
endLineNumber: err.line,
endColumn: err.column + 1,
message: err.message,
severity: err.severity === 'error' ? 8 : 4, // MarkerSeverity.Error : Warning
}))
monaco.editor.setModelMarkers(model, 'resolutionflow', markers)
}
}
} catch {
// Validation cancelled or failed — ignore
}
}, 800)
}, [setMarkdownSource, setMarkdownValidationResult])
// Insert template at cursor
const handleInsertTemplate = useCallback((template: string) => {
const editor = editorRef.current
if (!editor) return
const position = editor.getPosition()
if (!position) return
const model = editor.getModel()
if (!model) return
// Insert at end of document
const lastLine = model.getLineCount()
const lastColumn = model.getLineMaxColumn(lastLine)
editor.executeEdits('insert-template', [{
range: {
startLineNumber: lastLine,
startColumn: lastColumn,
endLineNumber: lastLine,
endColumn: lastColumn,
},
text: template,
}])
// Move cursor to the inserted template
const newLastLine = model.getLineCount()
editor.setPosition({ lineNumber: newLastLine, column: 1 })
editor.revealLine(newLastLine)
editor.focus()
}, [])
// Cleanup on unmount
useEffect(() => {
return () => {
_monacoEditor = null
if (validationTimeoutRef.current) {
clearTimeout(validationTimeoutRef.current)
}
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
}
}, [])
return (
<div className="flex h-full flex-col">
<CodeModeToolbar
validationErrors={markdownValidationErrors}
isValidating={isValidating}
isValid={isMarkdownValid}
onInsertTemplate={handleInsertTemplate}
onToggleSyntaxHelp={() => setSyntaxHelpOpen(!syntaxHelpOpen)}
syntaxHelpOpen={syntaxHelpOpen}
/>
<div className="relative flex-1 min-h-0">
<Editor
height="100%"
language={LANGUAGE_ID}
theme={THEME_ID}
value={markdownSource ?? ''}
onChange={handleEditorChange}
beforeMount={handleEditorWillMount}
onMount={handleEditorDidMount}
loading={
<div className="flex h-full items-center justify-center bg-[#0a0a0a]">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white/20 border-t-white" />
</div>
}
options={{
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
wordWrap: 'on',
scrollBeyondLastLine: false,
renderLineHighlight: 'line',
tabSize: 2,
insertSpaces: true,
automaticLayout: true,
suggestOnTriggerCharacters: true,
quickSuggestions: true,
padding: { top: 12, bottom: 12 },
accessibilitySupport: 'on',
}}
/>
{/* Syntax help as absolute overlay on right side */}
{syntaxHelpOpen && (
<div className="absolute right-0 top-0 bottom-0 z-20 w-[280px]">
<SyntaxHelpPanel open={syntaxHelpOpen} onClose={() => setSyntaxHelpOpen(false)} />
</div>
)}
</div>
</div>
)
}