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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SyntaxHelpPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SyntaxHelpPanel({ open, onClose }: SyntaxHelpPanelProps) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-l border-white/[0.06] bg-[#0a0a0a]">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-3 py-2">
|
||||
<span className="text-xs font-medium text-white/60">Syntax Reference</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-0.5 text-white/30 hover:bg-white/10 hover:text-white/60"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3 text-[11px] leading-relaxed text-white/50">
|
||||
<Section title="Node Structure">
|
||||
<Code>{`---
|
||||
id: node_id
|
||||
type: decision|action|solution
|
||||
parent: parent_id
|
||||
---`}</Code>
|
||||
</Section>
|
||||
|
||||
<Section title="Decision Node">
|
||||
<Code>{`# Question text here
|
||||
|
||||
> Help text (blockquote)
|
||||
|
||||
- [A] Option label → @target_id
|
||||
- [B] Another option → @target_id`}</Code>
|
||||
</Section>
|
||||
|
||||
<Section title="Action Node">
|
||||
<Code>{`## Action Title
|
||||
|
||||
Description paragraph.
|
||||
|
||||
\`\`\`commands
|
||||
ping 8.8.8.8
|
||||
tracert gateway
|
||||
\`\`\`
|
||||
|
||||
**Expected:** Expected outcome
|
||||
|
||||
→ @next_node_id`}</Code>
|
||||
</Section>
|
||||
|
||||
<Section title="Solution Node">
|
||||
<Code>{`## Solution Title
|
||||
|
||||
Description paragraph.
|
||||
|
||||
1. Resolution step one
|
||||
2. Resolution step two`}</Code>
|
||||
</Section>
|
||||
|
||||
<Section title="Variables">
|
||||
<Row label="User input" code="[USER_INPUT:prompt]" />
|
||||
<Row label="Reference" code="[VAR:name]" />
|
||||
<Row label="Save value" code="[SAVE_AS:name]" />
|
||||
</Section>
|
||||
|
||||
<Section title="Keyboard Shortcuts">
|
||||
<Row label="Toggle mode" code="Ctrl+Shift+M" />
|
||||
<Row label="Insert Decision" code="Ctrl+Shift+D" />
|
||||
<Row label="Insert Action" code="Ctrl+Shift+A" />
|
||||
<Row label="Insert Solution" code="Ctrl+Shift+S" />
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-white/30">{title}</h4>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Code({ children }: { children: string }) {
|
||||
return (
|
||||
<pre className={cn(
|
||||
'mb-2 overflow-x-auto rounded-md border border-white/[0.06] bg-black/50 px-2 py-1.5',
|
||||
'text-[10px] leading-relaxed text-white/50 whitespace-pre'
|
||||
)}>
|
||||
{children}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, code }: { label: string; code: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-0.5">
|
||||
<span className="text-white/40">{label}</span>
|
||||
<code className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/50">{code}</code>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
frontend/src/components/tree-editor/code-mode/index.ts
Normal file
3
frontend/src/components/tree-editor/code-mode/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { CodeModeEditor, getMonacoEditor } from './CodeModeEditor'
|
||||
export { CodeModeToolbar } from './CodeModeToolbar'
|
||||
export { SyntaxHelpPanel } from './SyntaxHelpPanel'
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { languages, editor, Position, IRange } from 'monaco-editor'
|
||||
|
||||
interface NodeInfo {
|
||||
id: string
|
||||
label: string
|
||||
type: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a completion provider for ResolutionFlow markdown.
|
||||
* Provides autocomplete for node references, types, and templates.
|
||||
*/
|
||||
export function createCompletionProvider(
|
||||
getNodes: () => NodeInfo[]
|
||||
): languages.CompletionItemProvider {
|
||||
return {
|
||||
triggerCharacters: ['@', ':', '['],
|
||||
provideCompletionItems(
|
||||
model: editor.ITextModel,
|
||||
position: Position,
|
||||
): languages.ProviderResult<languages.CompletionList> {
|
||||
const lineContent = model.getLineContent(position.lineNumber)
|
||||
const textUntilPosition = lineContent.substring(0, position.column - 1)
|
||||
|
||||
const word = model.getWordUntilPosition(position)
|
||||
const range: IRange = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
}
|
||||
|
||||
const suggestions: languages.CompletionItem[] = []
|
||||
|
||||
// After @ — suggest node IDs
|
||||
if (textUntilPosition.endsWith('@') || textUntilPosition.match(/@\w*$/)) {
|
||||
const atPos = textUntilPosition.lastIndexOf('@')
|
||||
const atRange: IRange = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: atPos + 2, // after the @
|
||||
endColumn: position.column,
|
||||
}
|
||||
|
||||
const nodes = getNodes()
|
||||
for (const node of nodes) {
|
||||
const typeIcon = node.type === 'decision' ? '?' : node.type === 'action' ? '!' : '='
|
||||
suggestions.push({
|
||||
label: `${node.id} — ${node.label}`,
|
||||
kind: 18, // CompletionItemKind.Reference
|
||||
insertText: node.id,
|
||||
detail: `${typeIcon} ${node.type}`,
|
||||
range: atRange,
|
||||
})
|
||||
}
|
||||
return { suggestions }
|
||||
}
|
||||
|
||||
// After "type:" — suggest node types
|
||||
if (textUntilPosition.match(/^type:\s*$/)) {
|
||||
for (const t of ['decision', 'action', 'solution']) {
|
||||
suggestions.push({
|
||||
label: t,
|
||||
kind: 12, // CompletionItemKind.Value
|
||||
insertText: t,
|
||||
range,
|
||||
})
|
||||
}
|
||||
return { suggestions }
|
||||
}
|
||||
|
||||
// After "---\n" at beginning — suggest node template
|
||||
if (lineContent.trim() === '---' && position.lineNumber <= 2) {
|
||||
suggestions.push(
|
||||
{
|
||||
label: 'Decision Node',
|
||||
kind: 14, // CompletionItemKind.Snippet
|
||||
insertText: [
|
||||
'',
|
||||
'id: ${1:node_id}',
|
||||
'type: decision',
|
||||
'---',
|
||||
'# ${2:What is the question?}',
|
||||
'',
|
||||
'> ${3:Help text for the engineer}',
|
||||
'',
|
||||
'- [A] ${4:Option A} → @${5:target_id}',
|
||||
'- [B] ${6:Option B} → @${7:target_id}',
|
||||
].join('\n'),
|
||||
insertTextRules: 4, // InsertTextRule.InsertAsSnippet
|
||||
detail: 'Decision node template',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'Action Node',
|
||||
kind: 14,
|
||||
insertText: [
|
||||
'',
|
||||
'id: ${1:node_id}',
|
||||
'type: action',
|
||||
'parent: ${2:parent_id}',
|
||||
'---',
|
||||
'## ${3:Action Title}',
|
||||
'',
|
||||
'${4:Description of what to do}',
|
||||
'',
|
||||
'```commands',
|
||||
'${5:command here}',
|
||||
'```',
|
||||
'',
|
||||
'**Expected:** ${6:Expected outcome}',
|
||||
'',
|
||||
'→ @${7:next_node_id}',
|
||||
].join('\n'),
|
||||
insertTextRules: 4,
|
||||
detail: 'Action node template',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'Solution Node',
|
||||
kind: 14,
|
||||
insertText: [
|
||||
'',
|
||||
'id: ${1:node_id}',
|
||||
'type: solution',
|
||||
'parent: ${2:parent_id}',
|
||||
'---',
|
||||
'## ${3:Solution Title}',
|
||||
'',
|
||||
'${4:Description}',
|
||||
'',
|
||||
'1. ${5:Step 1}',
|
||||
'2. ${6:Step 2}',
|
||||
].join('\n'),
|
||||
insertTextRules: 4,
|
||||
detail: 'Solution node template',
|
||||
range,
|
||||
},
|
||||
)
|
||||
return { suggestions }
|
||||
}
|
||||
|
||||
// After [VAR: — suggest variable names
|
||||
if (textUntilPosition.match(/\[VAR:$/)) {
|
||||
// Could be extended to track variable names from the document
|
||||
suggestions.push(
|
||||
{ label: 'hostname', kind: 5, insertText: 'hostname', range },
|
||||
{ label: 'username', kind: 5, insertText: 'username', range },
|
||||
{ label: 'ticket_number', kind: 5, insertText: 'ticket_number', range },
|
||||
{ label: 'client_name', kind: 5, insertText: 'client_name', range },
|
||||
)
|
||||
return { suggestions }
|
||||
}
|
||||
|
||||
return { suggestions }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { languages } from 'monaco-editor'
|
||||
|
||||
/**
|
||||
* Monarch tokenizer for ResolutionFlow tree markdown syntax.
|
||||
* Provides syntax highlighting for frontmatter, headings, options, references, and variables.
|
||||
*/
|
||||
export const resolutionFlowLanguage: languages.IMonarchLanguage = {
|
||||
tokenizer: {
|
||||
root: [
|
||||
// Frontmatter delimiter
|
||||
[/^---\s*$/, 'delimiter.frontmatter', '@frontmatter'],
|
||||
|
||||
// Headings
|
||||
[/^##\s+.*$/, 'heading.action'],
|
||||
[/^#\s+.*$/, 'heading.decision'],
|
||||
|
||||
// Blockquote (help text)
|
||||
[/^>\s.*$/, 'string.blockquote'],
|
||||
|
||||
// Option lines: - [X] Label → @target_id
|
||||
[/^-\s*\[[A-Za-z0-9]+\]/, 'keyword.option', '@optionLine'],
|
||||
|
||||
// Next node reference: → @node_id
|
||||
[/^→\s*@\S+/, 'variable.reference'],
|
||||
|
||||
// Expected outcome
|
||||
[/^\*\*Expected:\*\*\s*.*$/, 'keyword.expected'],
|
||||
|
||||
// Command block
|
||||
[/^```commands\s*$/, 'delimiter.commands', '@commandBlock'],
|
||||
[/^```\s*$/, 'delimiter.commands'],
|
||||
|
||||
// Variable tokens
|
||||
[/\[USER_INPUT:[^\]]+\]/, 'variable.input'],
|
||||
[/\[VAR:[^\]]+\]/, 'variable.reference'],
|
||||
[/\[SAVE_AS:[^\]]+\]/, 'variable.save'],
|
||||
|
||||
// Ordered list items
|
||||
[/^\d+\.\s+/, 'keyword.step'],
|
||||
|
||||
// Bold
|
||||
[/\*\*[^*]+\*\*/, 'strong'],
|
||||
|
||||
// Inline code
|
||||
[/`[^`]+`/, 'string.code'],
|
||||
|
||||
// Regular text
|
||||
[/./, 'text'],
|
||||
],
|
||||
|
||||
frontmatter: [
|
||||
[/^---\s*$/, 'delimiter.frontmatter', '@pop'],
|
||||
[/^(id)(:)(.*)$/, ['keyword.frontmatter', 'delimiter', 'string.id']],
|
||||
[/^(type)(:)(.*)$/, ['keyword.frontmatter', 'delimiter', 'type.value']],
|
||||
[/^(parent)(:)(.*)$/, ['keyword.frontmatter', 'delimiter', 'string.parent']],
|
||||
[/./, 'variable.other'],
|
||||
],
|
||||
|
||||
optionLine: [
|
||||
[/→\s*@\S+/, 'variable.reference'],
|
||||
[/$/, '', '@pop'],
|
||||
[/./, 'string.option'],
|
||||
],
|
||||
|
||||
commandBlock: [
|
||||
[/^```\s*$/, 'delimiter.commands', '@pop'],
|
||||
[/./, 'string.command'],
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const LANGUAGE_ID = 'resolutionflow'
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { editor } from 'monaco-editor'
|
||||
|
||||
/**
|
||||
* Dark theme for ResolutionFlow markdown syntax.
|
||||
* Matches the monochrome design system with functional color for node types.
|
||||
*/
|
||||
export const resolutionFlowTheme: editor.IStandaloneThemeData = {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
// Frontmatter
|
||||
{ token: 'delimiter.frontmatter', foreground: '6b7280' },
|
||||
{ token: 'keyword.frontmatter', foreground: '9ca3af' },
|
||||
{ token: 'string.id', foreground: 'e5e7eb', fontStyle: 'bold' },
|
||||
{ token: 'type.value', foreground: '60a5fa' },
|
||||
{ token: 'string.parent', foreground: 'a78bfa' },
|
||||
|
||||
// Headings
|
||||
{ token: 'heading.decision', foreground: '60a5fa', fontStyle: 'bold' },
|
||||
{ token: 'heading.action', foreground: 'fbbf24', fontStyle: 'bold' },
|
||||
|
||||
// Options
|
||||
{ token: 'keyword.option', foreground: '60a5fa' },
|
||||
{ token: 'string.option', foreground: 'e5e7eb' },
|
||||
|
||||
// References
|
||||
{ token: 'variable.reference', foreground: 'a78bfa' },
|
||||
|
||||
// Variables
|
||||
{ token: 'variable.input', foreground: 'fb923c', fontStyle: 'bold' },
|
||||
{ token: 'variable.save', foreground: 'fb923c' },
|
||||
|
||||
// Commands
|
||||
{ token: 'delimiter.commands', foreground: '6b7280' },
|
||||
{ token: 'string.command', foreground: '34d399' },
|
||||
|
||||
// Expected outcome
|
||||
{ token: 'keyword.expected', foreground: '34d399' },
|
||||
|
||||
// Ordered list
|
||||
{ token: 'keyword.step', foreground: '9ca3af' },
|
||||
|
||||
// Help text
|
||||
{ token: 'string.blockquote', foreground: '9ca3af', fontStyle: 'italic' },
|
||||
|
||||
// Formatting
|
||||
{ token: 'strong', fontStyle: 'bold' },
|
||||
{ token: 'string.code', foreground: '34d399' },
|
||||
|
||||
// Default
|
||||
{ token: 'text', foreground: 'd1d5db' },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#0a0a0a',
|
||||
'editor.foreground': '#d1d5db',
|
||||
'editor.lineHighlightBackground': '#ffffff08',
|
||||
'editor.selectionBackground': '#ffffff20',
|
||||
'editorCursor.foreground': '#ffffff',
|
||||
'editor.inactiveSelectionBackground': '#ffffff10',
|
||||
'editorLineNumber.foreground': '#4b5563',
|
||||
'editorLineNumber.activeForeground': '#9ca3af',
|
||||
'editorGutter.background': '#0a0a0a',
|
||||
'editorWidget.background': '#111111',
|
||||
'editorWidget.border': '#ffffff10',
|
||||
'editorSuggestWidget.background': '#111111',
|
||||
'editorSuggestWidget.border': '#ffffff10',
|
||||
'editorSuggestWidget.selectedBackground': '#ffffff15',
|
||||
},
|
||||
}
|
||||
|
||||
export const THEME_ID = 'resolutionflow-dark'
|
||||
Reference in New Issue
Block a user