Spaces:
Running
Running
| 'use client' | |
| import React, { useState, useEffect } from 'react' | |
| import Editor from '@monaco-editor/react' | |
| import { | |
| Download, | |
| SidebarSimple, | |
| FileCode, | |
| CaretDown, | |
| FloppyDisk | |
| } from '@phosphor-icons/react' | |
| import Window from './Window' | |
| interface FlutterRunnerProps { | |
| onClose: () => void | |
| onMinimize?: () => void | |
| onMaximize?: () => void | |
| onFocus?: () => void | |
| zIndex?: number | |
| initialCode?: string | |
| } | |
| interface FileNode { | |
| id: string | |
| name: string | |
| type: 'file' | 'folder' | |
| content?: string | |
| children?: FileNode[] | |
| isOpen?: boolean | |
| } | |
| const DEFAULT_FLUTTER_CODE = `` | |
| export function FlutterRunner({ onClose, onMinimize, onMaximize, onFocus, zIndex, initialCode }: FlutterRunnerProps) { | |
| const [code, setCode] = useState(initialCode || DEFAULT_FLUTTER_CODE) | |
| const [showEditor, setShowEditor] = useState(true) | |
| const [showFiles, setShowFiles] = useState(true) | |
| const [files, setFiles] = useState<FileNode[]>([]) | |
| const [activeFileId, setActiveFileId] = useState('main') | |
| const [activeFileName, setActiveFileName] = useState('main.dart') | |
| const [lastSaved, setLastSaved] = useState<Date | null>(null) | |
| const [passkey, setPasskey] = useState('') | |
| const [loading, setLoading] = useState(false) | |
| // Load all files for the file tree without changing the active file | |
| const loadAllFilesForTree = async (key: string, currentFileName: string) => { | |
| try { | |
| const response = await fetch(`/api/data?key=${encodeURIComponent(key)}&folder=`) | |
| const data = await response.json() | |
| if (data.error) { | |
| throw new Error(data.error) | |
| } | |
| // Filter for .dart files | |
| const dartFiles = data.files?.filter((f: any) => f.name.endsWith('.dart')) || [] | |
| if (dartFiles.length > 0) { | |
| const fileNodes: FileNode[] = dartFiles.map((file: any, index: number) => ({ | |
| id: `file_${index}`, | |
| name: file.name, | |
| type: 'file' as const, | |
| content: file.content | |
| })) | |
| setFiles([{ | |
| id: 'root', | |
| name: 'Dart Files', | |
| type: 'folder', | |
| isOpen: true, | |
| children: fileNodes | |
| }]) | |
| // Set active file ID to match the current file | |
| const currentFileNode = fileNodes.find(f => f.name === currentFileName) | |
| if (currentFileNode) { | |
| setActiveFileId(currentFileNode.id) | |
| } | |
| } else { | |
| // No files found, just show the current file | |
| setFiles([{ | |
| id: 'root', | |
| name: 'lib', | |
| type: 'folder', | |
| isOpen: true, | |
| children: [ | |
| { id: 'main', name: currentFileName, type: 'file', content: '' } | |
| ] | |
| }]) | |
| } | |
| } catch (err) { | |
| console.error('Error loading file tree:', err) | |
| // On error, just show the current file | |
| setFiles([{ | |
| id: 'root', | |
| name: 'lib', | |
| type: 'folder', | |
| isOpen: true, | |
| children: [ | |
| { id: 'main', name: currentFileName, type: 'file', content: '' } | |
| ] | |
| }]) | |
| } | |
| } | |
| // Load files from secure storage (used when no specific file is selected) | |
| const loadFiles = async (key: string) => { | |
| setLoading(true) | |
| try { | |
| const response = await fetch(`/api/data?key=${encodeURIComponent(key)}&folder=`) | |
| const data = await response.json() | |
| if (data.error) { | |
| throw new Error(data.error) | |
| } | |
| // Filter for .dart files | |
| const dartFiles = data.files?.filter((f: any) => f.name.endsWith('.dart')) || [] | |
| if (dartFiles.length > 0) { | |
| const fileNodes: FileNode[] = dartFiles.map((file: any, index: number) => ({ | |
| id: `file_${index}`, | |
| name: file.name, | |
| type: 'file' as const, | |
| content: file.content | |
| })) | |
| setFiles([{ | |
| id: 'root', | |
| name: 'Dart Files', | |
| type: 'folder', | |
| isOpen: true, | |
| children: fileNodes | |
| }]) | |
| // Load first file | |
| if (fileNodes.length > 0) { | |
| setActiveFileId(fileNodes[0].id) | |
| setActiveFileName(fileNodes[0].name) | |
| setCode(fileNodes[0].content || DEFAULT_FLUTTER_CODE) | |
| } | |
| } else { | |
| // No files found, create default | |
| setFiles([{ | |
| id: 'root', | |
| name: 'lib', | |
| type: 'folder', | |
| isOpen: true, | |
| children: [ | |
| { id: 'main', name: 'main.dart', type: 'file', content: DEFAULT_FLUTTER_CODE } | |
| ] | |
| }]) | |
| setCode(DEFAULT_FLUTTER_CODE) | |
| setActiveFileName('main.dart') | |
| } | |
| } catch (err) { | |
| console.error('Error loading files:', err) | |
| // Create default on error | |
| setFiles([{ | |
| id: 'root', | |
| name: 'lib', | |
| type: 'folder', | |
| isOpen: true, | |
| children: [ | |
| { id: 'main', name: 'main.dart', type: 'file', content: DEFAULT_FLUTTER_CODE } | |
| ] | |
| }]) | |
| setCode(DEFAULT_FLUTTER_CODE) | |
| setActiveFileName('main.dart') | |
| } finally { | |
| setLoading(false) | |
| } | |
| } | |
| const handleFileClick = (file: FileNode) => { | |
| if (file.type === 'file') { | |
| setActiveFileId(file.id) | |
| setActiveFileName(file.name) | |
| setCode(file.content || '') | |
| } | |
| } | |
| // Check for initial passkey and file content on mount | |
| useEffect(() => { | |
| const sessionPasskey = sessionStorage.getItem('currentPasskey') | |
| const sessionFileContent = sessionStorage.getItem('flutterFileContent') | |
| const sessionFileName = sessionStorage.getItem('currentFileName') | |
| if (sessionFileContent) { | |
| // Load file from session storage (from FileManager) | |
| console.log('Loading dart file from session:', sessionFileName, 'Content length:', sessionFileContent.length) | |
| setCode(sessionFileContent) | |
| setActiveFileName(sessionFileName || 'main.dart') | |
| if (sessionPasskey) { | |
| setPasskey(sessionPasskey) | |
| // Don't call loadFiles here - it would overwrite the file we just loaded | |
| // Just load all files for the file tree without changing the active file | |
| loadAllFilesForTree(sessionPasskey, sessionFileName || 'main.dart') | |
| } else { | |
| // For public files, just set up a simple file structure | |
| setFiles([{ | |
| id: 'root', | |
| name: 'lib', | |
| type: 'folder', | |
| isOpen: true, | |
| children: [ | |
| { id: 'main', name: sessionFileName || 'main.dart', type: 'file', content: sessionFileContent } | |
| ] | |
| }]) | |
| } | |
| // Clear session storage after loading | |
| sessionStorage.removeItem('flutterFileContent') | |
| } else if (sessionPasskey) { | |
| setPasskey(sessionPasskey) | |
| loadFiles(sessionPasskey) | |
| } else if (initialCode) { | |
| setCode(initialCode) | |
| } else { | |
| // Initialize with default empty file | |
| setFiles([{ | |
| id: 'root', | |
| name: 'lib', | |
| type: 'folder', | |
| isOpen: true, | |
| children: [ | |
| { id: 'main', name: 'main.dart', type: 'file', content: DEFAULT_FLUTTER_CODE } | |
| ] | |
| }]) | |
| } | |
| }, [initialCode]) | |
| // Listen for new files being loaded while the component is already open | |
| useEffect(() => { | |
| const checkForNewFile = () => { | |
| const sessionFileContent = sessionStorage.getItem('flutterFileContent') | |
| const sessionFileName = sessionStorage.getItem('currentFileName') | |
| const sessionPasskey = sessionStorage.getItem('currentPasskey') | |
| if (sessionFileContent) { | |
| console.log('Detected new file in session storage:', sessionFileName) | |
| setCode(sessionFileContent) | |
| setActiveFileName(sessionFileName || 'main.dart') | |
| if (sessionPasskey) { | |
| setPasskey(sessionPasskey) | |
| // Load all files for the file tree without overwriting the current file | |
| loadAllFilesForTree(sessionPasskey, sessionFileName || 'main.dart') | |
| } else { | |
| // For public files, just set up a simple file structure | |
| setFiles([{ | |
| id: 'root', | |
| name: 'lib', | |
| type: 'folder', | |
| isOpen: true, | |
| children: [ | |
| { id: 'main', name: sessionFileName || 'main.dart', type: 'file', content: sessionFileContent } | |
| ] | |
| }]) | |
| } | |
| // Clear session storage after loading | |
| sessionStorage.removeItem('flutterFileContent') | |
| } | |
| } | |
| // Check every 500ms for new files | |
| const interval = setInterval(checkForNewFile, 500) | |
| return () => clearInterval(interval) | |
| }, []) | |
| // Manual save function | |
| const handleSave = async () => { | |
| if (!passkey || !activeFileName) { | |
| alert('Cannot save: No passkey set. This file is from public storage.') | |
| return | |
| } | |
| try { | |
| setLoading(true) | |
| await fetch('/api/data', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| key: passkey, | |
| passkey: passkey, | |
| action: 'save_file', | |
| fileName: activeFileName, | |
| content: code | |
| }) | |
| }) | |
| setLastSaved(new Date()) | |
| } catch (error) { | |
| console.error('Save failed:', error) | |
| alert('Failed to save file') | |
| } finally { | |
| setLoading(false) | |
| } | |
| } | |
| const handleDownload = () => { | |
| const blob = new Blob([code], { type: 'text/plain' }) | |
| const url = URL.createObjectURL(blob) | |
| const a = document.createElement('a') | |
| a.href = url | |
| a.download = activeFileName || 'main.dart' | |
| a.click() | |
| URL.revokeObjectURL(url) | |
| } | |
| return ( | |
| <Window | |
| id="flutter-runner" | |
| title="Flutter IDE" | |
| isOpen={true} | |
| onClose={onClose} | |
| onMinimize={onMinimize} | |
| onMaximize={onMaximize} | |
| onFocus={onFocus} | |
| zIndex={zIndex} | |
| width={1400} | |
| height={800} | |
| x={40} | |
| y={40} | |
| className="flutter-ide-window" | |
| > | |
| <div className="flex h-full bg-[#1e1e1e] text-gray-300 font-sans"> | |
| {/* Code Editor - Conditionally Rendered */} | |
| {showEditor && ( | |
| <div className="w-[600px] flex flex-col border-r border-[#333]"> | |
| {/* Toolbar */} | |
| <div className="h-10 bg-[#2d2d2d] border-b border-[#1e1e1e] flex items-center justify-between px-4"> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={() => setShowFiles(!showFiles)} | |
| className={`p-1.5 rounded hover:bg-[#3e3e42] ${showFiles ? 'text-white' : 'text-gray-500'}`} | |
| title="Toggle Files" | |
| > | |
| <SidebarSimple size={16} /> | |
| </button> | |
| <div className="h-4 w-[1px] bg-gray-600 mx-2" /> | |
| <div className="flex items-center gap-2 px-3 py-1 bg-[#1e1e1e] rounded text-xs text-gray-400 border border-[#333]"> | |
| <FileCode size={14} className="text-blue-400" /> | |
| {activeFileName} | |
| </div> | |
| {lastSaved && ( | |
| <span className="text-xs text-green-400"> | |
| ✓ Saved {lastSaved.toLocaleTimeString()} | |
| </span> | |
| )} | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={handleSave} | |
| disabled={!passkey || loading} | |
| className={`p-1.5 rounded transition-colors ${ | |
| passkey && !loading | |
| ? 'text-blue-400 hover:text-blue-300 hover:bg-[#3e3e42]' | |
| : 'text-gray-600 cursor-not-allowed' | |
| }`} | |
| title={passkey ? "Save to Secure Storage" : "Cannot save public files"} | |
| > | |
| <FloppyDisk size={16} /> | |
| </button> | |
| <button | |
| onClick={handleDownload} | |
| className="p-1.5 text-gray-400 hover:text-white hover:bg-[#3e3e42] rounded transition-colors" | |
| title="Download Code" | |
| > | |
| <Download size={16} /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Monaco Editor */} | |
| <div className="flex-1"> | |
| <Editor | |
| height="100%" | |
| defaultLanguage="dart" | |
| theme="vs-dark" | |
| value={code} | |
| onChange={(value) => setCode(value || '')} | |
| options={{ | |
| minimap: { enabled: false }, | |
| fontSize: 14, | |
| fontFamily: "'JetBrains Mono', 'Fira Code', monospace", | |
| lineNumbers: 'on', | |
| scrollBeyondLastLine: false, | |
| automaticLayout: true, | |
| padding: { top: 16, bottom: 16 }, | |
| renderLineHighlight: 'all', | |
| smoothScrolling: true, | |
| cursorBlinking: 'smooth', | |
| cursorSmoothCaretAnimation: 'on' | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| {/* DartPad Preview - Takes full width when editor is hidden */} | |
| <div className="flex-1 flex flex-col"> | |
| <div className="h-10 bg-[#2d2d2d] border-b border-[#1e1e1e] flex items-center justify-between px-4"> | |
| <button | |
| onClick={() => setShowEditor(!showEditor)} | |
| className={`flex items-center gap-2 px-2 py-1 rounded transition-all ${showEditor | |
| ? 'bg-[#3e3e42] text-white hover:bg-[#4e4e52]' | |
| : 'bg-blue-600 text-white hover:bg-blue-700'}`} | |
| title={showEditor ? "Hide Code Editor" : "Show Code Editor"} | |
| > | |
| <SidebarSimple size={16} weight={showEditor ? "fill" : "regular"} /> | |
| <span className="text-xs font-medium"> | |
| {showEditor ? "Hide Editor" : "Show Editor"} | |
| </span> | |
| </button> | |
| <span className="text-xs text-gray-400 uppercase font-bold"> | |
| Live Preview {!showEditor && "- Full Screen"} | |
| </span> | |
| <div className="w-24" /> {/* Spacer for centering */} | |
| </div> | |
| <div className="flex-1 bg-[#1e1e1e] relative overflow-hidden"> | |
| <iframe | |
| src="https://dartpad.dev/embed-flutter.html?theme=dark&split=50" | |
| className="w-full h-full border-0" | |
| sandbox="allow-scripts allow-same-origin allow-popups" | |
| title="Flutter Preview" | |
| /> | |
| </div> | |
| </div> | |
| {/* File List - Right Side - Only show when editor is visible */} | |
| {showEditor && showFiles && ( | |
| <div className="w-64 bg-[#252526] border-l border-[#333] flex flex-col"> | |
| <div className="h-9 px-4 flex items-center text-xs font-bold text-gray-500 uppercase tracking-wider"> | |
| Project Files | |
| </div> | |
| <div className="flex-1 overflow-y-auto py-2"> | |
| <div className="px-2"> | |
| <div className="flex items-center gap-1 py-1 px-2 text-sm text-gray-300 hover:bg-[#2a2d2e] rounded cursor-pointer"> | |
| <CaretDown size={12} weight="bold" /> | |
| <span className="font-bold">Flutter App</span> | |
| </div> | |
| <div className="pl-4"> | |
| {files.map(file => ( | |
| <div key={file.id}> | |
| <div className="flex items-center gap-2 py-1 px-2 text-sm hover:bg-[#2a2d2e] rounded cursor-pointer text-blue-400"> | |
| <CaretDown size={12} weight="bold" /> | |
| {file.name} | |
| </div> | |
| {file.children?.map(child => ( | |
| <div | |
| key={child.id} | |
| onClick={() => handleFileClick(child)} | |
| className={`flex items-center gap-2 py-1 px-2 ml-4 text-sm rounded cursor-pointer ${activeFileId === child.id ? 'bg-[#37373d] text-white' : 'text-gray-400 hover:bg-[#2a2d2e]' | |
| }`} | |
| > | |
| <FileCode size={14} /> | |
| {child.name} | |
| </div> | |
| ))} | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </Window> | |
| ) | |
| } | |