Tesseract / studio /src /components /CodePanel.jsx
rkbosamia's picture
Tesseract major version upgrade - v2
9c49532
Raw
History Blame Contribute Delete
17.7 kB
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 (
<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>
)
}
// 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 (
<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>
)
}