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:
201
frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx
Normal file
201
frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user