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:
@@ -0,0 +1,186 @@
|
||||
import { ChevronDown, AlertCircle, CheckCircle2, Loader2, Plus, HelpCircle } from 'lucide-react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TreeMarkdownValidationError } from '@/types'
|
||||
|
||||
interface CodeModeToolbarProps {
|
||||
validationErrors: TreeMarkdownValidationError[]
|
||||
isValidating: boolean
|
||||
isValid: boolean
|
||||
onInsertTemplate: (template: string) => void
|
||||
onToggleSyntaxHelp: () => void
|
||||
syntaxHelpOpen: boolean
|
||||
}
|
||||
|
||||
const NODE_TEMPLATES = {
|
||||
decision: [
|
||||
'',
|
||||
'---',
|
||||
'id: new_decision',
|
||||
'type: decision',
|
||||
'parent: root',
|
||||
'---',
|
||||
'# What is the question?',
|
||||
'',
|
||||
'> Help text for the engineer',
|
||||
'',
|
||||
'- [A] Option A → @target_id',
|
||||
'- [B] Option B → @target_id',
|
||||
'',
|
||||
].join('\n'),
|
||||
action: [
|
||||
'',
|
||||
'---',
|
||||
'id: new_action',
|
||||
'type: action',
|
||||
'parent: root',
|
||||
'---',
|
||||
'## Action Title',
|
||||
'',
|
||||
'Description of what to do.',
|
||||
'',
|
||||
'```commands',
|
||||
'command here',
|
||||
'```',
|
||||
'',
|
||||
'**Expected:** Expected outcome',
|
||||
'',
|
||||
'→ @next_node_id',
|
||||
'',
|
||||
].join('\n'),
|
||||
solution: [
|
||||
'',
|
||||
'---',
|
||||
'id: new_solution',
|
||||
'type: solution',
|
||||
'parent: root',
|
||||
'---',
|
||||
'## Solution Title',
|
||||
'',
|
||||
'Description of the resolution.',
|
||||
'',
|
||||
'1. Step 1',
|
||||
'2. Step 2',
|
||||
'',
|
||||
].join('\n'),
|
||||
}
|
||||
|
||||
export function CodeModeToolbar({
|
||||
validationErrors,
|
||||
isValidating,
|
||||
isValid,
|
||||
onInsertTemplate,
|
||||
onToggleSyntaxHelp,
|
||||
syntaxHelpOpen,
|
||||
}: CodeModeToolbarProps) {
|
||||
const [insertOpen, setInsertOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const errorCount = validationErrors.filter(e => e.severity === 'error').length
|
||||
const warningCount = validationErrors.filter(e => e.severity === 'warning').length
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setInsertOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] bg-black/50 px-3 py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Insert Node dropdown */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setInsertOpen(!insertOpen)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md border border-white/10 px-2.5 py-1 text-xs font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Insert Node
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
{insertOpen && (
|
||||
<div className="absolute left-0 top-full z-50 mt-1 w-44 rounded-lg border border-white/10 bg-[#111] py-1 shadow-xl">
|
||||
<button
|
||||
onClick={() => { onInsertTemplate(NODE_TEMPLATES.decision); setInsertOpen(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-white/70 hover:bg-white/10"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-blue-400" />
|
||||
Decision
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onInsertTemplate(NODE_TEMPLATES.action); setInsertOpen(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-white/70 hover:bg-white/10"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-amber-400" />
|
||||
Action
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onInsertTemplate(NODE_TEMPLATES.solution); setInsertOpen(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-white/70 hover:bg-white/10"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
Solution
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Validation status */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{isValidating ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin text-white/40" />
|
||||
<span className="text-white/40">Validating...</span>
|
||||
</>
|
||||
) : isValid ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-3 w-3 text-emerald-400" />
|
||||
<span className="text-emerald-400/70">Synced</span>
|
||||
{warningCount > 0 && (
|
||||
<span className="text-yellow-400/70">
|
||||
({warningCount} warning{warningCount !== 1 ? 's' : ''})
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-3 w-3 text-red-400" />
|
||||
<span className="text-red-400/70">
|
||||
{errorCount} error{errorCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{warningCount > 0 && (
|
||||
<span className="text-yellow-400/70">
|
||||
, {warningCount} warning{warningCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Syntax Help toggle */}
|
||||
<button
|
||||
onClick={onToggleSyntaxHelp}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md px-2 py-1 text-xs',
|
||||
syntaxHelpOpen
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-white/40 hover:bg-white/[0.06] hover:text-white/60'
|
||||
)}
|
||||
>
|
||||
<HelpCircle className="h-3 w-3" />
|
||||
Syntax
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user