- AI flow builder: scaffold → branch detail → assemble → review flow - Generate All one-click branch generation with stop/cancel - Regenerate scaffold suggestions button - 3-action review screen: Start Flow, Open in Editor, Build Another - Fix Publish button gated behind !isDirty - Fix visibility column enforcement in tree access filter - Add ?visibility filter and author_name to GET /trees - Dashboard tabbed flows: My Flows / My Team / Public / All - Create button in My Flows tab, window focus reload (stale data fix) - Fork UI with optional reason modal - Fix account_id nullability in User type and schema - Keep is_public and visibility in sync on updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
200 lines
6.5 KiB
TypeScript
200 lines
6.5 KiB
TypeScript
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'
|
|
import { setMonacoEditor } from './monacoEditorRef'
|
|
import { Spinner } from '@/components/common/Spinner'
|
|
|
|
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
|
|
setMonacoEditor(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 () => {
|
|
setMonacoEditor(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-card">
|
|
<Spinner size="sm" className="h-6 w-6 border-t-foreground" />
|
|
</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>
|
|
)
|
|
}
|