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:
@@ -10,3 +10,4 @@ export { default as stepsApi } from './steps'
|
||||
export { default as stepCategoriesApi } from './stepCategories'
|
||||
export { default as accountsApi } from './accounts'
|
||||
export { default as adminApi } from './admin'
|
||||
export { treeMarkdownApi } from './treeMarkdown'
|
||||
|
||||
22
frontend/src/api/treeMarkdown.ts
Normal file
22
frontend/src/api/treeMarkdown.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import api from '@/api/client'
|
||||
import type { TreeMarkdownValidation } from '@/types'
|
||||
|
||||
export const treeMarkdownApi = {
|
||||
/** Export a tree's JSONB structure as ResolutionFlow markdown */
|
||||
exportMarkdown: async (treeId: string): Promise<{ markdown: string }> => {
|
||||
const response = await api.get(`/trees/${treeId}/export-markdown`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Parse markdown and update a tree's JSONB structure */
|
||||
importMarkdown: async (treeId: string, markdown: string): Promise<TreeMarkdownValidation> => {
|
||||
const response = await api.put(`/trees/${treeId}/import-markdown`, { markdown })
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Validate markdown without saving */
|
||||
validateMarkdown: async (markdown: string): Promise<TreeMarkdownValidation> => {
|
||||
const response = await api.post('/trees/validate-markdown', { markdown })
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
73
frontend/src/components/session/VariablePromptModal.tsx
Normal file
73
frontend/src/components/session/VariablePromptModal.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface VariablePromptModalProps {
|
||||
/** The prompt text from [USER_INPUT:prompt] */
|
||||
prompt: string
|
||||
/** Called with the user's input value */
|
||||
onSubmit: (value: string) => void
|
||||
/** Called when user cancels */
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function VariablePromptModal({ prompt, onSubmit, onCancel }: VariablePromptModalProps) {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (value.trim()) {
|
||||
onSubmit(value.trim())
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<div className="glass-card w-full max-w-md rounded-2xl p-6 shadow-lg">
|
||||
<h2 className="mb-1 text-lg font-semibold text-white">Input Required</h2>
|
||||
<p className="mb-4 text-sm text-white/40">
|
||||
This step requires you to provide a value.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="mb-2 block text-sm font-medium text-white/70">
|
||||
{prompt}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Enter value..."
|
||||
autoFocus
|
||||
className={cn(
|
||||
'w-full rounded-lg border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
||||
'placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!value.trim()}
|
||||
className={cn(
|
||||
'flex-1 rounded-lg bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className={cn(
|
||||
'rounded-lg border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { TreeMetadataForm } from './TreeMetadataForm'
|
||||
import { NodeList } from './NodeList'
|
||||
import { TreePreviewPanel } from '@/components/tree-preview/TreePreviewPanel'
|
||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Lazy load CodeModeEditor (Monaco is ~2MB)
|
||||
const CodeModeEditor = lazy(() =>
|
||||
import('./code-mode/CodeModeEditor').then(m => ({ default: m.CodeModeEditor }))
|
||||
)
|
||||
|
||||
interface TreeEditorLayoutProps {
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
|
||||
const editorMode = useTreeEditorStore(s => s.editorMode)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -15,28 +24,52 @@ export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
|
||||
isMobile ? 'flex-col' : 'flex-row'
|
||||
)}
|
||||
>
|
||||
{/* Left Panel - Form Editor */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col overflow-y-auto border-white/[0.06]',
|
||||
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4 p-4">
|
||||
<TreeMetadataForm />
|
||||
<NodeList />
|
||||
</div>
|
||||
</div>
|
||||
{editorMode === 'code' ? (
|
||||
<>
|
||||
{/* Code Mode: Monaco editor (60%) + Preview (40%) */}
|
||||
<div className={cn(
|
||||
'flex flex-col overflow-hidden border-white/[0.06]',
|
||||
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
|
||||
)}>
|
||||
<Suspense fallback={
|
||||
<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>
|
||||
}>
|
||||
<CodeModeEditor />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Preview */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 overflow-hidden bg-white/[0.02]',
|
||||
isMobile ? 'hidden' : 'block'
|
||||
)}
|
||||
>
|
||||
<TreePreviewPanel />
|
||||
</div>
|
||||
{/* Right Panel - Preview */}
|
||||
<div className={cn(
|
||||
'flex-1 overflow-hidden bg-white/[0.02]',
|
||||
isMobile ? 'hidden' : 'block'
|
||||
)}>
|
||||
<TreePreviewPanel />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Flow Mode: Form editor (60%) + Preview (40%) */}
|
||||
<div className={cn(
|
||||
'flex flex-col overflow-y-auto border-white/[0.06]',
|
||||
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
|
||||
)}>
|
||||
<div className="space-y-4 p-4">
|
||||
<TreeMetadataForm />
|
||||
<NodeList />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Preview */}
|
||||
<div className={cn(
|
||||
'flex-1 overflow-hidden bg-white/[0.02]',
|
||||
isMobile ? 'hidden' : 'block'
|
||||
)}>
|
||||
<TreePreviewPanel />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
90
frontend/src/lib/treeMarkdownSync.ts
Normal file
90
frontend/src/lib/treeMarkdownSync.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { TreeStructure } from '@/types'
|
||||
|
||||
export interface TreeMetadata {
|
||||
name?: string
|
||||
description?: string
|
||||
category?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight frontend serializer for instant preview when switching Form→Code.
|
||||
* The backend remains authoritative for actual saves.
|
||||
*/
|
||||
export function treeStructureToMarkdownPreview(
|
||||
structure: TreeStructure,
|
||||
metadata?: TreeMetadata,
|
||||
): string {
|
||||
const blocks: string[] = []
|
||||
|
||||
if (metadata) {
|
||||
const fm = ['---']
|
||||
if (metadata.name) fm.push(`name: ${metadata.name}`)
|
||||
if (metadata.description) fm.push(`description: ${metadata.description}`)
|
||||
if (metadata.category) fm.push(`category: ${metadata.category}`)
|
||||
if (metadata.tags?.length) fm.push(`tags: [${metadata.tags.join(', ')}]`)
|
||||
fm.push('---')
|
||||
blocks.push(fm.join('\n'))
|
||||
}
|
||||
|
||||
serializeNode(structure, null, blocks)
|
||||
return blocks.join('\n\n') + '\n'
|
||||
}
|
||||
|
||||
function serializeNode(
|
||||
node: TreeStructure,
|
||||
parentId: string | null,
|
||||
blocks: string[],
|
||||
): void {
|
||||
const fm = ['---', `id: ${node.id}`, `type: ${node.type}`]
|
||||
if (parentId !== null) fm.push(`parent: ${parentId}`)
|
||||
fm.push('---')
|
||||
|
||||
const body: string[] = []
|
||||
|
||||
if (node.type === 'decision') {
|
||||
if (node.question) {
|
||||
body.push(`# ${node.question}`, '')
|
||||
}
|
||||
if (node.help_text) {
|
||||
for (const line of node.help_text.split('\n')) {
|
||||
body.push(`> ${line}`)
|
||||
}
|
||||
body.push('')
|
||||
}
|
||||
if (node.options) {
|
||||
node.options.forEach((opt, i) => {
|
||||
const letter = String.fromCharCode(65 + i)
|
||||
if (opt.next_node_id) {
|
||||
body.push(`- [${letter}] ${opt.label} → @${opt.next_node_id}`)
|
||||
} else {
|
||||
body.push(`- [${letter}] ${opt.label}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (node.type === 'action') {
|
||||
if (node.title) body.push(`## ${node.title}`, '')
|
||||
if (node.description) body.push(node.description, '')
|
||||
if (node.commands?.length) {
|
||||
body.push('```commands')
|
||||
node.commands.forEach(cmd => body.push(cmd))
|
||||
body.push('```', '')
|
||||
}
|
||||
if (node.expected_outcome) body.push(`**Expected:** ${node.expected_outcome}`, '')
|
||||
if (node.next_node_id) body.push(`→ @${node.next_node_id}`)
|
||||
} else if (node.type === 'solution') {
|
||||
if (node.title) body.push(`## ${node.title}`, '')
|
||||
if (node.description) body.push(node.description, '')
|
||||
if (node.resolution_steps?.length) {
|
||||
node.resolution_steps.forEach((step, i) => body.push(`${i + 1}. ${step}`))
|
||||
}
|
||||
}
|
||||
|
||||
blocks.push(fm.join('\n') + '\n' + body.join('\n'))
|
||||
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
serializeNode(child, node.id, blocks)
|
||||
}
|
||||
}
|
||||
}
|
||||
49
frontend/src/lib/variableResolver.ts
Normal file
49
frontend/src/lib/variableResolver.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Frontend-side variable substitution for display during navigation.
|
||||
*
|
||||
* - [VAR:name] → replaced with variables[name]
|
||||
* - [USER_INPUT:prompt] → replaced with variables[prompt]
|
||||
* - [SAVE_AS:name] → removed from display
|
||||
*/
|
||||
export function resolveVariables(text: string, variables: Record<string, string>): string {
|
||||
// Replace [VAR:name]
|
||||
let result = text.replace(/\[VAR:([^\]]+)\]/g, (_, name) => {
|
||||
const key = name.trim()
|
||||
return variables[key] ?? `[VAR:${key}]`
|
||||
})
|
||||
|
||||
// Replace [USER_INPUT:prompt]
|
||||
result = result.replace(/\[USER_INPUT:([^\]]+)\]/g, (_, prompt) => {
|
||||
const key = prompt.trim()
|
||||
return variables[key] ?? `[USER_INPUT:${key}]`
|
||||
})
|
||||
|
||||
// Remove [SAVE_AS:name]
|
||||
result = result.replace(/\[SAVE_AS:[^\]]+\]/g, '')
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all [USER_INPUT:prompt] tokens from text.
|
||||
* Returns array of prompt strings.
|
||||
*/
|
||||
export function extractUserInputPrompts(text: string): string[] {
|
||||
const prompts: string[] = []
|
||||
const re = /\[USER_INPUT:([^\]]+)\]/g
|
||||
let match
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const prompt = match[1].trim()
|
||||
if (!prompts.includes(prompt)) {
|
||||
prompts.push(prompt)
|
||||
}
|
||||
}
|
||||
return prompts
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains any variable tokens.
|
||||
*/
|
||||
export function hasVariableTokens(text: string): boolean {
|
||||
return /\[(USER_INPUT|VAR|SAVE_AS):[^\]]+\]/.test(text)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
|
||||
import { useStore } from 'zustand'
|
||||
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText } from 'lucide-react'
|
||||
import { treesApi } from '@/api'
|
||||
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList } from 'lucide-react'
|
||||
import { getMonacoEditor } from '@/components/tree-editor/code-mode'
|
||||
import { treesApi, treeMarkdownApi } from '@/api'
|
||||
import type { TreeCreate, TreeUpdate, TreeStatus } from '@/types'
|
||||
import { useTreeEditorStore, useTreeEditorTemporal } from '@/store/treeEditorStore'
|
||||
import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout'
|
||||
@@ -24,6 +25,7 @@ export function TreeEditorPage() {
|
||||
isLoading,
|
||||
isSaving,
|
||||
validationErrors,
|
||||
editorMode,
|
||||
initNewTree,
|
||||
loadTree,
|
||||
loadDraft,
|
||||
@@ -34,7 +36,8 @@ export function TreeEditorPage() {
|
||||
markSaved,
|
||||
setLoading,
|
||||
setSaving,
|
||||
selectNode
|
||||
selectNode,
|
||||
setEditorMode,
|
||||
} = useTreeEditorStore()
|
||||
|
||||
// Access undo/redo from temporal store
|
||||
@@ -61,22 +64,50 @@ export function TreeEditorPage() {
|
||||
isDirty && currentLocation.pathname !== nextLocation.pathname
|
||||
)
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
if (editorMode === 'code') {
|
||||
// In Code Mode, use Monaco's native undo (word-level, like VS Code)
|
||||
const editor = getMonacoEditor()
|
||||
if (editor) {
|
||||
editor.trigger('toolbar', 'undo', null)
|
||||
editor.focus()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (pastStates.length > 0) {
|
||||
undo()
|
||||
toast.info('Undone')
|
||||
}
|
||||
}, [editorMode, pastStates.length, undo])
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
if (editorMode === 'code') {
|
||||
// In Code Mode, use Monaco's native redo (word-level, like VS Code)
|
||||
const editor = getMonacoEditor()
|
||||
if (editor) {
|
||||
editor.trigger('toolbar', 'redo', null)
|
||||
editor.focus()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (futureStates.length > 0) {
|
||||
redo()
|
||||
toast.info('Redone')
|
||||
}
|
||||
}, [editorMode, futureStates.length, redo])
|
||||
|
||||
// Keyboard shortcuts for undo/redo/save
|
||||
useKeyboardShortcuts([
|
||||
{
|
||||
key: 'z',
|
||||
ctrl: true,
|
||||
handler: () => {
|
||||
if (pastStates.length > 0) undo()
|
||||
}
|
||||
handler: handleUndo
|
||||
},
|
||||
{
|
||||
key: 'z',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
handler: () => {
|
||||
if (futureStates.length > 0) redo()
|
||||
}
|
||||
handler: handleRedo
|
||||
},
|
||||
{
|
||||
key: 's',
|
||||
@@ -84,6 +115,14 @@ export function TreeEditorPage() {
|
||||
handler: () => {
|
||||
handleSave()
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'm',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
handler: () => {
|
||||
setEditorMode(editorMode === 'form' ? 'code' : 'form')
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
@@ -165,6 +204,29 @@ export function TreeEditorPage() {
|
||||
const handleSaveDraft = useCallback(async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
// In Code Mode, run fresh validation on current markdown before saving
|
||||
if (editorMode === 'code') {
|
||||
const { markdownSource, setMarkdownValidationResult } = useTreeEditorStore.getState()
|
||||
if (markdownSource) {
|
||||
const result = await treeMarkdownApi.validateMarkdown(markdownSource)
|
||||
setMarkdownValidationResult(result) // applies tree_structure + metadata to store
|
||||
if (!result.valid) {
|
||||
const errorCount = result.errors.filter(e => e.severity === 'error').length
|
||||
toast.error(`Cannot save: ${errorCount} markdown error${errorCount !== 1 ? 's' : ''} — fix them in the editor first`)
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check tree name is set (metadata may come from Code Mode markdown)
|
||||
const currentState = useTreeEditorStore.getState()
|
||||
if (!currentState.name.trim()) {
|
||||
toast.error('Tree name is required. Add a "name:" field in the metadata block or switch to Flow Mode.')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
const treeData = { ...getTreeForSave(), status: 'draft' as TreeStatus }
|
||||
if (isEditMode) {
|
||||
await treesApi.update(id!, treeData as TreeUpdate)
|
||||
@@ -174,31 +236,67 @@ export function TreeEditorPage() {
|
||||
} else {
|
||||
const newTree = await treesApi.create(treeData as TreeCreate)
|
||||
setTreeStatus('draft')
|
||||
// Mark saved BEFORE navigating to avoid triggering the blocker
|
||||
markSaved()
|
||||
toast.success('Draft created successfully')
|
||||
// Navigate to edit mode with the new ID
|
||||
navigate(`/trees/${newTree.id}/edit`, { replace: true })
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to save draft:', err)
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { status?: number; data?: { detail?: string | { message?: string; errors?: string[] } } } }
|
||||
if (axiosErr.response?.status === 422) {
|
||||
const detail = axiosErr.response.data?.detail
|
||||
if (typeof detail === 'object' && detail?.errors) {
|
||||
toast.error(`Validation failed: ${detail.errors.join(', ')}`)
|
||||
} else if (typeof detail === 'string') {
|
||||
toast.error(`Validation failed: ${detail}`)
|
||||
} else {
|
||||
toast.error('Tree has validation errors. Fix them before saving.')
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
toast.error('Failed to save draft. Please try again.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [isEditMode, id, getTreeForSave, markSaved, navigate])
|
||||
}, [isEditMode, id, editorMode, getTreeForSave, markSaved, navigate])
|
||||
|
||||
const handlePublish = useCallback(async () => {
|
||||
// Validate first
|
||||
const errors = validate()
|
||||
const hasErrors = errors.some(e => e.severity === 'error')
|
||||
if (hasErrors) {
|
||||
toast.error('Please fix validation errors before publishing')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
// In Code Mode, run fresh validation on current markdown before publishing
|
||||
if (editorMode === 'code') {
|
||||
const { markdownSource, setMarkdownValidationResult } = useTreeEditorStore.getState()
|
||||
if (markdownSource) {
|
||||
const result = await treeMarkdownApi.validateMarkdown(markdownSource)
|
||||
setMarkdownValidationResult(result)
|
||||
if (!result.valid) {
|
||||
const errorCount = result.errors.filter(e => e.severity === 'error').length
|
||||
toast.error(`Cannot publish: ${errorCount} markdown error${errorCount !== 1 ? 's' : ''} — fix them in the editor first`)
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check tree name is set
|
||||
const currentState = useTreeEditorStore.getState()
|
||||
if (!currentState.name.trim()) {
|
||||
toast.error('Tree name is required. Add a "name:" field in the metadata block or switch to Flow Mode.')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate tree structure
|
||||
const errors = validate()
|
||||
const hasErrors = errors.some(e => e.severity === 'error')
|
||||
if (hasErrors) {
|
||||
toast.error('Please fix validation errors before publishing')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
const treeData = { ...getTreeForSave(), status: 'published' as TreeStatus }
|
||||
if (isEditMode) {
|
||||
await treesApi.update(id!, treeData as TreeUpdate)
|
||||
@@ -208,10 +306,8 @@ export function TreeEditorPage() {
|
||||
} else {
|
||||
const newTree = await treesApi.create(treeData as TreeCreate)
|
||||
setTreeStatus('published')
|
||||
// Mark saved BEFORE navigating to avoid triggering the blocker
|
||||
markSaved()
|
||||
toast.success('Tree published successfully')
|
||||
// Navigate to edit mode with the new ID
|
||||
navigate(`/trees/${newTree.id}/edit`, { replace: true })
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -220,7 +316,7 @@ export function TreeEditorPage() {
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [isEditMode, id, validate, getTreeForSave, markSaved, navigate])
|
||||
}, [isEditMode, id, editorMode, validate, getTreeForSave, markSaved, navigate])
|
||||
|
||||
// Keep handleSave for backward compatibility (Ctrl+S shortcut)
|
||||
const handleSave = useCallback(async () => {
|
||||
@@ -371,17 +467,52 @@ export function TreeEditorPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mode Toggle */}
|
||||
<div className="flex items-center rounded-md border border-white/[0.06]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorMode('form')}
|
||||
title="Flow Mode — form-based editing"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-l-md px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
editorMode === 'form'
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-white/40 hover:bg-white/[0.06] hover:text-white/60'
|
||||
)}
|
||||
>
|
||||
<LayoutList className="h-3.5 w-3.5" />
|
||||
Flow
|
||||
</button>
|
||||
<div className="h-5 w-px bg-white/[0.06]" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorMode('code')}
|
||||
title="Code Mode — markdown editing (Ctrl+Shift+M)"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-r-md px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
editorMode === 'code'
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-white/40 hover:bg-white/[0.06] hover:text-white/60'
|
||||
)}
|
||||
>
|
||||
<Code2 className="h-3.5 w-3.5" />
|
||||
Code
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mx-1 h-6 w-px bg-white/[0.06]" />
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<div className="flex items-center rounded-md border border-white/[0.06]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => undo()}
|
||||
onClick={handleUndo}
|
||||
disabled={pastStates.length === 0}
|
||||
title={pastStates.length > 0 ? `Undo (Ctrl+Z) - ${pastStates.length} step${pastStates.length !== 1 ? 's' : ''} available` : 'Nothing to undo'}
|
||||
className={cn(
|
||||
'rounded-l-md p-2 transition-colors',
|
||||
pastStates.length > 0
|
||||
? 'text-white hover:bg-white/[0.06]'
|
||||
? 'text-white hover:bg-white/[0.06] active:bg-white/[0.12]'
|
||||
: 'text-white/20 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
@@ -390,13 +521,13 @@ export function TreeEditorPage() {
|
||||
<div className="h-6 w-px bg-white/[0.06]" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => redo()}
|
||||
onClick={handleRedo}
|
||||
disabled={futureStates.length === 0}
|
||||
title={futureStates.length > 0 ? `Redo (Ctrl+Shift+Z) - ${futureStates.length} step${futureStates.length !== 1 ? 's' : ''} available` : 'Nothing to redo'}
|
||||
className={cn(
|
||||
'rounded-r-md p-2 transition-colors',
|
||||
futureStates.length > 0
|
||||
? 'text-white hover:bg-white/[0.06]'
|
||||
? 'text-white hover:bg-white/[0.06] active:bg-white/[0.12]'
|
||||
: 'text-white/20 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
import { create } from 'zustand'
|
||||
import { temporal } from 'zundo'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import { immer } from 'zustand/middleware/immer'
|
||||
import type { Tree, TreeStructure, TreeCreate, TreeUpdate, NodeType } from '@/types'
|
||||
import type { Tree, TreeStructure, TreeCreate, TreeUpdate, NodeType, TreeMarkdownValidationError, TreeMarkdownValidation } from '@/types'
|
||||
import { treeStructureToMarkdownPreview } from '@/lib/treeMarkdownSync'
|
||||
|
||||
// Throttle helper: captures first call immediately, then throttles subsequent calls
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function throttle<T extends (...args: any[]) => void>(fn: T, ms: number): T {
|
||||
let lastCall = 0
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return ((...args: any[]) => {
|
||||
const now = Date.now()
|
||||
if (now - lastCall >= ms) {
|
||||
lastCall = now
|
||||
fn(...args)
|
||||
} else {
|
||||
// Schedule a trailing call to capture the final state
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
lastCall = Date.now()
|
||||
fn(...args)
|
||||
}, ms - (now - lastCall))
|
||||
}
|
||||
}) as T
|
||||
}
|
||||
|
||||
// Validation error interface
|
||||
export interface ValidationError {
|
||||
@@ -14,6 +38,71 @@ export interface ValidationError {
|
||||
// Draft storage key
|
||||
const DRAFT_STORAGE_KEY = 'tree-editor-draft'
|
||||
|
||||
// Check if a tree is effectively empty (just initialized, no real content)
|
||||
const isEmptyTree = (tree: TreeStructure | null): boolean => {
|
||||
if (!tree) return true
|
||||
if (tree.question && tree.question.trim()) return false
|
||||
if (tree.children && tree.children.length > 0) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// Starter template for new trees in Code Mode — all @refs are valid within the template
|
||||
const CODE_MODE_STARTER_TEMPLATE = `---
|
||||
name: My New Tree
|
||||
description: A troubleshooting decision tree
|
||||
---
|
||||
|
||||
---
|
||||
id: root
|
||||
type: decision
|
||||
---
|
||||
# What type of issue is the user experiencing?
|
||||
|
||||
> Select the category that best matches the reported problem
|
||||
|
||||
- [A] Option A \u2192 @option_a_action
|
||||
- [B] Option B \u2192 @option_b_action
|
||||
|
||||
---
|
||||
id: option_a_action
|
||||
type: action
|
||||
parent: root
|
||||
---
|
||||
## Investigate Option A
|
||||
|
||||
Describe the investigation steps here.
|
||||
|
||||
\`\`\`commands
|
||||
example-command --flag
|
||||
\`\`\`
|
||||
|
||||
**Expected:** Describe expected results here
|
||||
|
||||
\u2192 @resolution
|
||||
|
||||
---
|
||||
id: option_b_action
|
||||
type: action
|
||||
parent: root
|
||||
---
|
||||
## Investigate Option B
|
||||
|
||||
Describe the investigation steps here.
|
||||
|
||||
\u2192 @resolution
|
||||
|
||||
---
|
||||
id: resolution
|
||||
type: solution
|
||||
parent: root
|
||||
---
|
||||
## Resolution
|
||||
|
||||
1. Document findings
|
||||
2. Apply the fix
|
||||
3. Verify the issue is resolved
|
||||
`
|
||||
|
||||
// Helper to generate unique IDs
|
||||
const generateId = () => crypto.randomUUID()
|
||||
|
||||
@@ -114,6 +203,14 @@ interface TreeEditorState {
|
||||
isSaving: boolean
|
||||
validationErrors: ValidationError[]
|
||||
|
||||
// Code Mode state
|
||||
editorMode: 'form' | 'code'
|
||||
markdownSource: string | null
|
||||
markdownValidationErrors: TreeMarkdownValidationError[]
|
||||
isMarkdownValid: boolean
|
||||
isValidating: boolean
|
||||
lastValidTreeFromMarkdown: TreeStructure | null
|
||||
|
||||
// Auto-save state
|
||||
lastSavedAt: Date | null
|
||||
draftSavedAt: Date | null
|
||||
@@ -159,6 +256,13 @@ interface TreeEditorState {
|
||||
markSaved: () => void
|
||||
getTreeForSave: () => TreeCreate | TreeUpdate
|
||||
|
||||
// Actions - Code Mode
|
||||
setEditorMode: (mode: 'form' | 'code') => void
|
||||
setMarkdownSource: (markdown: string) => void
|
||||
setMarkdownValidationResult: (result: TreeMarkdownValidation) => void
|
||||
syncMarkdownToTree: () => void
|
||||
syncTreeToMarkdown: () => void
|
||||
|
||||
// Actions - State
|
||||
setLoading: (loading: boolean) => void
|
||||
setSaving: (saving: boolean) => void
|
||||
@@ -188,6 +292,12 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
validationErrors: [],
|
||||
editorMode: 'form',
|
||||
markdownSource: null,
|
||||
markdownValidationErrors: [],
|
||||
isMarkdownValid: true,
|
||||
isValidating: false,
|
||||
lastValidTreeFromMarkdown: null,
|
||||
lastSavedAt: null,
|
||||
draftSavedAt: null,
|
||||
hasDraft: false,
|
||||
@@ -216,6 +326,12 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
state.isLoading = false
|
||||
state.isSaving = false
|
||||
state.validationErrors = []
|
||||
state.editorMode = 'form'
|
||||
state.markdownSource = null
|
||||
state.markdownValidationErrors = []
|
||||
state.isMarkdownValid = true
|
||||
state.isValidating = false
|
||||
state.lastValidTreeFromMarkdown = null
|
||||
state.lastSavedAt = null
|
||||
state.draftSavedAt = null
|
||||
state.hasDraft = hasDraft
|
||||
@@ -292,6 +408,12 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
state.isLoading = false
|
||||
state.isSaving = false
|
||||
state.validationErrors = []
|
||||
state.editorMode = 'form'
|
||||
state.markdownSource = null
|
||||
state.markdownValidationErrors = []
|
||||
state.isMarkdownValid = true
|
||||
state.isValidating = false
|
||||
state.lastValidTreeFromMarkdown = null
|
||||
state.lastSavedAt = null
|
||||
state.draftSavedAt = null
|
||||
state.hasDraft = false
|
||||
@@ -773,6 +895,96 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
}
|
||||
},
|
||||
|
||||
// Code Mode actions
|
||||
setEditorMode: (mode: 'form' | 'code') => {
|
||||
const current = get()
|
||||
if (mode === current.editorMode) return
|
||||
|
||||
if (mode === 'code') {
|
||||
// Form → Code: generate markdown from tree structure (synchronous)
|
||||
const { treeStructure, name, description, category, tags } = current
|
||||
if (isEmptyTree(treeStructure) && !name.trim()) {
|
||||
// New empty tree: use starter template
|
||||
set((state) => {
|
||||
state.markdownSource = CODE_MODE_STARTER_TEMPLATE
|
||||
state.markdownValidationErrors = []
|
||||
state.isMarkdownValid = false // needs validation
|
||||
state.isValidating = false
|
||||
state.editorMode = mode
|
||||
})
|
||||
} else if (treeStructure) {
|
||||
const metadata = { name, description, category, tags }
|
||||
const md = treeStructureToMarkdownPreview(treeStructure, metadata)
|
||||
set((state) => {
|
||||
state.markdownSource = md
|
||||
state.markdownValidationErrors = []
|
||||
state.isMarkdownValid = true
|
||||
state.isValidating = false
|
||||
state.editorMode = mode
|
||||
})
|
||||
} else {
|
||||
set((state) => { state.editorMode = mode })
|
||||
}
|
||||
} else {
|
||||
// Code → Form: apply last valid parse to tree structure
|
||||
get().syncMarkdownToTree()
|
||||
set((state) => { state.editorMode = mode })
|
||||
}
|
||||
},
|
||||
|
||||
setMarkdownSource: (markdown: string) => {
|
||||
set((state) => {
|
||||
state.markdownSource = markdown
|
||||
state.isDirty = true
|
||||
state.isValidating = true
|
||||
})
|
||||
},
|
||||
|
||||
setMarkdownValidationResult: (result: TreeMarkdownValidation) => {
|
||||
set((state) => {
|
||||
state.markdownValidationErrors = result.errors
|
||||
state.isMarkdownValid = result.valid
|
||||
state.isValidating = false
|
||||
if (result.valid && result.tree_structure) {
|
||||
state.lastValidTreeFromMarkdown = result.tree_structure as TreeStructure
|
||||
// Live sync: apply valid parsed tree to treeStructure immediately
|
||||
state.treeStructure = result.tree_structure as TreeStructure
|
||||
}
|
||||
// Apply metadata from parsed markdown (bidirectional sync)
|
||||
if (result.metadata) {
|
||||
if (result.metadata.name !== undefined) state.name = result.metadata.name
|
||||
if (result.metadata.description !== undefined) state.description = result.metadata.description
|
||||
if (result.metadata.category !== undefined) state.category = result.metadata.category
|
||||
if (result.metadata.tags !== undefined) state.tags = result.metadata.tags
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
syncMarkdownToTree: () => {
|
||||
const { lastValidTreeFromMarkdown, isMarkdownValid } = get()
|
||||
if (isMarkdownValid && lastValidTreeFromMarkdown) {
|
||||
set((state) => {
|
||||
state.treeStructure = lastValidTreeFromMarkdown
|
||||
state.markdownSource = null
|
||||
state.isDirty = true
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
syncTreeToMarkdown: () => {
|
||||
const { treeStructure, name, description, category, tags } = get()
|
||||
if (treeStructure) {
|
||||
const metadata = { name, description, category, tags }
|
||||
const md = treeStructureToMarkdownPreview(treeStructure, metadata)
|
||||
set((state) => {
|
||||
state.markdownSource = md
|
||||
state.markdownValidationErrors = []
|
||||
state.isMarkdownValid = true
|
||||
state.isValidating = false
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
setLoading: (loading: boolean) => {
|
||||
set((state) => { state.isLoading = loading })
|
||||
},
|
||||
@@ -839,7 +1051,15 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
tags: state.tags,
|
||||
isPublic: state.isPublic,
|
||||
treeStructure: state.treeStructure
|
||||
})
|
||||
}),
|
||||
// Skip no-op entries where partialized fields haven't changed
|
||||
equality: (pastState, currentState) => shallow(pastState, currentState),
|
||||
// Throttle history captures: collapse rapid changes (typing, validation) into ~one entry per 3s
|
||||
// This makes Flow Mode undo revert meaningful chunks (whole field edits) instead of single characters
|
||||
handleSet: (handleSet) =>
|
||||
throttle<typeof handleSet>((state) => {
|
||||
handleSet(state)
|
||||
}, 3000),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { persist } from 'zustand/middleware'
|
||||
type ExportFormat = 'markdown' | 'text' | 'html' | 'psa'
|
||||
type TreeLibraryView = 'grid' | 'list' | 'table'
|
||||
type TreeSortBy = 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'
|
||||
type EditorMode = 'form' | 'code'
|
||||
|
||||
interface UserPreferencesState {
|
||||
defaultExportFormat: ExportFormat
|
||||
@@ -12,6 +13,8 @@ interface UserPreferencesState {
|
||||
setTreeLibraryView: (view: TreeLibraryView) => void
|
||||
treeLibrarySortBy: TreeSortBy
|
||||
setTreeLibrarySortBy: (sortBy: TreeSortBy) => void
|
||||
preferredEditorMode: EditorMode
|
||||
setPreferredEditorMode: (mode: EditorMode) => void
|
||||
}
|
||||
|
||||
export const useUserPreferencesStore = create<UserPreferencesState>()(
|
||||
@@ -23,6 +26,8 @@ export const useUserPreferencesStore = create<UserPreferencesState>()(
|
||||
setTreeLibraryView: (view) => set({ treeLibraryView: view }),
|
||||
treeLibrarySortBy: 'usage_count',
|
||||
setTreeLibrarySortBy: (sortBy) => set({ treeLibrarySortBy: sortBy }),
|
||||
preferredEditorMode: 'form',
|
||||
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),
|
||||
}),
|
||||
{
|
||||
name: 'user-preferences-storage',
|
||||
|
||||
@@ -185,3 +185,25 @@ export interface TreeValidationResponse {
|
||||
can_publish: boolean
|
||||
errors: ValidationError[]
|
||||
}
|
||||
|
||||
// Tree markdown types
|
||||
export interface TreeMarkdownValidationError {
|
||||
line: number
|
||||
column: number
|
||||
message: string
|
||||
severity: 'error' | 'warning'
|
||||
}
|
||||
|
||||
export interface TreeMarkdownMetadata {
|
||||
name?: string
|
||||
description?: string
|
||||
category?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface TreeMarkdownValidation {
|
||||
valid: boolean
|
||||
errors: TreeMarkdownValidationError[]
|
||||
tree_structure: TreeStructure | null
|
||||
metadata?: TreeMarkdownMetadata | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user