diff --git a/frontends/nextjs/src/components/editors/lua/LuaBlocksBridge.tsx b/frontends/nextjs/src/components/editors/lua/LuaBlocksBridge.tsx new file mode 100644 index 000000000..2bd4076ab --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/LuaBlocksBridge.tsx @@ -0,0 +1,133 @@ +import { Badge } from '@/components/ui' +import { Button } from '@/components/ui' +import { Input } from '@/components/ui' +import { Label } from '@/components/ui' +import { Trash } from '@phosphor-icons/react' +import type { LuaExecutionResult } from '@/lib/lua-engine' +import type { LuaScript } from '@/lib/level-types' +import { LuaExecutionResultCard } from './LuaExecutionResultCard' +import { LuaContextInfo } from './LuaContextInfo' + +interface LuaBlocksBridgeProps { + currentScript: LuaScript + testInputs: Record + testOutput: LuaExecutionResult | null + onAddParameter: () => void + onDeleteParameter: (index: number) => void + onUpdateParameter: (index: number, updates: { name?: string; type?: string }) => void + onUpdateScript: (updates: Partial) => void + onUpdateTestInput: (name: string, value: any) => void +} + +export function LuaBlocksBridge({ + currentScript, + testInputs, + testOutput, + onAddParameter, + onDeleteParameter, + onUpdateParameter, + onUpdateScript, + onUpdateTestInput, +}: LuaBlocksBridgeProps) { + return ( +
+
+
+ + onUpdateScript({ name: event.target.value })} + placeholder="validate_user" + className="font-mono" + /> +
+
+ + onUpdateScript({ returnType: event.target.value })} + placeholder="table, boolean, string..." + /> +
+
+ +
+ + onUpdateScript({ description: event.target.value })} + placeholder="What this script does..." + /> +
+ +
+
+ + +
+
+ {currentScript.parameters.length === 0 ? ( +

No parameters defined

+ ) : ( + currentScript.parameters.map((param, index) => ( +
+ onUpdateParameter(index, { name: event.target.value })} + placeholder="paramName" + className="flex-1 font-mono text-sm" + /> + onUpdateParameter(index, { type: event.target.value })} + placeholder="string" + className="w-32 text-sm" + /> + +
+ )) + )} +
+
+ + {currentScript.parameters.length > 0 && ( +
+ +
+ {currentScript.parameters.map(param => ( +
+ + { + const value = + param.type === 'number' + ? parseFloat(event.target.value) || 0 + : param.type === 'boolean' + ? event.target.value === 'true' + : event.target.value + onUpdateTestInput(param.name, value) + }} + placeholder={`Enter ${param.type} value`} + className="flex-1 text-sm" + type={param.type === 'number' ? 'number' : 'text'} + /> + + {param.type} + +
+ ))} +
+
+ )} + + {testOutput && } + + +
+ ) +} diff --git a/frontends/nextjs/src/components/editors/lua/LuaCodeEditorView.tsx b/frontends/nextjs/src/components/editors/lua/LuaCodeEditorView.tsx new file mode 100644 index 000000000..63f94ab91 --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/LuaCodeEditorView.tsx @@ -0,0 +1,125 @@ +import Editor from '@monaco-editor/react' +import { ArrowsOut, BookOpen, FileCode } from '@phosphor-icons/react' +import { Button } from '@/components/ui' +import { Label } from '@/components/ui' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui' +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui' +import { LuaSnippetLibrary } from '@/components/editors/lua/LuaSnippetLibrary' +import { getLuaExampleCode, getLuaExamplesList } from '@/lib/lua-examples' +import type { LuaScript } from '@/lib/level-types' +import { toast } from 'sonner' + +interface LuaCodeEditorViewProps { + currentScript: LuaScript + isFullscreen: boolean + showSnippetLibrary: boolean + onSnippetLibraryChange: (value: boolean) => void + onInsertSnippet: (code: string) => void + onToggleFullscreen: () => void + onUpdateCode: (code: string) => void + editorRef: { current: any } +} + +export function LuaCodeEditorView({ + currentScript, + isFullscreen, + showSnippetLibrary, + onSnippetLibraryChange, + onInsertSnippet, + onToggleFullscreen, + onUpdateCode, + editorRef, +}: LuaCodeEditorViewProps) { + return ( +
+
+ +
+ + + + + + + Lua Snippet Library + Browse and insert pre-built code templates + +
+ +
+
+
+ + +
+
+
+ onUpdateCode(value || '')} + onMount={editor => { + editorRef.current = editor + }} + theme="vs-dark" + options={{ + minimap: { enabled: isFullscreen }, + fontSize: 14, + fontFamily: 'JetBrains Mono, monospace', + lineNumbers: 'on', + roundedSelection: true, + scrollBeyondLastLine: false, + automaticLayout: true, + tabSize: 2, + wordWrap: 'on', + quickSuggestions: true, + suggestOnTriggerCharacters: true, + acceptSuggestionOnEnter: 'on', + snippetSuggestions: 'inline', + parameterHints: { enabled: true }, + formatOnPaste: true, + formatOnType: true, + }} + /> +
+

+ Write Lua code. Access parameters via context.data. Use + log() or print() for output. Press + Ctrl+Space for autocomplete. +

+
+ ) +} diff --git a/frontends/nextjs/src/components/editors/lua/LuaContextInfo.tsx b/frontends/nextjs/src/components/editors/lua/LuaContextInfo.tsx new file mode 100644 index 000000000..573370116 --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/LuaContextInfo.tsx @@ -0,0 +1,23 @@ +export function LuaContextInfo() { + return ( +
+
+

Available in context:

+
    +
  • + context.data - Input data +
  • +
  • + context.user - Current user info +
  • +
  • + context.kv - Key-value storage +
  • +
  • + context.log(msg) - Logging function +
  • +
+
+
+ ) +} diff --git a/frontends/nextjs/src/components/editors/lua/LuaEditor.tsx b/frontends/nextjs/src/components/editors/lua/LuaEditor.tsx index 90909ce67..f8869f71a 100644 --- a/frontends/nextjs/src/components/editors/lua/LuaEditor.tsx +++ b/frontends/nextjs/src/components/editors/lua/LuaEditor.tsx @@ -1,28 +1,12 @@ -import { useState, useEffect, useRef } from 'react' -import { Button } from '@/components/ui' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' -import { Input } from '@/components/ui' -import { Label } from '@/components/ui' -import { Badge } from '@/components/ui' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui' -import { Plus, Trash, Play, CheckCircle, XCircle, FileCode, ArrowsOut, BookOpen, ShieldCheck } from '@phosphor-icons/react' -import { toast } from 'sonner' -import { executeLuaScriptWithProfile } from '@/lib/lua/execute-lua-script-with-profile' -import type { LuaExecutionResult } from '@/lib/lua-engine' -import { getLuaExampleCode, getLuaExamplesList } from '@/lib/lua-examples' -import type { LuaScript } from '@/lib/level-types' -import Editor from '@monaco-editor/react' -import { useMonaco } from '@monaco-editor/react' -import { LuaSnippetLibrary } from '@/components/editors/lua/LuaSnippetLibrary' -import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui' -import { securityScanner, type SecurityScanResult } from '@/lib/security-scanner' +import { Card, CardContent } from '@/components/ui' import { SecurityWarningDialog } from '@/components/organisms/security/SecurityWarningDialog' +import { LuaEditorToolbar } from './LuaEditorToolbar' +import { LuaCodeEditorView } from './LuaCodeEditorView' +import { LuaBlocksBridge } from './LuaBlocksBridge' +import { LuaScriptsSidebar } from './LuaScriptsSidebar' +import { useLuaEditorState } from './state/useLuaEditorState' +import { useLuaEditorPersistence } from './persistence/useLuaEditorPersistence' +import type { LuaScript } from '@/lib/level-types' interface LuaEditorProps { scripts: LuaScript[] @@ -30,365 +14,26 @@ interface LuaEditorProps { } export function LuaEditor({ scripts, onScriptsChange }: LuaEditorProps) { - const [selectedScript, setSelectedScript] = useState( - scripts.length > 0 ? scripts[0].id : null - ) - const [testOutput, setTestOutput] = useState(null) - const [testInputs, setTestInputs] = useState>({}) - const [isExecuting, setIsExecuting] = useState(false) - const [isFullscreen, setIsFullscreen] = useState(false) - const [showSnippetLibrary, setShowSnippetLibrary] = useState(false) - const [securityScanResult, setSecurityScanResult] = useState(null) - const [showSecurityDialog, setShowSecurityDialog] = useState(false) - const editorRef = useRef(null) - const monaco = useMonaco() + const state = useLuaEditorState({ scripts, onScriptsChange }) - const currentScript = scripts.find(s => s.id === selectedScript) - - useEffect(() => { - if (monaco) { - monaco.languages.registerCompletionItemProvider('lua', { - provideCompletionItems: (model, position) => { - const word = model.getWordUntilPosition(position) - const range = { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: word.startColumn, - endColumn: word.endColumn - } - - const suggestions: any[] = [ - { - label: 'context.data', - kind: monaco.languages.CompletionItemKind.Property, - insertText: 'context.data', - documentation: 'Access input parameters passed to the script', - range - }, - { - label: 'context.user', - kind: monaco.languages.CompletionItemKind.Property, - insertText: 'context.user', - documentation: 'Current user information (username, role, etc.)', - range - }, - { - label: 'context.kv', - kind: monaco.languages.CompletionItemKind.Property, - insertText: 'context.kv', - documentation: 'Key-value storage interface', - range - }, - { - label: 'context.log', - kind: monaco.languages.CompletionItemKind.Function, - insertText: 'context.log(${1:message})', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Log a message to the output console', - range - }, - { - label: 'log', - kind: monaco.languages.CompletionItemKind.Function, - insertText: 'log(${1:message})', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Log a message (shortcut for context.log)', - range - }, - { - label: 'print', - kind: monaco.languages.CompletionItemKind.Function, - insertText: 'print(${1:message})', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Print a message to output', - range - }, - { - label: 'return', - kind: monaco.languages.CompletionItemKind.Keyword, - insertText: 'return ${1:result}', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Return a value from the script', - range - }, - ] - - return { suggestions } - } - }) - - monaco.languages.setLanguageConfiguration('lua', { - comments: { - lineComment: '--', - blockComment: ['--[[', ']]'] - }, - brackets: [ - ['{', '}'], - ['[', ']'], - ['(', ')'] - ], - autoClosingPairs: [ - { open: '{', close: '}' }, - { open: '[', close: ']' }, - { open: '(', close: ')' }, - { open: '"', close: '"' }, - { open: "'", close: "'" } - ] - }) - } - }, [monaco]) - - useEffect(() => { - if (currentScript) { - const inputs: Record = {} - currentScript.parameters.forEach((param) => { - inputs[param.name] = param.type === 'number' ? 0 : param.type === 'boolean' ? false : '' - }) - setTestInputs(inputs) - } - }, [selectedScript, currentScript?.parameters.length]) - - const handleAddScript = () => { - const newScript: LuaScript = { - id: `lua_${Date.now()}`, - name: 'New Script', - code: '-- Lua script example\n-- Access input parameters via context.data\n-- Use log() or print() to output messages\n\nlog("Script started")\n\nif context.data then\n log("Received data:", context.data)\nend\n\nlocal result = {\n success = true,\n message = "Script executed successfully"\n}\n\nreturn result', - parameters: [], - } - onScriptsChange([...scripts, newScript]) - setSelectedScript(newScript.id) - toast.success('Script created') - } - - const handleDeleteScript = (scriptId: string) => { - onScriptsChange(scripts.filter(s => s.id !== scriptId)) - if (selectedScript === scriptId) { - setSelectedScript(scripts.length > 1 ? scripts[0].id : null) - } - toast.success('Script deleted') - } - - const handleUpdateScript = (updates: Partial) => { - if (!currentScript) return - - onScriptsChange( - scripts.map(s => s.id === selectedScript ? { ...s, ...updates } : s) - ) - } - - const handleTestScript = async () => { - if (!currentScript) return - - const scanResult = securityScanner.scanLua(currentScript.code) - setSecurityScanResult(scanResult) - - if (scanResult.severity === 'critical' || scanResult.severity === 'high') { - setShowSecurityDialog(true) - toast.warning('Security issues detected in script') - return - } - - if (scanResult.severity === 'medium' && scanResult.issues.length > 0) { - toast.warning(`${scanResult.issues.length} security warning(s) detected`) - } - - setIsExecuting(true) - setTestOutput(null) - - try { - const contextData: any = {} - currentScript.parameters.forEach((param) => { - contextData[param.name] = testInputs[param.name] - }) - - const result = await executeLuaScriptWithProfile(currentScript.code, { - data: contextData, - user: { username: 'test_user', role: 'god' }, - log: (...args: any[]) => console.log('[Lua]', ...args) - }, currentScript) - - setTestOutput(result) - - if (result.success) { - toast.success('Script executed successfully') - } else { - toast.error('Script execution failed') - } - - } catch (error) { - toast.error('Execution error: ' + (error instanceof Error ? error.message : String(error))) - setTestOutput({ - success: false, - error: error instanceof Error ? error.message : String(error), - logs: [] - }) - } finally { - setIsExecuting(false) - } - } - - const handleScanCode = () => { - if (!currentScript) return - - const scanResult = securityScanner.scanLua(currentScript.code) - setSecurityScanResult(scanResult) - setShowSecurityDialog(true) - - if (scanResult.safe) { - toast.success('No security issues detected') - } else { - toast.warning(`${scanResult.issues.length} security issue(s) detected`) - } - } - - const handleProceedWithExecution = () => { - setShowSecurityDialog(false) - if (!currentScript) return - - setIsExecuting(true) - setTestOutput(null) - - setTimeout(async () => { - try { - const contextData: any = {} - currentScript.parameters.forEach((param) => { - contextData[param.name] = testInputs[param.name] - }) - - const result = await executeLuaScriptWithProfile(currentScript.code, { - data: contextData, - user: { username: 'test_user', role: 'god' }, - log: (...args: any[]) => console.log('[Lua]', ...args) - }, currentScript) - - setTestOutput(result) - - if (result.success) { - toast.success('Script executed successfully') - } else { - toast.error('Script execution failed') - } - - } catch (error) { - toast.error('Execution error: ' + (error instanceof Error ? error.message : String(error))) - setTestOutput({ - success: false, - error: error instanceof Error ? error.message : String(error), - logs: [] - }) - } finally { - setIsExecuting(false) - } - }, 100) - } - - const handleAddParameter = () => { - if (!currentScript) return - - const newParam = { name: `param${currentScript.parameters.length + 1}`, type: 'string' } - handleUpdateScript({ - parameters: [...currentScript.parameters, newParam], - }) - } - - const handleDeleteParameter = (index: number) => { - if (!currentScript) return - - handleUpdateScript({ - parameters: currentScript.parameters.filter((_, i) => i !== index), - }) - } - - const handleUpdateParameter = (index: number, updates: { name?: string; type?: string }) => { - if (!currentScript) return - - handleUpdateScript({ - parameters: currentScript.parameters.map((p, i) => - i === index ? { ...p, ...updates } : p - ), - }) - } - - const handleInsertSnippet = (code: string) => { - if (!currentScript) return - - if (editorRef.current) { - const selection = editorRef.current.getSelection() - if (selection) { - editorRef.current.executeEdits('', [{ - range: selection, - text: code, - forceMoveMarkers: true - }]) - editorRef.current.focus() - } else { - const currentCode = currentScript.code - const newCode = currentCode ? currentCode + '\n\n' + code : code - handleUpdateScript({ code: newCode }) - } - } else { - const currentCode = currentScript.code - const newCode = currentCode ? currentCode + '\n\n' + code : code - handleUpdateScript({ code: newCode }) - } - - setShowSnippetLibrary(false) - } + useLuaEditorPersistence({ + monaco: state.monaco, + currentScript: state.currentScript, + setTestInputs: state.setTestInputs, + }) return (
- - -
- Lua Scripts - -
- Custom logic scripts -
- -
- {scripts.length === 0 ? ( -

- No scripts yet. Create one to start. -

- ) : ( - scripts.map((script) => ( -
setSelectedScript(script.id)} - > -
-
{script.name}
-
- {script.parameters.length} params -
-
- -
- )) - )} -
-
-
+ - {!currentScript ? ( + {!state.currentScript ? (

Select or create a script to edit

@@ -396,282 +41,46 @@ export function LuaEditor({ scripts, onScriptsChange }: LuaEditorProps) { ) : ( <> - -
-
- Edit Script: {currentScript.name} - Write custom Lua logic -
-
- - -
-
-
+ -
-
- - handleUpdateScript({ name: e.target.value })} - placeholder="validate_user" - className="font-mono" - /> -
-
- - handleUpdateScript({ returnType: e.target.value })} - placeholder="table, boolean, string..." - /> -
-
+ -
- - handleUpdateScript({ description: e.target.value })} - placeholder="What this script does..." - /> -
- -
-
- - -
-
- {currentScript.parameters.length === 0 ? ( -

- No parameters defined -

- ) : ( - currentScript.parameters.map((param, index) => ( -
- handleUpdateParameter(index, { name: e.target.value })} - placeholder="paramName" - className="flex-1 font-mono text-sm" - /> - handleUpdateParameter(index, { type: e.target.value })} - placeholder="string" - className="w-32 text-sm" - /> - -
- )) - )} -
-
- - {currentScript.parameters.length > 0 && ( -
- -
- {currentScript.parameters.map((param) => ( -
- - { - const value = param.type === 'number' - ? parseFloat(e.target.value) || 0 - : param.type === 'boolean' - ? e.target.value === 'true' - : e.target.value - setTestInputs({ ...testInputs, [param.name]: value }) - }} - placeholder={`Enter ${param.type} value`} - className="flex-1 text-sm" - type={param.type === 'number' ? 'number' : 'text'} - /> - - {param.type} - -
- ))} -
-
- )} - -
-
- -
- - - - - - - Lua Snippet Library - - Browse and insert pre-built code templates - - -
- -
-
-
- - -
-
-
- handleUpdateScript({ code: value || '' })} - onMount={(editor) => { - editorRef.current = editor - }} - theme="vs-dark" - options={{ - minimap: { enabled: isFullscreen }, - fontSize: 14, - fontFamily: 'JetBrains Mono, monospace', - lineNumbers: 'on', - roundedSelection: true, - scrollBeyondLastLine: false, - automaticLayout: true, - tabSize: 2, - wordWrap: 'on', - quickSuggestions: true, - suggestOnTriggerCharacters: true, - acceptSuggestionOnEnter: 'on', - snippetSuggestions: 'inline', - parameterHints: { enabled: true }, - formatOnPaste: true, - formatOnType: true, - }} - /> -
-

- Write Lua code. Access parameters via context.data. Use log() or print() for output. Press Ctrl+Space for autocomplete. -

-
- - {testOutput && ( - - -
- {testOutput.success ? ( - - ) : ( - - )} - - {testOutput.success ? 'Execution Successful' : 'Execution Failed'} - -
-
- - {testOutput.error && ( -
- -
-                          {testOutput.error}
-                        
-
- )} - - {testOutput.logs.length > 0 && ( -
- -
-                          {testOutput.logs.join('\n')}
-                        
-
- )} - - {testOutput.result !== null && testOutput.result !== undefined && ( -
- -
-                          {JSON.stringify(testOutput.result, null, 2)}
-                        
-
- )} -
-
- )} - -
-
-

Available in context:

-
    -
  • context.data - Input data
  • -
  • context.user - Current user info
  • -
  • context.kv - Key-value storage
  • -
  • context.log(msg) - Logging function
  • -
-
-
+ state.handleUpdateScript({ code })} + editorRef={state.editorRef} + />
)} - {securityScanResult && ( + {state.securityScanResult && ( setShowSecurityDialog(false)} + open={state.showSecurityDialog} + onOpenChange={state.setShowSecurityDialog} + scanResult={state.securityScanResult} + onProceed={state.handleProceedWithExecution} + onCancel={() => state.setShowSecurityDialog(false)} codeType="Lua script" showProceedButton={true} /> diff --git a/frontends/nextjs/src/components/editors/lua/LuaEditorToolbar.tsx b/frontends/nextjs/src/components/editors/lua/LuaEditorToolbar.tsx new file mode 100644 index 000000000..690273a8a --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/LuaEditorToolbar.tsx @@ -0,0 +1,33 @@ +import { Button } from '@/components/ui' +import { CardDescription, CardHeader, CardTitle } from '@/components/ui' +import { Play, ShieldCheck } from '@phosphor-icons/react' + +interface LuaEditorToolbarProps { + scriptName: string + onScan: () => void + onTest: () => void + isExecuting: boolean +} + +export function LuaEditorToolbar({ scriptName, onScan, onTest, isExecuting }: LuaEditorToolbarProps) { + return ( + +
+
+ Edit Script: {scriptName} + Write custom Lua logic +
+
+ + +
+
+
+ ) +} diff --git a/frontends/nextjs/src/components/editors/lua/LuaExecutionResultCard.tsx b/frontends/nextjs/src/components/editors/lua/LuaExecutionResultCard.tsx new file mode 100644 index 000000000..2ad92d791 --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/LuaExecutionResultCard.tsx @@ -0,0 +1,51 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui' +import { Label } from '@/components/ui' +import { CheckCircle, XCircle } from '@phosphor-icons/react' +import type { LuaExecutionResult } from '@/lib/lua-engine' + +interface LuaExecutionResultCardProps { + result: LuaExecutionResult +} + +export function LuaExecutionResultCard({ result }: LuaExecutionResultCardProps) { + return ( + + +
+ {result.success ? ( + + ) : ( + + )} + + {result.success ? 'Execution Successful' : 'Execution Failed'} + +
+
+ + {result.error && ( +
+ +
{result.error}
+
+ )} + + {result.logs.length > 0 && ( +
+ +
{result.logs.join('\n')}
+
+ )} + + {result.result !== null && result.result !== undefined && ( +
+ +
+              {JSON.stringify(result.result, null, 2)}
+            
+
+ )} +
+
+ ) +} diff --git a/frontends/nextjs/src/components/editors/lua/LuaScriptsSidebar.tsx b/frontends/nextjs/src/components/editors/lua/LuaScriptsSidebar.tsx new file mode 100644 index 000000000..6c9f4389e --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/LuaScriptsSidebar.tsx @@ -0,0 +1,60 @@ +import { Button } from '@/components/ui' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' +import { Plus, Trash } from '@phosphor-icons/react' +import type { LuaScript } from '@/lib/level-types' + +interface LuaScriptsSidebarProps { + scripts: LuaScript[] + selectedScript: string | null + onSelect: (scriptId: string) => void + onAdd: () => void + onDelete: (scriptId: string) => void +} + +export function LuaScriptsSidebar({ scripts, selectedScript, onSelect, onAdd, onDelete }: LuaScriptsSidebarProps) { + return ( + + +
+ Lua Scripts + +
+ Custom logic scripts +
+ +
+ {scripts.length === 0 ? ( +

No scripts yet. Create one to start.

+ ) : ( + scripts.map(script => ( +
onSelect(script.id)} + > +
+
{script.name}
+
{script.parameters.length} params
+
+ +
+ )) + )} +
+
+
+ ) +} diff --git a/frontends/nextjs/src/components/editors/lua/handlers/executionHandlers.ts b/frontends/nextjs/src/components/editors/lua/handlers/executionHandlers.ts new file mode 100644 index 000000000..6cf1d955a --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/handlers/executionHandlers.ts @@ -0,0 +1,120 @@ +import { toast } from 'sonner' +import { executeLuaScriptWithProfile } from '@/lib/lua/execute-lua-script-with-profile' +import type { LuaScript } from '@/lib/level-types' +import type { LuaExecutionResult } from '@/lib/lua-engine' +import { securityScanner } from '@/lib/security-scanner' + +interface ScriptGetter { + getCurrentScript: () => LuaScript | null + getTestInputs: () => Record +} + +interface ExecutionState { + setIsExecuting: (value: boolean) => void + setTestOutput: (value: LuaExecutionResult | null) => void + setSecurityScanResult: (result: any) => void + setShowSecurityDialog: (value: boolean) => void +} + +export const createTestScript = ({ + getCurrentScript, + getTestInputs, + setIsExecuting, + setTestOutput, + setSecurityScanResult, + setShowSecurityDialog, +}: ScriptGetter & ExecutionState) => async () => { + const currentScript = getCurrentScript() + if (!currentScript) return + + const scanResult = securityScanner.scanLua(currentScript.code) + setSecurityScanResult(scanResult) + + if (scanResult.severity === 'critical' || scanResult.severity === 'high') { + setShowSecurityDialog(true) + toast.warning('Security issues detected in script') + return + } + + if (scanResult.severity === 'medium' && scanResult.issues.length > 0) { + toast.warning(`${scanResult.issues.length} security warning(s) detected`) + } + + await executeScript({ currentScript, getTestInputs, setIsExecuting, setTestOutput }) +} + +export const createScanCode = ({ + getCurrentScript, + setSecurityScanResult, + setShowSecurityDialog, +}: Omit & ScriptGetter) => () => { + const currentScript = getCurrentScript() + if (!currentScript) return + + const scanResult = securityScanner.scanLua(currentScript.code) + setSecurityScanResult(scanResult) + setShowSecurityDialog(true) + + if (scanResult.safe) { + toast.success('No security issues detected') + } else { + toast.warning(`${scanResult.issues.length} security issue(s) detected`) + } +} + +export const createProceedExecution = ({ + getCurrentScript, + getTestInputs, + setIsExecuting, + setTestOutput, + setShowSecurityDialog, +}: ScriptGetter & Omit) => () => { + setShowSecurityDialog(false) + const currentScript = getCurrentScript() + if (!currentScript) return + + setTimeout(() => executeScript({ currentScript, getTestInputs, setIsExecuting, setTestOutput }), 100) +} + +const executeScript = async ({ + currentScript, + getTestInputs, + setIsExecuting, + setTestOutput, +}: { + currentScript: LuaScript + getTestInputs: () => Record + setIsExecuting: (value: boolean) => void + setTestOutput: (value: LuaExecutionResult | null) => void +}) => { + setIsExecuting(true) + setTestOutput(null) + + try { + const contextData: Record = {} + currentScript.parameters.forEach(param => { + contextData[param.name] = getTestInputs()[param.name] + }) + + const result = await executeLuaScriptWithProfile( + currentScript.code, + { + data: contextData, + user: { username: 'test_user', role: 'god' }, + log: (...args: any[]) => console.log('[Lua]', ...args), + }, + currentScript + ) + + setTestOutput(result) + toast[result.success ? 'success' : 'error']( + result.success ? 'Script executed successfully' : 'Script execution failed' + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + toast.error(`Execution error: ${message}`) + setTestOutput({ success: false, error: message, logs: [] }) + } finally { + setIsExecuting(false) + } +} diff --git a/frontends/nextjs/src/components/editors/lua/handlers/parameterHandlers.ts b/frontends/nextjs/src/components/editors/lua/handlers/parameterHandlers.ts new file mode 100644 index 000000000..e1bf3cda9 --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/handlers/parameterHandlers.ts @@ -0,0 +1,52 @@ +import type { LuaScript } from '@/lib/level-types' + +interface ParameterHandlerProps { + currentScript: LuaScript | null + handleUpdateScript: (updates: Partial) => void +} + +interface TestInputHandlerProps { + getTestInputs: () => Record + setTestInputs: (value: Record) => void +} + +export const createAddParameter = ({ currentScript, handleUpdateScript }: ParameterHandlerProps) => () => { + if (!currentScript) return + + const newParam = { + name: `param${currentScript.parameters.length + 1}`, + type: 'string', + } + + handleUpdateScript({ parameters: [...currentScript.parameters, newParam] }) +} + +export const createDeleteParameter = ({ currentScript, handleUpdateScript }: ParameterHandlerProps) => ( + index: number +) => { + if (!currentScript) return + + handleUpdateScript({ + parameters: currentScript.parameters.filter((_, i) => i !== index), + }) +} + +export const createUpdateParameter = ({ currentScript, handleUpdateScript }: ParameterHandlerProps) => ( + index: number, + updates: { name?: string; type?: string } +) => { + if (!currentScript) return + + handleUpdateScript({ + parameters: currentScript.parameters.map((param, i) => + i === index ? { ...param, ...updates } : param + ), + }) +} + +export const createUpdateTestInput = ({ getTestInputs, setTestInputs }: TestInputHandlerProps) => ( + name: string, + value: any +) => { + setTestInputs({ ...getTestInputs(), [name]: value }) +} diff --git a/frontends/nextjs/src/components/editors/lua/handlers/scriptHandlers.ts b/frontends/nextjs/src/components/editors/lua/handlers/scriptHandlers.ts new file mode 100644 index 000000000..ed128c36b --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/handlers/scriptHandlers.ts @@ -0,0 +1,64 @@ +import { toast } from 'sonner' +import type { Dispatch, SetStateAction } from 'react' +import type { LuaScript } from '@/lib/level-types' + +const defaultCode = `-- Lua script example +-- Access input parameters via context.data +-- Use log() or print() to output messages + +log("Script started") + +if context.data then + log("Received data:", context.data) +end + +local result = { + success = true, + message = "Script executed successfully" +} + +return result` + +interface UpdateProps { + scripts: LuaScript[] + onScriptsChange: (scripts: LuaScript[]) => void + selectedScript: string | null +} + +interface ScriptCrudProps extends UpdateProps { + setSelectedScript: Dispatch> +} + +export const createAddScript = ({ scripts, onScriptsChange, setSelectedScript }: ScriptCrudProps) => () => { + const newScript: LuaScript = { + id: `lua_${Date.now()}`, + name: 'New Script', + code: defaultCode, + parameters: [], + } + onScriptsChange([...scripts, newScript]) + setSelectedScript(newScript.id) + toast.success('Script created') +} + +export const createDeleteScript = ({ + scripts, + onScriptsChange, + selectedScript, + setSelectedScript, +}: ScriptCrudProps) => (scriptId: string) => { + onScriptsChange(scripts.filter(script => script.id !== scriptId)) + if (selectedScript === scriptId) { + setSelectedScript(scripts.length > 1 ? scripts[0]?.id ?? null : null) + } + toast.success('Script deleted') +} + +export const createUpdateScript = ({ scripts, onScriptsChange, selectedScript }: UpdateProps) => ( + updates: Partial +) => { + if (!selectedScript) return + onScriptsChange( + scripts.map(script => (script.id === selectedScript ? { ...script, ...updates } : script)) + ) +} diff --git a/frontends/nextjs/src/components/editors/lua/handlers/snippetHandlers.ts b/frontends/nextjs/src/components/editors/lua/handlers/snippetHandlers.ts new file mode 100644 index 000000000..dabe358be --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/handlers/snippetHandlers.ts @@ -0,0 +1,49 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import type { LuaScript } from '@/lib/level-types' + +interface SnippetProps { + currentScript: LuaScript | null + handleUpdateScript: (updates: Partial) => void + editorRef: MutableRefObject + setShowSnippetLibrary: Dispatch> +} + +interface FullscreenProps { + isFullscreen: boolean + setIsFullscreen: Dispatch> +} + +export const createInsertSnippet = ({ + currentScript, + handleUpdateScript, + editorRef, + setShowSnippetLibrary, +}: SnippetProps) => (code: string) => { + if (!currentScript) return + + if (editorRef.current) { + const selection = editorRef.current.getSelection() + if (selection) { + editorRef.current.executeEdits('', [ + { + range: selection, + text: code, + forceMoveMarkers: true, + }, + ]) + editorRef.current.focus() + } else { + const newCode = currentScript.code ? `${currentScript.code}\n\n${code}` : code + handleUpdateScript({ code: newCode }) + } + } else { + const newCode = currentScript.code ? `${currentScript.code}\n\n${code}` : code + handleUpdateScript({ code: newCode }) + } + + setShowSnippetLibrary(false) +} + +export const createToggleFullscreen = ({ isFullscreen, setIsFullscreen }: FullscreenProps) => () => { + setIsFullscreen(!isFullscreen) +} diff --git a/frontends/nextjs/src/components/editors/lua/persistence/useLuaEditorPersistence.ts b/frontends/nextjs/src/components/editors/lua/persistence/useLuaEditorPersistence.ts new file mode 100644 index 000000000..4b89a327b --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/persistence/useLuaEditorPersistence.ts @@ -0,0 +1,119 @@ +import { useEffect } from 'react' +import type { languages } from 'monaco-editor' +import type { LuaScript } from '@/lib/level-types' + +interface UsePersistenceProps { + monaco: typeof import('monaco-editor') | null + currentScript: LuaScript | null + setTestInputs: (value: Record) => void +} + +export function useLuaEditorPersistence({ + monaco, + currentScript, + setTestInputs, +}: UsePersistenceProps) { + useEffect(() => { + if (!monaco) return + + monaco.languages.registerCompletionItemProvider('lua', { + provideCompletionItems: (model, position) => { + const word = model.getWordUntilPosition(position) + const range: languages.CompletionItem['range'] = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + } + + const suggestions: languages.CompletionItem[] = [ + { + label: 'context.data', + kind: monaco.languages.CompletionItemKind.Property, + insertText: 'context.data', + documentation: 'Access input parameters passed to the script', + range, + }, + { + label: 'context.user', + kind: monaco.languages.CompletionItemKind.Property, + insertText: 'context.user', + documentation: 'Current user information (username, role, etc.)', + range, + }, + { + label: 'context.kv', + kind: monaco.languages.CompletionItemKind.Property, + insertText: 'context.kv', + documentation: 'Key-value storage interface', + range, + }, + { + label: 'context.log', + kind: monaco.languages.CompletionItemKind.Function, + insertText: 'context.log(${1:message})', + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: 'Log a message to the output console', + range, + }, + { + label: 'log', + kind: monaco.languages.CompletionItemKind.Function, + insertText: 'log(${1:message})', + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: 'Log a message (shortcut for context.log)', + range, + }, + { + label: 'print', + kind: monaco.languages.CompletionItemKind.Function, + insertText: 'print(${1:message})', + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: 'Print a message to output', + range, + }, + { + label: 'return', + kind: monaco.languages.CompletionItemKind.Keyword, + insertText: 'return ${1:result}', + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: 'Return a value from the script', + range, + }, + ] + + return { suggestions } + }, + }) + + monaco.languages.setLanguageConfiguration('lua', { + comments: { + lineComment: '--', + blockComment: ['--[[', ']]'], + }, + brackets: [ + ['{', '}'], + ['[', ']'], + ['(', ')'], + ], + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + ], + }) + }, [monaco]) + + useEffect(() => { + if (!currentScript) return + + const inputs: Record = {} + currentScript.parameters.forEach(param => { + inputs[param.name] = param.type === 'number' ? 0 : param.type === 'boolean' ? false : '' + }) + setTestInputs(inputs) + }, [currentScript?.id, currentScript?.parameters.length, setTestInputs]) + +} diff --git a/frontends/nextjs/src/components/editors/lua/state/useLuaEditorState.ts b/frontends/nextjs/src/components/editors/lua/state/useLuaEditorState.ts new file mode 100644 index 000000000..4db9de06e --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/state/useLuaEditorState.ts @@ -0,0 +1,110 @@ +import { useMemo, useRef, useState } from 'react' +import { useMonaco } from '@monaco-editor/react' +import type { LuaScript } from '@/lib/level-types' +import type { LuaExecutionResult } from '@/lib/lua-engine' +import type { SecurityScanResult } from '@/lib/security-scanner' +import { + createAddScript, + createDeleteScript, + createUpdateScript, +} from '../handlers/scriptHandlers' +import { + createAddParameter, + createDeleteParameter, + createUpdateParameter, + createUpdateTestInput, +} from '../handlers/parameterHandlers' +import { + createInsertSnippet, + createToggleFullscreen, +} from '../handlers/snippetHandlers' +import { + createProceedExecution, + createScanCode, + createTestScript, +} from '../handlers/executionHandlers' + +interface UseLuaEditorStateProps { + scripts: LuaScript[] + onScriptsChange: (scripts: LuaScript[]) => void +} + +export function useLuaEditorState({ scripts, onScriptsChange }: UseLuaEditorStateProps) { + const [selectedScript, setSelectedScript] = useState( + scripts.length > 0 ? scripts[0].id : null + ) + const [testOutput, setTestOutput] = useState(null) + const [testInputs, setTestInputs] = useState>({}) + const [isExecuting, setIsExecuting] = useState(false) + const [isFullscreen, setIsFullscreen] = useState(false) + const [showSnippetLibrary, setShowSnippetLibrary] = useState(false) + const [securityScanResult, setSecurityScanResult] = useState(null) + const [showSecurityDialog, setShowSecurityDialog] = useState(false) + const editorRef = useRef(null) + const monaco = useMonaco() + + const currentScript = useMemo( + () => scripts.find(script => script.id === selectedScript) || null, + [scripts, selectedScript] + ) + + const handleUpdateScript = createUpdateScript({ + scripts, + onScriptsChange, + selectedScript, + }) + + return { + monaco, + editorRef, + currentScript, + selectedScript, + setSelectedScript, + testOutput, + setTestOutput, + testInputs, + setTestInputs, + isExecuting, + setIsExecuting, + isFullscreen, + setIsFullscreen, + showSnippetLibrary, + setShowSnippetLibrary, + securityScanResult, + setSecurityScanResult, + showSecurityDialog, + setShowSecurityDialog, + handleAddScript: createAddScript({ scripts, onScriptsChange, setSelectedScript }), + handleDeleteScript: createDeleteScript({ scripts, onScriptsChange, selectedScript, setSelectedScript }), + handleUpdateScript, + handleAddParameter: createAddParameter({ currentScript, handleUpdateScript }), + handleDeleteParameter: createDeleteParameter({ currentScript, handleUpdateScript }), + handleUpdateParameter: createUpdateParameter({ currentScript, handleUpdateScript }), + handleUpdateTestInput: createUpdateTestInput({ + getTestInputs: () => testInputs, + setTestInputs, + }), + handleInsertSnippet: createInsertSnippet({ currentScript, handleUpdateScript, editorRef, setShowSnippetLibrary }), + handleToggleFullscreen: createToggleFullscreen({ isFullscreen, setIsFullscreen }), + handleTestScript: createTestScript({ + getCurrentScript: () => currentScript, + getTestInputs: () => testInputs, + setIsExecuting, + setTestOutput, + setSecurityScanResult, + setShowSecurityDialog, + }), + handleScanCode: createScanCode({ + getCurrentScript: () => currentScript, + setSecurityScanResult, + setShowSecurityDialog, + }), + handleProceedWithExecution: createProceedExecution({ + getCurrentScript: () => currentScript, + getTestInputs: () => testInputs, + setIsExecuting, + setTestOutput, + setShowSecurityDialog, + }), + } +}