Spaces:
Running
Running
| import { useMemo, useCallback, useRef, useEffect, useState } from 'react'; | |
| import { useEditorStore } from '../../store/editorStore'; | |
| import { useProjectStore } from '../../store/projectStore'; | |
| import { useCollaborationStore, Collaborator } from '../../store/collaborationStore'; | |
| import { useProjectSocket } from '../../hooks/useWebSocket'; | |
| import { useParams } from 'react-router-dom'; | |
| import { getBlocksForFramework } from '../../blocks/registry'; | |
| import { compileWeb } from '../../compilers/web'; | |
| import { compileElectron } from '../../compilers/electron'; | |
| import { compileMaui } from '../../compilers/maui'; | |
| import { compileNodeJS } from '../../compilers/nodejs'; | |
| import { SyntaxHighlight } from './SyntaxHighlight'; | |
| import { | |
| Download, Copy, FileCode, | |
| Eye, Code2, SplitSquareHorizontal | |
| } from 'lucide-react'; | |
| import { saveAs } from 'file-saver'; | |
| import JSZip from 'jszip'; | |
| import { getColor } from '../Collaboration/CollaboratorAvatars'; | |
| export default function CodeView() { | |
| const { id } = useParams<{ id: string }>(); | |
| const { currentProject } = useProjectStore(); | |
| const { activeFileId, fileTree, viewMode, setViewMode, visualElements, activeFileContent, setActiveFileContent } = useEditorStore(); | |
| const collaborators = useCollaborationStore((s) => s.collaborators); | |
| const { emitCursorMove, emitFileChanged } = useProjectSocket(id); | |
| const editorRef = useRef<HTMLDivElement>(null); | |
| const [isEditing, setIsEditing] = useState(false); | |
| const compiledCode = useMemo(() => { | |
| if (!currentProject) return ''; | |
| if (isEditing) return activeFileContent; | |
| const activeFile = fileTree.find(f => f.id === activeFileId); | |
| const blocks = getBlocksForFramework(currentProject.framework); | |
| const compilerOptions = { | |
| blocks, fileTree, visualElements, activeFile, | |
| projectName: currentProject.name, | |
| }; | |
| switch (currentProject.framework) { | |
| case 'web': return compileWeb(compilerOptions); | |
| case 'electron': return compileElectron(compilerOptions); | |
| case 'maui': return compileMaui(compilerOptions); | |
| case 'nodejs': return compileNodeJS(compilerOptions); | |
| default: return '// Select a framework to generate code'; | |
| } | |
| }, [currentProject, activeFileId, fileTree, visualElements, isEditing, activeFileContent]); | |
| const collaboratorCursors = useMemo(() => { | |
| if (!activeFileId) return []; | |
| return collaborators.filter((c) => c.activeFileId === activeFileId && c.cursor && c.cursor.line != null); | |
| }, [collaborators, activeFileId]); | |
| const lines = useMemo(() => compiledCode.split('\n'), [compiledCode]); | |
| const handleEditorClick = useCallback((e: React.MouseEvent) => { | |
| const rect = editorRef.current?.getBoundingClientRect(); | |
| if (!rect) return; | |
| const lineHeight = 20; | |
| const line = Math.floor((e.clientY - rect.top + editorRef.current!.scrollTop) / lineHeight); | |
| const ch = Math.min(0, 0); | |
| if (activeFileId) { | |
| emitCursorMove(activeFileId, { line: Math.max(0, line), ch }); | |
| } | |
| }, [activeFileId, emitCursorMove]); | |
| const fileChangeTimer = useRef<number>(); | |
| const cursorThrottle = useRef<number>(); | |
| const handleTextChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => { | |
| const content = e.target.value; | |
| setActiveFileContent(content); | |
| // Debounce file_changed to 300ms to avoid flooding when typing | |
| if (activeFileId) { | |
| clearTimeout(fileChangeTimer.current); | |
| fileChangeTimer.current = window.setTimeout(() => { | |
| emitFileChanged(activeFileId, content); | |
| }, 300); | |
| } | |
| // Track cursor position | |
| const textarea = e.target; | |
| const pos = textarea.selectionStart; | |
| const before = content.substring(0, pos); | |
| const line = before.split('\n').length - 1; | |
| const ch = before.length - before.lastIndexOf('\n') - 1; | |
| clearTimeout(cursorThrottle.current); | |
| cursorThrottle.current = window.setTimeout(() => { | |
| if (activeFileId) emitCursorMove(activeFileId, { line, ch }); | |
| }, 100); | |
| }, [activeFileId, emitCursorMove, emitFileChanged, setActiveFileContent]); | |
| const handleDownload = useCallback(async () => { | |
| if (!currentProject) return; | |
| const zip = new JSZip(); | |
| const addFilesToZip = (files: any[], currentPath: string = '') => { | |
| for (const file of files) { | |
| if (file.type === 'folder') { | |
| if (file.children) addFilesToZip(file.children, currentPath + file.name + '/'); | |
| } else { | |
| zip.file(currentPath + file.name, file.content || ''); | |
| } | |
| } | |
| }; | |
| addFilesToZip(fileTree); | |
| const blob = await zip.generateAsync({ type: 'blob' }); | |
| saveAs(blob, `${currentProject.name}.zip`); | |
| }, [currentProject, fileTree]); | |
| const handleCopy = useCallback(() => { | |
| navigator.clipboard.writeText(compiledCode); | |
| }, [compiledCode]); | |
| const scriptFileName = useMemo(() => { | |
| const scriptFile = fileTree.find(f => f.type === 'js' || f.type === 'typescript'); | |
| return scriptFile?.name || fileTree.find(f => f.id === activeFileId)?.name || 'output'; | |
| }, [fileTree, activeFileId]); | |
| return ( | |
| <div className="flex flex-col h-full"> | |
| {/* Code Toolbar */} | |
| <div className="h-10 glass border-b border-surface-700 flex items-center justify-between px-4"> | |
| <div className="flex items-center gap-2"> | |
| <FileCode className="w-4 h-4 text-primary-400" /> | |
| <span className="text-sm font-medium text-white">{scriptFileName}</span> | |
| <span className="badge-web">{currentProject?.framework || 'web'}</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| {!isEditing && ( | |
| <div className="flex bg-surface-800 rounded-lg p-0.5"> | |
| <button onClick={() => setViewMode('design')} | |
| className={`px-2 py-1 rounded-md text-xs ${viewMode === 'design' ? 'bg-primary-600 text-white' : 'text-surface-400'}`}> | |
| <Code2 className="w-3.5 h-3.5" /> | |
| </button> | |
| <button onClick={() => setViewMode('split')} | |
| className={`px-2 py-1 rounded-md text-xs ${viewMode === 'split' ? 'bg-primary-600 text-white' : 'text-surface-400'}`}> | |
| <SplitSquareHorizontal className="w-3.5 h-3.5" /> | |
| </button> | |
| </div> | |
| )} | |
| <button onClick={() => setIsEditing(!isEditing)} | |
| className={`btn-ghost px-2 py-1 text-xs ${isEditing ? 'text-primary-400 bg-primary-500/10' : ''}`} | |
| title={isEditing ? 'View compiled code' : 'Edit source directly'}> | |
| <Code2 className="w-3.5 h-3.5" /> | |
| {isEditing ? 'Compiled' : 'Edit'} | |
| </button> | |
| <div className="w-px h-4 bg-surface-700" /> | |
| <button onClick={handleCopy} className="btn-ghost p-1.5" title="Copy to clipboard"> | |
| <Copy className="w-3.5 h-3.5" /> | |
| </button> | |
| <button onClick={handleDownload} className="btn-ghost p-1.5" title="Download project as ZIP"> | |
| <Download className="w-3.5 h-3.5" /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Code Content */} | |
| <div className="flex-1 overflow-auto"> | |
| {isEditing ? ( | |
| <div className="relative h-full"> | |
| <textarea | |
| value={activeFileContent} | |
| onChange={handleTextChange} | |
| className="w-full h-full bg-transparent text-transparent caret-white resize-none font-mono text-sm leading-5 p-4 absolute inset-0 z-10 outline-none" | |
| spellCheck={false} | |
| /> | |
| <div className="pointer-events-none p-4"> | |
| <SyntaxHighlight code={activeFileContent} language={getLanguage(currentProject?.framework || 'web', fileTree.find(f => f.id === activeFileId)?.type)} /> | |
| </div> | |
| {collaboratorCursors.length > 0 && ( | |
| <div className="absolute inset-0 pointer-events-none z-20"> | |
| {collaboratorCursors.map((c) => ( | |
| <div | |
| key={c.userId} | |
| className="absolute left-0 w-0.5 h-5" | |
| style={{ | |
| top: `${c.cursor!.line * 20 + 16}px`, | |
| backgroundColor: getColor(c.userId), | |
| }} | |
| > | |
| <span | |
| className="absolute left-0.5 -top-4 text-[9px] px-1 py-0.5 rounded-r whitespace-nowrap text-white font-bold" | |
| style={{ backgroundColor: getColor(c.userId) }} | |
| > | |
| {c.username} | |
| </span> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ) : ( | |
| <div ref={editorRef} onClick={handleEditorClick} className="relative"> | |
| {compiledCode ? ( | |
| <> | |
| <SyntaxHighlight code={compiledCode} language={getLanguage(currentProject?.framework || 'web', fileTree.find(f => f.id === activeFileId)?.type)} /> | |
| {collaboratorCursors.length > 0 && ( | |
| <div className="absolute inset-0 pointer-events-none top-0 left-0"> | |
| {collaboratorCursors.map((c) => ( | |
| <div | |
| key={c.userId} | |
| className="absolute left-0 w-0.5 h-5" | |
| style={{ | |
| top: `${c.cursor!.line * 20 + 16}px`, | |
| backgroundColor: getColor(c.userId), | |
| }} | |
| > | |
| <span | |
| className="absolute left-0.5 -top-4 text-[9px] px-1 py-0.5 rounded-r whitespace-nowrap text-white font-bold" | |
| style={{ backgroundColor: getColor(c.userId) }} | |
| > | |
| {c.username} | |
| </span> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </> | |
| ) : ( | |
| <div className="flex items-center justify-center h-full text-surface-500 text-sm"> | |
| <div className="text-center"> | |
| <Code2 className="w-12 h-12 mx-auto mb-3 opacity-30" /> | |
| <p>Add blocks to see generated code</p> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function getLanguage(framework: string, fileType?: string): string { | |
| if (fileType) { | |
| if (fileType === 'js' || fileType === 'javascript') return 'javascript'; | |
| if (fileType === 'ts' || fileType === 'typescript') return 'typescript'; | |
| if (fileType === 'html' || fileType === 'xaml') return 'xml'; | |
| if (fileType === 'csharp' || fileType === 'cs') return 'csharp'; | |
| if (fileType === 'css') return 'css'; | |
| if (fileType === 'json') return 'json'; | |
| } | |
| switch (framework) { | |
| case 'web': return 'javascript'; | |
| case 'electron': return 'typescript'; | |
| case 'maui': return 'xml'; | |
| case 'nodejs': return 'javascript'; | |
| default: return 'javascript'; | |
| } | |
| } | |