Files
resolutionflow/frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx
chihlasm ed4ab059bf feat: AI flow builder, visibility model, dashboard tabs, fork UI (#88)
- 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>
2026-02-24 07:40:44 -05:00

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>
)
}