| const { useState, useEffect, useRef, useCallback } = React; |
|
|
| const API = ''; |
|
|
| |
| const fileIcon = (name) => { |
| if (!name) return 'π'; |
| const ext = name.split('.').pop()?.toLowerCase(); |
| const icons = { |
| js: 'π¨', jsx: 'βοΈ', ts: 'π·', tsx: 'βοΈ', |
| py: 'π', html: 'π', css: 'π¨', json: 'π', |
| md: 'π', txt: 'π', sh: 'β‘', env: 'π', |
| png: 'πΌοΈ', jpg: 'πΌοΈ', svg: 'π', gif: 'πΌοΈ', |
| gitignore: 'π§', dockerfile: 'π³', |
| }; |
| return icons[ext] || 'π'; |
| }; |
|
|
| const timestamp = () => new Date().toLocaleTimeString('en', {hour12: false}); |
|
|
| const parseMarkdown = (text) => { |
| if (!text) return ''; |
| try { return marked.parse(text); } catch { return text; } |
| }; |
|
|
| |
| const MonacoEditor = ({ value, onChange, language = 'javascript', path }) => { |
| const ref = useRef(null); |
| const editorRef = useRef(null); |
| const modelRef = useRef({}); |
|
|
| useEffect(() => { |
| require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.44.0/min/vs' } }); |
| require(['vs/editor/editor.main'], () => { |
| if (!ref.current) return; |
| const editor = monaco.editor.create(ref.current, { |
| value: value || '', |
| language, |
| theme: 'vs-dark', |
| fontSize: 13, |
| fontFamily: "'JetBrains Mono', monospace", |
| fontLigatures: true, |
| minimap: { enabled: window.innerWidth > 1200 }, |
| lineNumbers: 'on', |
| scrollBeyondLastLine: false, |
| automaticLayout: true, |
| padding: { top: 10 }, |
| cursorStyle: 'line', |
| wordWrap: 'on', |
| tabSize: 2, |
| }); |
| editor.onDidChangeModelContent(() => onChange?.(editor.getValue())); |
| editorRef.current = editor; |
| }); |
| return () => editorRef.current?.dispose(); |
| }, []); |
|
|
| useEffect(() => { |
| if (!editorRef.current) return; |
| const current = editorRef.current.getValue(); |
| if (current !== value) editorRef.current.setValue(value || ''); |
| const lang = getLanguage(path); |
| monaco.editor.setModelLanguage(editorRef.current.getModel(), lang); |
| }, [value, path]); |
|
|
| return <div ref={ref} style={{ height: '100%', width: '100%' }} />; |
| }; |
|
|
| const getLanguage = (path) => { |
| if (!path) return 'plaintext'; |
| const ext = path.split('.').pop()?.toLowerCase(); |
| const map = { js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', py: 'python', html: 'html', css: 'css', json: 'json', md: 'markdown', sh: 'shell', txt: 'plaintext', yaml: 'yaml', yml: 'yaml' }; |
| return map[ext] || 'plaintext'; |
| }; |
|
|
| |
| const FileTree = ({ files, activeFile, onOpen, onRefresh, project }) => { |
| const [ctx, setCtx] = useState(null); |
| const [renaming, setRenaming] = useState(null); |
| const [renameVal, setRenameVal] = useState(''); |
|
|
| const buildTree = (files) => { |
| const tree = {}; |
| files.forEach(f => { |
| const parts = f.path.split('/'); |
| let cur = tree; |
| parts.forEach((part, i) => { |
| if (!cur[part]) cur[part] = { _type: i === parts.length - 1 ? f.type : 'dir', _path: parts.slice(0, i + 1).join('/'), _children: {} }; |
| cur = cur[part]._children; |
| }); |
| }); |
| return tree; |
| }; |
|
|
| const renderTree = (tree, depth = 0) => { |
| return Object.entries(tree).map(([name, node]) => ( |
| <div key={node._path}> |
| <div |
| className={`tree-item ${activeFile === node._path ? 'active' : ''}`} |
| style={{ paddingLeft: `${10 + depth * 14}px` }} |
| onClick={() => node._type === 'file' && onOpen(node._path)} |
| onContextMenu={(e) => { e.preventDefault(); setCtx({ x: e.clientX, y: e.clientY, path: node._path, type: node._type }); }} |
| > |
| <span>{node._type === 'dir' ? 'π' : fileIcon(name)}</span> |
| {renaming === node._path |
| ? <input autoFocus value={renameVal} onChange={e => setRenameVal(e.target.value)} |
| onBlur={() => setRenaming(null)} |
| onKeyDown={e => { if (e.key === 'Enter') { /* rename api call */ setRenaming(null); } }} |
| style={{ background: 'var(--bg-4)', border: '1px solid var(--accent)', color: 'var(--text-0)', padding: '1px 4px', borderRadius: '3px', fontSize: '12px', fontFamily: 'var(--font-mono)', width: '100px' }} |
| /> |
| : <span>{name}</span> |
| } |
| </div> |
| {node._type === 'dir' && renderTree(node._children, depth + 1)} |
| </div> |
| )); |
| }; |
|
|
| const handleCtxAction = async (action) => { |
| if (!ctx) return; |
| if (action === 'open') onOpen(ctx.path); |
| if (action === 'rename') { setRenaming(ctx.path); setRenameVal(ctx.path.split('/').pop()); } |
| if (action === 'delete') { |
| await fetch(`${API}/api/file/${project}/${ctx.path}`, { method: 'DELETE' }); |
| onRefresh(); |
| } |
| setCtx(null); |
| }; |
|
|
| const tree = buildTree(files); |
|
|
| return ( |
| <div className="file-tree" onClick={() => setCtx(null)}> |
| <div className="file-tree-header"> |
| <span>Files</span> |
| <div className="file-tree-actions"> |
| <div className="tree-action-btn" title="New File" onClick={async () => { |
| const name = prompt('File name:'); |
| if (name) { await fetch(`${API}/api/file/${project}/${name}`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({content:''}) }); onRefresh(); } |
| }}>+</div> |
| <div className="tree-action-btn" title="Refresh" onClick={onRefresh}>βΊ</div> |
| </div> |
| </div> |
| {files.length === 0 |
| ? <div style={{padding:'16px', color:'var(--text-2)', fontSize:'11px', textAlign:'center'}}>No files yet</div> |
| : renderTree(tree) |
| } |
| {ctx && ( |
| <div className="context-menu" style={{ left: ctx.x, top: ctx.y }}> |
| {ctx.type === 'file' && <div className="context-item" onClick={() => handleCtxAction('open')}>π Open</div>} |
| <div className="context-item" onClick={() => handleCtxAction('rename')}>βοΈ Rename</div> |
| <div className="context-divider" /> |
| <div className="context-item danger" onClick={() => handleCtxAction('delete')}>ποΈ Delete</div> |
| </div> |
| )} |
| </div> |
| ); |
| }; |
|
|
| |
| const AgentPanel = ({ project, onFilesUpdate, onConsoleLog }) => { |
| const [messages, setMessages] = useState([]); |
| const [input, setInput] = useState(''); |
| const [running, setRunning] = useState(false); |
| const [history, setHistory] = useState([]); |
| const abortRef = useRef(null); |
| const outputRef = useRef(null); |
|
|
| const addMsg = (msg) => { |
| setMessages(prev => [...prev, { ...msg, id: Date.now() + Math.random() }]); |
| setTimeout(() => outputRef.current?.scrollTo(0, outputRef.current.scrollHeight), 50); |
| }; |
|
|
| const stop = () => { abortRef.current?.abort(); setRunning(false); }; |
|
|
| const send = async () => { |
| if (!input.trim() || running || !project) return; |
| const msg = input.trim(); |
| setInput(''); |
| setRunning(true); |
| addMsg({ type: 'user', content: msg }); |
|
|
| abortRef.current = new AbortController(); |
| try { |
| const res = await fetch(`${API}/api/agent`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ project, message: msg, history }), |
| signal: abortRef.current.signal, |
| }); |
|
|
| const reader = res.body.getReader(); |
| const decoder = new TextDecoder(); |
| let finalMsg = ''; |
|
|
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| const lines = decoder.decode(value).split('\n'); |
| for (const line of lines) { |
| if (!line.startsWith('data: ')) continue; |
| try { |
| const evt = JSON.parse(line.slice(6)); |
| if (evt.type === 'thinking') addMsg({ type: 'thinking', model: evt.model, content: evt.content }); |
| else if (evt.type === 'analysis') addMsg({ type: 'analysis', content: evt.content }); |
| else if (evt.type === 'plan') addMsg({ type: 'plan', content: evt.content }); |
| else if (evt.type === 'tool_start') { |
| addMsg({ type: 'tool-start', content: `${evt.tool}(${JSON.stringify(evt.args).slice(0, 80)}...)` }); |
| onConsoleLog({ type: 'tool', text: `[agent] ${evt.tool}: ${JSON.stringify(evt.args).slice(0, 60)}` }); |
| } |
| else if (evt.type === 'tool_result') { |
| const isErr = evt.result?.startsWith('β'); |
| addMsg({ type: 'tool-result', error: isErr, content: evt.result?.slice(0, 200) }); |
| onConsoleLog({ type: isErr ? 'error' : 'success', text: `[result] ${evt.result?.slice(0, 80)}` }); |
| } |
| else if (evt.type === 'debug') addMsg({ type: 'debug', content: evt.content }); |
| else if (evt.type === 'content') addMsg({ type: 'content', content: evt.content }); |
| else if (evt.type === 'final') { |
| finalMsg += evt.content; |
| setMessages(prev => { |
| const last = prev[prev.length - 1]; |
| if (last?.type === 'final') return [...prev.slice(0, -1), { ...last, content: last.content + evt.content }]; |
| return [...prev, { type: 'final', content: evt.content, id: Date.now() }]; |
| }); |
| } |
| else if (evt.type === 'files_update') onFilesUpdate(evt.files); |
| else if (evt.type === 'done') { |
| setRunning(false); |
| addMsg({ type: 'done', content: 'β
Task completed!' }); |
| onConsoleLog({ type: 'success', text: '[agent] Task completed' }); |
| setHistory(prev => [...prev, { role: 'user', content: msg }, { role: 'assistant', content: finalMsg }]); |
| } |
| } catch {} |
| } |
| } |
| } catch (e) { |
| if (e.name !== 'AbortError') addMsg({ type: 'error', content: String(e) }); |
| setRunning(false); |
| } |
| }; |
|
|
| const renderMsg = (msg) => { |
| const modelBadge = msg.model && <div className={`model-badge ${msg.model}`}>{msg.model === 'qwen' ? 'Qwen3 235B' : msg.model === 'oss' ? 'GPT-OSS 120B' : 'Llama 70B'}</div>; |
| switch (msg.type) { |
| case 'user': return <div className="agent-msg" style={{background:'var(--accent-dim)',color:'var(--text-0)'}}>{msg.content}</div>; |
| case 'thinking': return <div className="agent-msg thinking">{modelBadge}<div className="pulse" />{msg.content}</div>; |
| case 'analysis': return <div className="agent-msg analysis"><div className="model-badge qwen">Qwen3 235B</div><div className="markdown" dangerouslySetInnerHTML={{__html: parseMarkdown(msg.content)}} /></div>; |
| case 'plan': return <div className="agent-msg plan"><div className="model-badge oss">GPT-OSS 120B</div><div className="markdown" dangerouslySetInnerHTML={{__html: parseMarkdown(msg.content)}} /></div>; |
| case 'tool-start': return <div className="agent-msg tool-start">β‘ {msg.content}</div>; |
| case 'tool-result': return <div className={`agent-msg tool-result ${msg.error ? 'error' : ''}`}>{msg.content}</div>; |
| case 'debug': return <div className="agent-msg debug">π§ {msg.content}</div>; |
| case 'final': return <div className="agent-msg final"><div className="model-badge llama">Llama 70B</div><div className="markdown" dangerouslySetInnerHTML={{__html: parseMarkdown(msg.content)}} /></div>; |
| case 'done': return <div className="agent-msg done">{msg.content}</div>; |
| default: return <div className="agent-msg" style={{color:'var(--text-1)'}}>{msg.content}</div>; |
| } |
| }; |
|
|
| return ( |
| <div className="agent-panel"> |
| <div className="agent-output" ref={outputRef}> |
| {messages.length === 0 && ( |
| <div className="empty-state"> |
| <div className="empty-icon">β‘</div> |
| <div>Describe what you want to build</div> |
| <div style={{fontSize:'11px',color:'var(--text-2)'}}>Powered by Qwen3 235B + GPT-OSS 120B</div> |
| </div> |
| )} |
| {messages.map(msg => <div key={msg.id}>{renderMsg(msg)}</div>)} |
| </div> |
| <div className="agent-input-wrap"> |
| <div className="agent-input-box"> |
| <textarea |
| className="agent-textarea" |
| placeholder={project ? "Describe your task..." : "Select a project first..."} |
| value={input} |
| disabled={!project} |
| onChange={e => { setInput(e.target.value); e.target.style.height = 'auto'; e.target.style.height = Math.min(e.target.scrollHeight, 120) + 'px'; }} |
| onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }} |
| rows={1} |
| /> |
| {running |
| ? <button className="stop-btn" onClick={stop} title="Stop">β </button> |
| : <button className="send-btn" onClick={send} disabled={!input.trim() || !project}>βΆ</button> |
| } |
| </div> |
| </div> |
| </div> |
| ); |
| }; |
|
|
| |
| const AssistantPanel = () => { |
| const [messages, setMessages] = useState([]); |
| const [input, setInput] = useState(''); |
| const [model, setModel] = useState('llama'); |
| const [streaming, setStreaming] = useState(false); |
| const messagesRef = useRef(null); |
|
|
| const send = async () => { |
| if (!input.trim() || streaming) return; |
| const msg = input.trim(); |
| setInput(''); |
| setStreaming(true); |
| const history = messages.map(m => ({ role: m.role, content: m.content })); |
| setMessages(prev => [...prev, { role: 'user', content: msg, id: Date.now() }]); |
|
|
| try { |
| const res = await fetch(`${API}/api/assistant`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ message: msg, history, model }), |
| }); |
| const reader = res.body.getReader(); |
| const decoder = new TextDecoder(); |
| let id = Date.now(); |
| setMessages(prev => [...prev, { role: 'assistant', content: '', id }]); |
|
|
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| for (const line of decoder.decode(value).split('\n')) { |
| if (!line.startsWith('data: ')) continue; |
| try { |
| const evt = JSON.parse(line.slice(6)); |
| if (evt.type === 'token') setMessages(prev => prev.map(m => m.id === id ? { ...m, content: m.content + evt.content } : m)); |
| } catch {} |
| } |
| } |
| } catch (e) { |
| setMessages(prev => [...prev, { role: 'assistant', content: 'Error: ' + e.message, id: Date.now() }]); |
| } |
| setStreaming(false); |
| setTimeout(() => messagesRef.current?.scrollTo(0, messagesRef.current.scrollHeight), 50); |
| }; |
|
|
| return ( |
| <div className="assistant-panel"> |
| <div className="assistant-model-select"> |
| <span>Model:</span> |
| <select className="model-select" value={model} onChange={e => setModel(e.target.value)}> |
| <option value="llama">Llama 70B (Cloudflare)</option> |
| <option value="qwen">Qwen3 235B (Cerebras)</option> |
| <option value="oss">GPT-OSS 120B (Cerebras)</option> |
| </select> |
| </div> |
| <div className="assistant-messages" ref={messagesRef}> |
| {messages.length === 0 && ( |
| <div className="empty-state"> |
| <div className="empty-icon">π¬</div> |
| <div>Ask me anything</div> |
| </div> |
| )} |
| {messages.map(msg => ( |
| <div key={msg.id} className={`chat-msg ${msg.role}`}> |
| <div className="chat-bubble"> |
| <div className="markdown" dangerouslySetInnerHTML={{__html: parseMarkdown(msg.content)}} /> |
| </div> |
| </div> |
| ))} |
| </div> |
| <div className="agent-input-wrap"> |
| <div className="agent-input-box"> |
| <textarea |
| className="agent-textarea" |
| placeholder="Ask anything..." |
| value={input} |
| onChange={e => { setInput(e.target.value); e.target.style.height = 'auto'; e.target.style.height = Math.min(e.target.scrollHeight, 120) + 'px'; }} |
| onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }} |
| rows={1} |
| /> |
| <button className="send-btn" onClick={send} disabled={!input.trim() || streaming}>βΆ</button> |
| </div> |
| </div> |
| </div> |
| ); |
| }; |
|
|
| |
| const ShellPanel = ({ project }) => { |
| const [lines, setLines] = useState([{ type: 'info', text: `XRO IDE Shell β project: ${project || 'none'}` }]); |
| const [input, setInput] = useState(''); |
| const [history, setHistory] = useState([]); |
| const [histIdx, setHistIdx] = useState(-1); |
| const outputRef = useRef(null); |
|
|
| const run = async () => { |
| if (!input.trim() || !project) return; |
| const cmd = input.trim(); |
| setHistory(prev => [cmd, ...prev]); |
| setHistIdx(-1); |
| setInput(''); |
| setLines(prev => [...prev, { type: 'cmd', text: `C:\\XRO-IDE\\${project}> ${cmd}` }]); |
|
|
| try { |
| const res = await fetch(`${API}/api/shell`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ project, command: cmd }), |
| }); |
| const reader = res.body.getReader(); |
| const decoder = new TextDecoder(); |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| for (const line of decoder.decode(value).split('\n')) { |
| if (!line.startsWith('data: ')) continue; |
| try { |
| const evt = JSON.parse(line.slice(6)); |
| if (evt.type === 'output') setLines(prev => [...prev, { type: 'info', text: evt.content.trimEnd() }]); |
| if (evt.type === 'error') setLines(prev => [...prev, { type: 'err', text: evt.content }]); |
| } catch {} |
| } |
| } |
| } catch (e) { |
| setLines(prev => [...prev, { type: 'err', text: String(e) }]); |
| } |
| setTimeout(() => outputRef.current?.scrollTo(0, outputRef.current.scrollHeight), 50); |
| }; |
|
|
| return ( |
| <div className="shell-panel"> |
| <div className="shell-output" ref={outputRef}> |
| <div style={{color:'var(--text-2)',fontSize:'11px',marginBottom:'8px'}}>Microsoft Windows [Version 11.0] β XRO IDE Shell</div> |
| {lines.map((l, i) => <div key={i} className={`shell-line ${l.type}`}>{l.text}</div>)} |
| </div> |
| <div className="shell-input-row"> |
| <span className="shell-prompt">C:\XRO-IDE\{project || '?'}{'>'}</span> |
| <input |
| className="shell-input" |
| value={input} |
| onChange={e => setInput(e.target.value)} |
| onKeyDown={e => { |
| if (e.key === 'Enter') run(); |
| if (e.key === 'ArrowUp') { const idx = Math.min(histIdx + 1, history.length - 1); setHistIdx(idx); setInput(history[idx] || ''); } |
| if (e.key === 'ArrowDown') { const idx = Math.max(histIdx - 1, -1); setHistIdx(idx); setInput(idx === -1 ? '' : history[idx]); } |
| }} |
| placeholder={project ? 'type command...' : 'select a project first'} |
| disabled={!project} |
| autoComplete="off" |
| spellCheck={false} |
| /> |
| </div> |
| </div> |
| ); |
| }; |
|
|
| |
| const ConsolePanel = ({ logs }) => { |
| const ref = useRef(null); |
| useEffect(() => { ref.current?.scrollTo(0, ref.current.scrollHeight); }, [logs]); |
| return ( |
| <div className="console-panel" ref={ref}> |
| {logs.length === 0 && <div style={{color:'var(--text-2)'}}>Agent output will appear here...</div>} |
| {logs.map((l, i) => ( |
| <div key={i} className={`console-line ${l.type}`}> |
| <span className="console-time">{l.time}</span> |
| <span>{l.text}</span> |
| </div> |
| ))} |
| </div> |
| ); |
| }; |
|
|
| |
| const PreviewPanel = () => { |
| const [url, setUrl] = useState('http://localhost:3000'); |
| const [src, setSrc] = useState(''); |
| return ( |
| <div className="preview-panel"> |
| <div className="preview-bar"> |
| <input className="preview-url" value={url} onChange={e => setUrl(e.target.value)} onKeyDown={e => e.key === 'Enter' && setSrc(url)} placeholder="Enter URL..." /> |
| <button className="btn btn-primary" style={{padding:'4px 10px',fontSize:'12px'}} onClick={() => setSrc(url)}>Go</button> |
| </div> |
| {src ? <iframe className="preview-iframe" src={src} sandbox="allow-scripts allow-same-origin allow-forms" /> : <div className="empty-state"><div className="empty-icon">π</div><div>Enter URL and press Go</div></div>} |
| </div> |
| ); |
| }; |
|
|
| |
| const FilesPanel = ({ project, files, onRefresh }) => { |
| const [openFiles, setOpenFiles] = useState([]); |
| const [activeFile, setActiveFile] = useState(null); |
| const [fileContents, setFileContents] = useState({}); |
| const [modified, setModified] = useState({}); |
|
|
| const openFile = async (path) => { |
| if (!openFiles.includes(path)) setOpenFiles(prev => [...prev, path]); |
| setActiveFile(path); |
| if (!fileContents[path]) { |
| const res = await fetch(`${API}/api/file/${project}/${path}`); |
| const data = await res.json(); |
| setFileContents(prev => ({ ...prev, [path]: data.content || '' })); |
| } |
| }; |
|
|
| const closeFile = (path, e) => { |
| e.stopPropagation(); |
| const idx = openFiles.indexOf(path); |
| const next = openFiles.filter(f => f !== path); |
| setOpenFiles(next); |
| if (activeFile === path) setActiveFile(next[Math.max(0, idx - 1)] || null); |
| }; |
|
|
| const saveFile = async () => { |
| if (!activeFile) return; |
| await fetch(`${API}/api/file/${project}/${activeFile}`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ content: fileContents[activeFile] }) |
| }); |
| setModified(prev => ({ ...prev, [activeFile]: false })); |
| }; |
|
|
| useEffect(() => { |
| const handler = (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveFile(); } }; |
| window.addEventListener('keydown', handler); |
| return () => window.removeEventListener('keydown', handler); |
| }, [activeFile, fileContents]); |
|
|
| return ( |
| <div className="files-panel"> |
| <FileTree files={files} activeFile={activeFile} onOpen={openFile} onRefresh={onRefresh} project={project} /> |
| <div className="file-editor-area"> |
| {openFiles.length > 0 && ( |
| <div className="editor-file-tabs"> |
| {openFiles.map(f => ( |
| <div key={f} className={`editor-tab ${activeFile === f ? 'active' : ''}`} onClick={() => openFile(f)}> |
| {modified[f] && <span style={{color:'var(--yellow)',fontSize:'8px'}}>β</span>} |
| <span>{fileIcon(f)}</span> |
| <span>{f.split('/').pop()}</span> |
| <span className="tab-close" onClick={e => closeFile(f, e)}>Γ</span> |
| </div> |
| ))} |
| </div> |
| )} |
| <div className="editor-container"> |
| {activeFile |
| ? <MonacoEditor |
| value={fileContents[activeFile] || ''} |
| path={activeFile} |
| language={getLanguage(activeFile)} |
| onChange={val => { setFileContents(prev => ({ ...prev, [activeFile]: val })); setModified(prev => ({ ...prev, [activeFile]: true })); }} |
| /> |
| : <div className="empty-state"><div className="empty-icon">π</div><div>Click a file to open it</div></div> |
| } |
| </div> |
| </div> |
| </div> |
| ); |
| }; |
|
|
| |
| const NewProjectModal = ({ onClose, onCreate }) => { |
| const [name, setName] = useState(''); |
| const [template, setTemplate] = useState('empty'); |
| const templates = [ |
| { id: 'empty', label: 'β¬ Empty' }, |
| { id: 'react', label: 'βοΈ React' }, |
| { id: 'node', label: 'π© Node.js' }, |
| { id: 'python', label: 'π Python' }, |
| { id: 'html', label: 'π HTML/CSS' }, |
| ]; |
|
|
| return ( |
| <div className="modal-overlay" onClick={onClose}> |
| <div className="modal" onClick={e => e.stopPropagation()}> |
| <div className="modal-title">+ New Project</div> |
| <div className="modal-field"> |
| <label className="modal-label">Project Name</label> |
| <input className="modal-input" value={name} onChange={e => setName(e.target.value.replace(/\s+/g, '-').toLowerCase())} placeholder="my-project" autoFocus /> |
| </div> |
| <div className="modal-field"> |
| <label className="modal-label">Template</label> |
| <div className="template-grid"> |
| {templates.map(t => ( |
| <button key={t.id} className={`template-btn ${template === t.id ? 'selected' : ''}`} onClick={() => setTemplate(t.id)}>{t.label}</button> |
| ))} |
| </div> |
| </div> |
| <div className="modal-actions"> |
| <button className="btn btn-secondary" onClick={onClose}>Cancel</button> |
| <button className="btn btn-primary" disabled={!name} onClick={() => { onCreate(name, template); onClose(); }}>Create βΆ</button> |
| </div> |
| </div> |
| </div> |
| ); |
| }; |
|
|
| |
| const App = () => { |
| const [projects, setProjects] = useState([]); |
| const [activeProject, setActiveProject] = useState(null); |
| const [files, setFiles] = useState([]); |
| const [showProjectMenu, setShowProjectMenu] = useState(false); |
| const [showNewProject, setShowNewProject] = useState(false); |
| const [consoleLogs, setConsoleLogs] = useState([]); |
|
|
| |
| const [leftTab, setLeftTab] = useState('agent'); |
|
|
| |
| const [rightTabs, setRightTabs] = useState([ |
| { id: 'editor', label: 'π Editor', fixed: true }, |
| { id: 'files', label: 'π Files', fixed: true }, |
| { id: 'console', label: 'β¬ Console', fixed: true }, |
| ]); |
| const [activeRightTab, setActiveRightTab] = useState('editor'); |
| const [showTabPopup, setShowTabPopup] = useState(false); |
| const [showLeftPopup, setShowLeftPopup] = useState(false); |
|
|
| |
| const [mobileTab, setMobileTab] = useState('agent'); |
|
|
| useEffect(() => { loadProjects(); }, []); |
| useEffect(() => { if (activeProject) loadFiles(); }, [activeProject]); |
| useEffect(() => { |
| const saved = localStorage.getItem('xro-active-project'); |
| if (saved) setActiveProject(saved); |
| }, [projects]); |
|
|
| const loadProjects = async () => { |
| const res = await fetch(`${API}/api/projects`); |
| const data = await res.json(); |
| setProjects(data); |
| }; |
|
|
| const loadFiles = async () => { |
| const res = await fetch(`${API}/api/files/${activeProject}`); |
| const data = await res.json(); |
| setFiles(data); |
| }; |
|
|
| const createProject = async (name, template) => { |
| await fetch(`${API}/api/projects`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ name, template }), |
| }); |
| await loadProjects(); |
| setActiveProject(name); |
| localStorage.setItem('xro-active-project', name); |
| }; |
|
|
| const selectProject = (name) => { |
| setActiveProject(name); |
| localStorage.setItem('xro-active-project', name); |
| setShowProjectMenu(false); |
| }; |
|
|
| const addConsoleLog = (log) => { |
| setConsoleLogs(prev => [...prev, { ...log, time: timestamp() }]); |
| }; |
|
|
| const addRightTab = (type) => { |
| const id = `${type}-${Date.now()}`; |
| const labels = { shell: '>_ Shell', preview: 'π Preview' }; |
| setRightTabs(prev => [...prev, { id, type, label: labels[type] || type }]); |
| setActiveRightTab(id); |
| setShowTabPopup(false); |
| }; |
|
|
| const closeRightTab = (id, e) => { |
| e.stopPropagation(); |
| setRightTabs(prev => prev.filter(t => t.id !== id)); |
| if (activeRightTab === id) setActiveRightTab('editor'); |
| }; |
|
|
| const renderRightContent = () => { |
| const tab = rightTabs.find(t => t.id === activeRightTab); |
| if (!tab) return null; |
| if (tab.id === 'editor') return <div className="tab-content"><MonacoEditor value="" onChange={() => {}} /></div>; |
| if (tab.id === 'files') return <div className="tab-content"><FilesPanel project={activeProject} files={files} onRefresh={loadFiles} /></div>; |
| if (tab.id === 'console') return <div className="tab-content"><ConsolePanel logs={consoleLogs} /></div>; |
| if (tab.type === 'shell') return <div className="tab-content"><ShellPanel project={activeProject} /></div>; |
| if (tab.type === 'preview') return <div className="tab-content"><PreviewPanel /></div>; |
| return null; |
| }; |
|
|
| |
| const mobileLeftActive = ['agent', 'assistant'].includes(mobileTab); |
| const mobileRightActive = ['editor', 'files', 'console'].includes(mobileTab); |
| if (mobileLeftActive && mobileTab !== leftTab) { |
| |
| } |
|
|
| return ( |
| <div className="ide" onClick={() => { setShowProjectMenu(false); setShowTabPopup(false); setShowLeftPopup(false); }}> |
| {/* Header */} |
| <div className="header"> |
| <div className="header-logo"> |
| <div className="header-logo-dot" /> |
| XRO IDE |
| </div> |
| |
| {/* Project switcher */} |
| <div className="project-switcher" onClick={e => e.stopPropagation()}> |
| <button className="project-btn" onClick={() => setShowProjectMenu(!showProjectMenu)}> |
| π {activeProject || 'Select Project'} βΎ |
| </button> |
| {showProjectMenu && ( |
| <div className="project-dropdown"> |
| {projects.map(p => ( |
| <div key={p.name} className={`project-dropdown-item ${activeProject === p.name ? 'active' : ''}`} onClick={() => selectProject(p.name)}> |
| {activeProject === p.name ? 'β' : 'β'} {p.name} |
| </div> |
| ))} |
| {projects.length > 0 && <div className="project-dropdown-divider" />} |
| <div className="new-project-btn" onClick={() => { setShowNewProject(true); setShowProjectMenu(false); }}> |
| + New Project |
| </div> |
| </div> |
| )} |
| </div> |
| |
| <div className="header-spacer" /> |
| <button className="settings-btn">β</button> |
| </div> |
| |
| {/* Main */} |
| <div className="main"> |
| {/* Left Panel */} |
| <div className={`left-panel ${mobileLeftActive ? 'mobile-active' : ''}`}> |
| <div className="left-tabs" onClick={e => e.stopPropagation()}> |
| <div className={`tab ${leftTab === 'agent' ? 'active' : ''}`} onClick={() => setLeftTab('agent')}>β‘ Agent</div> |
| <div className={`tab ${leftTab === 'assistant' ? 'active' : ''}`} onClick={() => setLeftTab('assistant')}>π¬ Assistant</div> |
| <div className="header-spacer" /> |
| <div className="tab-add" onClick={() => setShowLeftPopup(!showLeftPopup)}> |
| + |
| {showLeftPopup && ( |
| <div className="tab-popup"> |
| <div className="tab-popup-item" onClick={() => { setLeftTab('agent'); setShowLeftPopup(false); }}>β‘ New Agent Tab</div> |
| <div className="tab-popup-item" onClick={() => { setLeftTab('assistant'); setShowLeftPopup(false); }}>π¬ New Chat</div> |
| </div> |
| )} |
| </div> |
| </div> |
| <div className="tab-content"> |
| {leftTab === 'agent' |
| ? <AgentPanel project={activeProject} onFilesUpdate={(f) => setFiles(f)} onConsoleLog={addConsoleLog} /> |
| : <AssistantPanel /> |
| } |
| </div> |
| </div> |
| |
| {/* Right Panel */} |
| <div className={`right-panel ${mobileRightActive ? 'mobile-active' : ''}`}> |
| <div className="right-tabs" onClick={e => e.stopPropagation()}> |
| {rightTabs.map(tab => ( |
| <div key={tab.id} className={`tab ${activeRightTab === tab.id ? 'active' : ''}`} onClick={() => setActiveRightTab(tab.id)}> |
| {tab.label} |
| {!tab.fixed && <span className="tab-close" onClick={e => closeRightTab(tab.id, e)}>Γ</span>} |
| </div> |
| ))} |
| <div className="tab-add" style={{position:'relative'}} onClick={() => setShowTabPopup(!showTabPopup)}> |
| + |
| {showTabPopup && ( |
| <div className="tab-popup"> |
| <div className="tab-popup-item" onClick={() => addRightTab('shell')}>{'>'} New Shell</div> |
| <div className="tab-popup-item" onClick={() => addRightTab('preview')}>π New Preview</div> |
| </div> |
| )} |
| </div> |
| </div> |
| {renderRightContent()} |
| </div> |
| </div> |
| |
| {/* Mobile Nav */} |
| <div className="mobile-nav"> |
| {[ |
| { id: 'agent', icon: 'β‘', label: 'Agent' }, |
| { id: 'assistant', icon: 'π¬', label: 'Chat' }, |
| { id: 'editor', icon: 'π', label: 'Code' }, |
| { id: 'files', icon: 'π', label: 'Files' }, |
| { id: 'console', icon: 'β¬', label: 'Console' }, |
| ].map(item => ( |
| <div key={item.id} className={`mobile-nav-item ${mobileTab === item.id ? 'active' : ''}`} |
| onClick={() => { |
| setMobileTab(item.id); |
| if (['agent','assistant'].includes(item.id)) setLeftTab(item.id); |
| else setActiveRightTab(item.id); |
| }}> |
| <span className="mobile-nav-item-icon">{item.icon}</span> |
| <span>{item.label}</span> |
| </div> |
| ))} |
| </div> |
| |
| {/* Modals */} |
| {showNewProject && <NewProjectModal onClose={() => setShowNewProject(false)} onCreate={createProject} />} |
| </div> |
| ); |
| }; |
|
|
| ReactDOM.createRoot(document.getElementById('root')).render(<App />); |
|
|