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' // Monaco language by extension / filename. // // We pick from the language IDs Monaco bundles natively — the editor lazy- // loads the appropriate tokenizer when the file is opened. Anything we // don't recognize falls through to 'plaintext' (no highlighting) rather // than masquerading as JavaScript, which used to silently mis-highlight // .py files in the FastAPI templates. // // To add a language: drop its extensions into _EXT_LANG; if Monaco doesn't // ship it natively, fall back to a syntactically-similar one (e.g. // .prisma → graphql is a closer match than javascript). const _EXT_LANG = { // JS / TS family js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript', ts: 'typescript', tsx: 'typescript', // Python py: 'python', pyi: 'python', pyx: 'python', // Web css: 'css', scss: 'scss', sass: 'scss', less: 'less', html: 'html', htm: 'html', svg: 'xml', xml: 'xml', vue: 'html', svelte: 'html', // Data / config json: 'json', json5: 'json', jsonc: 'json', yml: 'yaml', yaml: 'yaml', toml: 'ini', ini: 'ini', env: 'ini', // DB / schema sql: 'sql', prisma:'graphql', // close-enough tokenizer graphql:'graphql', gql:'graphql', // Docs md: 'markdown', mdx: 'markdown', // Shell sh: 'shell', bash: 'shell', zsh: 'shell', fish: 'shell', // Container / infra dockerfile:'dockerfile', // Systems languages 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', // Functional / scripting lua: 'lua', pl: 'perl', r: 'r', scala: 'scala', dart: 'dart', // Misc txt: 'plaintext', log: 'plaintext', } // Extensionless conventional names — handled before the extension lookup. 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] // Match the longest known multi-dot suffix first (e.g. ".env.local" already // caught by _NAME_LANG; ".d.ts" handled here). if (base.endsWith('.d.ts')) return 'typescript' const dot = base.lastIndexOf('.') if (dot === -1) return 'plaintext' return _EXT_LANG[base.slice(dot + 1)] || 'plaintext' } // 500ms after the last keystroke before we PUT the file. Long enough that // rapid typing produces one save, short enough that a quick tab-switch // doesn't risk data loss. const SAVE_DEBOUNCE_MS = 500 function TrashIcon() { return ( ) } function FileIcon({ active }) { return ( ) } function FolderIcon() { return ( ) } // Depth from a flat path like "frontend/src/App.jsx" → 2 function depthOf(path) { return path.split('/').length - 1 } // Display name = last segment. 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, // already depth-first ordered by backend [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) // { x, y, path } 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]) // Flush any pending save timers when the panel unmounts (e.g. on mode // switch) so we don't leave them hanging. We capture the ref's current // value at unmount time so the eslint hook-deps rule is satisfied. 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 // No leading slash — paths are relative to the project root. const path = trimmed.replace(/^\/+/, '') if (project.files[path] != null) { // Just open it. 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 (
No project loaded.
)} {project.id && fileEntries.length === 0 && !isAddingFile && (
Empty project.
Click + to add a file.
{project.id ? 'Click a file in the tree to start editing.' : 'Open or create a session to begin.'}