Sfoglia il codice sorgente

fix(webui): scrollable fields, a real code editor, and a modal worth working in

- A field that hits its height cap now scrolls instead of hiding the rest of
  its content.
- Code fields use Monaco, the editor the Nodes page already uses: syntax
  highlighting, line numbers and a gutter, in the current theme.
- The node modal was 32rem wide whatever it held. It is 48rem now, 72rem with
  the data panel open, can be dragged to any size, and has a button to fill the
  window. The choice is remembered.
fszontagh 1 mese fa
parent
commit
cbbf637289

+ 4 - 8
webui/src/components/ExpressionInput.tsx

@@ -16,9 +16,6 @@ interface ExpressionInputProps {
   placeholder?: string
   availableFields?: AvailableField[]
   multiline?: boolean
-  // Set for fields that hold source code, which want a monospace face and room
-  // to work in whatever their current contents happen to look like.
-  codeMode?: boolean
   className?: string
 }
 
@@ -87,7 +84,6 @@ export function ExpressionInput({
   placeholder,
   availableFields = [],
   multiline = false,
-  codeMode = false,
   className = '',
 }: ExpressionInputProps) {
   const [showAutocomplete, setShowAutocomplete] = useState(false)
@@ -265,7 +261,7 @@ export function ExpressionInput({
 
   const kind = detectContentKind(value || '')
   const kindLabel = KIND_LABELS[kind]
-  const isCode = codeMode || kind === 'json' || kind === 'broken-json'
+  const isCode = kind === 'json' || kind === 'broken-json'
 
   // The field is always a textarea so it can grow with what is typed into it
   // without swapping the element under the cursor, which would lose focus and
@@ -274,9 +270,9 @@ export function ExpressionInput({
     const el = inputRef.current as HTMLTextAreaElement | null
     if (!el) return
     el.style.height = 'auto'
-    const minHeight = codeMode ? 200 : multiline ? 80 : 38
+    const minHeight = multiline ? 80 : 38
     el.style.height = Math.min(Math.max(el.scrollHeight, minHeight), 420) + 'px'
-  }, [value, multiline, codeMode])
+  }, [value, multiline])
 
   const formatJson = () => {
     try {
@@ -297,7 +293,7 @@ export function ExpressionInput({
             onSelect={handleSelect}
             onKeyDown={handleKeyDown}
             placeholder={placeholder || 'Enter value or use {{ }} for expressions'}
-            className={`w-full px-3 py-2 pr-10 border border-gray-200 dark:border-slate-600 rounded-l-lg text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-y overflow-hidden ${
+            className={`w-full px-3 py-2 pr-10 border border-gray-200 dark:border-slate-600 rounded-l-lg text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-y overflow-y-auto ${
               isCode ? 'font-mono text-xs' : ''
             }`}
             rows={1}

+ 57 - 5
webui/src/components/workflow/NodeConfigModal.tsx

@@ -1,5 +1,8 @@
-import { X, Database } from 'lucide-react'
+import { useEffect, useState } from 'react'
+import { X, Database, Maximize2, Minimize2 } from 'lucide-react'
 import { useQuery } from '@tanstack/react-query'
+import Editor from '@monaco-editor/react'
+import { useTheme } from '../../contexts/ThemeContext'
 import { NodeDefinition } from '../../api/workflows'
 import { credentialsApi, CredentialInfo } from '../../api/credentials'
 import { ConditionBuilder, Condition, AvailableField } from '../ConditionBuilder'
@@ -53,10 +56,30 @@ export function NodeConfigModal({
   })
 
   const credentials: CredentialInfo[] = credentialsData?.credentials || []
+  const { resolvedTheme } = useTheme()
+
+  // Editing a node is sometimes a one-line change and sometimes a hundred lines
+  // of script, so the size is the editor's choice and it is remembered.
+  const [maximized, setMaximized] = useState(
+    () => localStorage.getItem('nodeConfigMaximized') === 'true'
+  )
+
+  useEffect(() => {
+    localStorage.setItem('nodeConfigMaximized', String(maximized))
+  }, [maximized])
+
+  const sizeClasses = maximized
+    ? 'w-[96vw] h-[92vh] max-w-none'
+    : showDataPanel
+      ? 'w-full max-w-6xl max-h-[90vh]'
+      : 'w-full max-w-3xl max-h-[90vh]'
 
   return (
-    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
-      <div className={`bg-white dark:bg-slate-800 rounded-xl max-h-[90vh] overflow-hidden flex ${showDataPanel ? 'w-full max-w-4xl' : 'w-full max-w-lg'}`}>
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+      <div
+        className={`bg-white dark:bg-slate-800 rounded-xl overflow-hidden flex resize ${sizeClasses}`}
+        style={{ minWidth: '32rem', minHeight: '20rem' }}
+      >
         {/* Main configuration area */}
         <div className="flex-1 flex flex-col overflow-hidden">
           <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-slate-700">
@@ -74,6 +97,13 @@ export function NodeConfigModal({
               >
                 <Database className="w-5 h-5" />
               </button>
+              <button
+                onClick={() => setMaximized((current) => !current)}
+                className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-500 dark:text-gray-400"
+                title={maximized ? 'Restore size' : 'Fill the window'}
+              >
+                {maximized ? <Minimize2 className="w-5 h-5" /> : <Maximize2 className="w-5 h-5" />}
+              </button>
               <button
                 onClick={onClose}
                 className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded"
@@ -423,14 +453,36 @@ export function NodeConfigModal({
                           className="w-full mt-2 px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg font-mono text-xs bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
                         />
                       </div>
+                    ) : prop.format === 'code' ? (
+                      /* Source code is read as much as it is written, so it gets
+                         a real editor: highlighting, line numbers and a gutter. */
+                      <div className="border border-gray-200 dark:border-slate-600 rounded-lg overflow-hidden">
+                        <Editor
+                          height={maximized ? '60vh' : '360px'}
+                          language="javascript"
+                          theme={resolvedTheme === 'dark' ? 'vs-dark' : 'light'}
+                          value={editingConfig[key] ?? prop.default ?? ''}
+                          onChange={(value) =>
+                            onConfigChange({ ...editingConfig, [key]: value ?? '' })
+                          }
+                          options={{
+                            minimap: { enabled: false },
+                            fontSize: 13,
+                            wordWrap: 'on',
+                            scrollBeyondLastLine: false,
+                            automaticLayout: true,
+                            tabSize: 2,
+                            padding: { top: 8, bottom: 8 },
+                          }}
+                        />
+                      </div>
                     ) : (
                       <ExpressionInput
                         value={editingConfig[key] ?? prop.default ?? ''}
                         onChange={(value) =>
                           onConfigChange({ ...editingConfig, [key]: value })
                         }
-                        multiline={prop.format === 'code' || prop.format === 'textarea'}
-                        codeMode={prop.format === 'code'}
+                        multiline={prop.format === 'textarea'}
                         placeholder={prop.description}
                         availableFields={getUpstreamOutputFields(selectedNodeData.id).map((f) => ({
                           path: f.path,