| import { useState, useRef, useEffect, useMemo } from 'react' |
| import Editor from '@monaco-editor/react' |
| import useAppStore from '../store/appStore' |
| import { useCopyToClipboard } from '../hooks/useCopyToClipboard' |
| import { |
| deleteFile as apiDeleteFile, |
| writeFile as apiWriteFile, |
| } from '../api/projects' |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const _EXT_LANG = { |
| |
| js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript', |
| ts: 'typescript', tsx: 'typescript', |
| |
| py: 'python', pyi: 'python', pyx: 'python', |
| |
| css: 'css', scss: 'scss', sass: 'scss', less: 'less', |
| html: 'html', htm: 'html', svg: 'xml', xml: 'xml', |
| vue: 'html', svelte: 'html', |
| |
| json: 'json', json5: 'json', jsonc: 'json', |
| yml: 'yaml', yaml: 'yaml', |
| toml: 'ini', ini: 'ini', env: 'ini', |
| |
| sql: 'sql', |
| prisma:'graphql', |
| graphql:'graphql', gql:'graphql', |
| |
| md: 'markdown', mdx: 'markdown', |
| |
| sh: 'shell', bash: 'shell', zsh: 'shell', fish: 'shell', |
| |
| dockerfile:'dockerfile', |
| |
| go: 'go', |
| rs: 'rust', |
| java: 'java', kt: 'kotlin', kts: 'kotlin', |
| swift: 'swift', |
| c: 'c', h: 'c', |
| cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp', hh: 'cpp', |
| cs: 'csharp', |
| rb: 'ruby', |
| php: 'php', |
| |
| lua: 'lua', pl: 'perl', r: 'r', scala: 'scala', dart: 'dart', |
| |
| txt: 'plaintext', log: 'plaintext', |
| } |
|
|
| |
| const _NAME_LANG = { |
| 'dockerfile': 'dockerfile', |
| 'containerfile': 'dockerfile', |
| 'makefile': 'makefile', |
| 'rakefile': 'ruby', |
| 'gemfile': 'ruby', |
| '.gitignore': 'ignore', |
| '.dockerignore': 'ignore', |
| '.env': 'ini', |
| '.env.local': 'ini', |
| '.env.development':'ini', |
| '.env.production': 'ini', |
| } |
|
|
| const LANG = (path = '') => { |
| if (!path) return 'plaintext' |
| const base = path.split('/').pop().toLowerCase() |
| if (_NAME_LANG[base]) return _NAME_LANG[base] |
| |
| |
| if (base.endsWith('.d.ts')) return 'typescript' |
| const dot = base.lastIndexOf('.') |
| if (dot === -1) return 'plaintext' |
| return _EXT_LANG[base.slice(dot + 1)] || 'plaintext' |
| } |
|
|
| |
| |
| |
| const SAVE_DEBOUNCE_MS = 500 |
|
|
| function TrashIcon() { |
| return ( |
| <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"> |
| <path d="M3 5h10M7 3h2" /> |
| <rect x="4" y="5" width="8" height="8" rx="1" /> |
| <path d="M6 8v3M10 8v3" /> |
| </svg> |
| ) |
| } |
|
|
| function FileIcon({ active }) { |
| return ( |
| <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" |
| strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" |
| style={{ color: active ? 'var(--accent-hi)' : 'var(--text-faint)', flexShrink: 0 }}> |
| <path d="M9 2H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V6L9 2z" /> |
| <path d="M9 2v4h4" /> |
| </svg> |
| ) |
| } |
|
|
| function FolderIcon() { |
| return ( |
| <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" |
| strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" |
| style={{ color: 'var(--text-faint)', flexShrink: 0 }}> |
| <path d="M2 4.5A1.5 1.5 0 0 1 3.5 3h3l1.5 1.5h4.5A1.5 1.5 0 0 1 14 6v6.5A1.5 1.5 0 0 1 12.5 14h-9A1.5 1.5 0 0 1 2 12.5v-8z" /> |
| </svg> |
| ) |
| } |
|
|
| |
| function depthOf(path) { |
| return path.split('/').length - 1 |
| } |
|
|
| |
| function baseName(path) { |
| const i = path.lastIndexOf('/') |
| return i === -1 ? path : path.slice(i + 1) |
| } |
|
|
| export default function CodePanel() { |
| const { |
| project, setProjectFile, removeProjectFile, addToTree, setActiveFile, markDirty, |
| runtime, theme, |
| } = useAppStore() |
|
|
| const fileEntries = useMemo( |
| () => project.tree, |
| [project.tree], |
| ) |
|
|
| const activeFile = project.activeFile |
| const currentCode = activeFile ? (project.files[activeFile] ?? '') : '' |
|
|
| const [isAddingFile, setIsAddingFile] = useState(false) |
| const [newFileName, setNewFileName] = useState('') |
| const [contextMenu, setContextMenu] = useState(null) |
|
|
| const { copy } = useCopyToClipboard() |
| const newFileInputRef = useRef(null) |
| const saveTimers = useRef(new Map()) |
|
|
| useEffect(() => { |
| if (isAddingFile) setTimeout(() => newFileInputRef.current?.focus(), 0) |
| }, [isAddingFile]) |
|
|
| useEffect(() => { |
| if (!contextMenu) return |
| const close = () => setContextMenu(null) |
| document.addEventListener('click', close) |
| return () => document.removeEventListener('click', close) |
| }, [contextMenu]) |
|
|
| |
| |
| |
| useEffect(() => { |
| const timers = saveTimers.current |
| return () => { |
| for (const t of timers.values()) clearTimeout(t) |
| timers.clear() |
| } |
| }, []) |
|
|
| function scheduleSave(path, content) { |
| if (!project.id) return |
| const timers = saveTimers.current |
| if (timers.has(path)) clearTimeout(timers.get(path)) |
| markDirty(path, true) |
| const t = setTimeout(async () => { |
| try { |
| await apiWriteFile(project.id, path, content) |
| } catch (e) { |
| console.error('save', path, e) |
| } finally { |
| markDirty(path, false) |
| timers.delete(path) |
| } |
| }, SAVE_DEBOUNCE_MS) |
| timers.set(path, t) |
| } |
|
|
| function handleEditorChange(value) { |
| if (!activeFile) return |
| const content = value || '' |
| setProjectFile(activeFile, content) |
| scheduleSave(activeFile, content) |
| } |
|
|
| async function handleDelete(path) { |
| setContextMenu(null) |
| if (!project.id) return |
| try { |
| await apiDeleteFile(project.id, path) |
| removeProjectFile(path) |
| } catch (e) { |
| console.error('delete', path, e) |
| } |
| } |
|
|
| async function commitNewFile() { |
| const trimmed = newFileName.trim() |
| setIsAddingFile(false) |
| setNewFileName('') |
| if (!trimmed || !project.id) return |
|
|
| |
| const path = trimmed.replace(/^\/+/, '') |
| if (project.files[path] != null) { |
| |
| setActiveFile(path) |
| return |
| } |
| try { |
| await apiWriteFile(project.id, path, '') |
| setProjectFile(path, '') |
| addToTree({ path, is_dir: false, size: 0 }) |
| setActiveFile(path) |
| } catch (e) { |
| console.error('create', path, e) |
| } |
| } |
|
|
| function handleContextMenu(e, path) { |
| e.preventDefault() |
| setContextMenu({ x: e.clientX, y: e.clientY, path }) |
| } |
|
|
| const fileFiles = fileEntries.filter((e) => !e.is_dir) |
| const hasAnyFile = fileFiles.length > 0 |
|
|
| return ( |
| <div className="flex h-full" style={{ background: 'var(--bg)' }}> |
| |
| {/* ββ File Explorer ββββββββββββββββββββββββββββββββββββββ */} |
| <div |
| className="flex flex-col shrink-0 border-r" |
| style={{ width: 220, background: 'var(--surface)', borderColor: 'var(--border)' }} |
| > |
| <div |
| className="flex items-center justify-between px-3 py-2 border-b shrink-0" |
| style={{ borderColor: 'var(--border)' }} |
| > |
| <span |
| className="text-[9.5px] font-semibold tracking-widest uppercase" |
| style={{ color: 'var(--text-faint)' }} |
| > |
| Files |
| </span> |
| <button |
| onClick={() => setIsAddingFile(true)} |
| disabled={!project.id} |
| className="w-5 h-5 rounded flex items-center justify-center disabled:opacity-30" |
| style={{ color: 'var(--text-dim)' }} |
| title="New file" |
| > |
| <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"> |
| <path d="M8 3v10M3 8h10" /> |
| </svg> |
| </button> |
| </div> |
| |
| <div className="flex-1 overflow-y-auto py-1"> |
| {!project.id && ( |
| <p className="text-[11px] px-3 py-2 leading-relaxed" style={{ color: 'var(--text-faint)' }}> |
| No project loaded. |
| </p> |
| )} |
| |
| {project.id && fileEntries.length === 0 && !isAddingFile && ( |
| <p className="text-[11px] px-3 py-2 leading-relaxed" style={{ color: 'var(--text-faint)' }}> |
| Empty project. |
| <br />Click + to add a file. |
| </p> |
| )} |
| |
| {fileEntries.map((entry) => { |
| const { path, is_dir } = entry |
| const isActive = !is_dir && activeFile === path |
| const indent = depthOf(path) * 10 + 10 |
| |
| if (is_dir) { |
| return ( |
| <div |
| key={path} |
| className="flex items-center gap-1.5 px-2 py-1 select-none" |
| style={{ paddingLeft: indent }} |
| > |
| <FolderIcon /> |
| <span |
| className="flex-1 text-[11px] mono truncate" |
| style={{ color: 'var(--text-faint)' }} |
| > |
| {baseName(path)} |
| </span> |
| </div> |
| ) |
| } |
| |
| const isDirty = project.dirty[path] |
| return ( |
| <div |
| key={path} |
| onClick={() => setActiveFile(path)} |
| onContextMenu={(e) => handleContextMenu(e, path)} |
| className="group relative flex items-center gap-1.5 py-1 cursor-pointer select-none" |
| style={{ |
| paddingLeft: indent, |
| paddingRight: 8, |
| background: isActive ? 'var(--accent-bg)' : 'transparent', |
| borderLeft: isActive ? '2px solid var(--accent)' : '2px solid transparent', |
| }} |
| title={path} |
| > |
| <FileIcon active={isActive} /> |
| <span |
| className="flex-1 text-[11px] mono truncate" |
| style={{ color: isActive ? 'var(--text)' : 'var(--text-dim)' }} |
| > |
| {baseName(path)} |
| </span> |
| {isDirty && ( |
| <span |
| className="w-1.5 h-1.5 rounded-full shrink-0" |
| style={{ background: 'var(--accent)' }} |
| title="Unsaved" |
| /> |
| )} |
| <button |
| onClick={(e) => { e.stopPropagation(); handleDelete(path) }} |
| className="opacity-0 group-hover:opacity-100 w-4 h-4 rounded flex items-center justify-center shrink-0 transition-opacity" |
| style={{ color: 'var(--text-faint)' }} |
| title="Delete file" |
| > |
| <TrashIcon /> |
| </button> |
| </div> |
| ) |
| })} |
| |
| {isAddingFile && ( |
| <div className="px-2.5 py-1.5"> |
| <input |
| ref={newFileInputRef} |
| value={newFileName} |
| onChange={(e) => setNewFileName(e.target.value)} |
| onBlur={commitNewFile} |
| onKeyDown={(e) => { |
| if (e.key === 'Enter') commitNewFile() |
| if (e.key === 'Escape') { setIsAddingFile(false); setNewFileName('') } |
| }} |
| placeholder="path/to/file.jsx" |
| className="w-full text-[11px] mono outline-none px-1.5 py-0.5 rounded" |
| style={{ |
| background: 'var(--surface-2)', |
| border: '1px solid var(--accent)', |
| color: 'var(--text)', |
| }} |
| /> |
| </div> |
| )} |
| </div> |
| </div> |
| |
| {/* ββ Editor area ββββββββββββββββββββββββββββββββββββββββ */} |
| <div className="flex-1 flex flex-col min-w-0"> |
| <div |
| className="flex items-center gap-2 px-3 py-1.5 border-b shrink-0" |
| style={{ background: 'var(--surface)', borderColor: 'var(--border)' }} |
| > |
| <span className="text-[12px] mono flex-1 truncate" style={{ color: 'var(--text-dim)' }}> |
| {activeFile || '(no file)'} |
| </span> |
| <div className="flex items-center gap-1.5 shrink-0"> |
| {activeFile && ( |
| <button |
| onClick={() => copy(currentCode, 'File copied')} |
| className="h-7 px-2.5 rounded text-[11.5px] flex items-center gap-1 transition-colors" |
| style={{ color: 'var(--text-dim)', border: '1px solid var(--border-2)' }} |
| title="Copy file" |
| > |
| <svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> |
| <rect x="4" y="4" width="9" height="9" rx="1.5" /> |
| <path d="M3 11V3.5A1.5 1.5 0 0 1 4.5 2H11" /> |
| </svg> |
| Copy |
| </button> |
| )} |
| {/* Server status badge β changes reflect via Vite HMR automatically */} |
| <div |
| className="flex items-center gap-1.5 px-2 h-7 rounded text-[10.5px] mono" |
| style={{ border: '1px solid var(--border-2)', color: 'var(--text-faint)' }} |
| title={runtime.status === 'running' ? `Dev server on :${runtime.frontendPort}` : 'Click Run in Preview to start dev servers'} |
| > |
| <span style={{ |
| width: 5, height: 5, borderRadius: '50%', flexShrink: 0, |
| background: runtime.status === 'running' ? '#22c55e' : 'var(--text-faint)', |
| }} /> |
| {runtime.status === 'running' ? `HMR :${runtime.frontendPort}` : 'offline'} |
| </div> |
| </div> |
| </div> |
| |
| <div className="flex-1 min-h-0"> |
| {!activeFile || !hasAnyFile ? ( |
| <div className="h-full flex flex-col items-center justify-center gap-2"> |
| <svg width="32" height="32" viewBox="0 0 16 16" fill="none" stroke="currentColor" |
| strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" |
| style={{ color: 'var(--text-faint)' }}> |
| <path d="M9 2H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V6L9 2z" /> |
| <path d="M9 2v4h4" /> |
| </svg> |
| <p className="text-[12.5px] text-center" style={{ color: 'var(--text-faint)' }}> |
| {project.id |
| ? 'Click a file in the tree to start editing.' |
| : 'Open or create a session to begin.'} |
| </p> |
| </div> |
| ) : ( |
| <Editor |
| height="100%" |
| path={activeFile} |
| language={LANG(activeFile)} |
| value={currentCode} |
| onChange={handleEditorChange} |
| theme={theme === 'light' ? 'vs' : 'vs-dark'} |
| options={{ |
| fontSize: 13, |
| fontFamily: "'JetBrains Mono', ui-monospace, monospace", |
| fontLigatures: false, |
| minimap: { enabled: false }, |
| scrollBeyondLastLine: false, |
| wordWrap: 'on', |
| lineNumbers: 'on', |
| padding: { top: 12 }, |
| renderLineHighlight: 'gutter', |
| smoothScrolling: true, |
| }} |
| /> |
| )} |
| </div> |
| </div> |
| |
| {/* ββ Right-click context menu βββββββββββββββββββββββββββ */} |
| {contextMenu && ( |
| <div |
| onClick={(e) => e.stopPropagation()} |
| style={{ |
| position: 'fixed', |
| top: contextMenu.y, |
| left: contextMenu.x, |
| zIndex: 1000, |
| background: 'var(--surface-3)', |
| border: '1px solid var(--border-2)', |
| borderRadius: 8, |
| padding: 4, |
| minWidth: 140, |
| boxShadow: '0 8px 24px rgba(0,0,0,0.5)', |
| }} |
| > |
| <button |
| onClick={() => handleDelete(contextMenu.path)} |
| className="w-full flex items-center gap-2 px-3 py-1.5 rounded text-[12px] text-left" |
| style={{ color: 'var(--danger)' }} |
| > |
| <TrashIcon /> |
| Delete |
| </button> |
| </div> |
| )} |
| </div> |
| ) |
| } |
|
|