This commit is contained in:
Rob Colbert 2026-05-12 19:57:27 -04:00
parent c14c3a235a
commit 37907ef098

View File

@ -1,6 +1,6 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from "react";
import Ace from 'react-ace'; import Ace from "react-ace";
import ace from 'ace-builds'; import ace from "ace-builds";
// ── Vite ?url imports for ACE modes ────────────────────────────────────── // ── Vite ?url imports for ACE modes ──────────────────────────────────────
// These resolve at build time to asset URLs. We register them with ACE's // These resolve at build time to asset URLs. We register them with ACE's
@ -10,44 +10,44 @@ import ace from 'ace-builds';
// See: https://github.com/ajaxorg/ace/issues/4597 // See: https://github.com/ajaxorg/ace/issues/4597
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
import modeJavascriptUrl from 'ace-builds/src-noconflict/mode-javascript?url'; import modeJavascriptUrl from "ace-builds/src-noconflict/mode-javascript?url";
import modeTypescriptUrl from 'ace-builds/src-noconflict/mode-typescript?url'; import modeTypescriptUrl from "ace-builds/src-noconflict/mode-typescript?url";
import modePythonUrl from 'ace-builds/src-noconflict/mode-python?url'; import modePythonUrl from "ace-builds/src-noconflict/mode-python?url";
import modeJsonUrl from 'ace-builds/src-noconflict/mode-json?url'; import modeJsonUrl from "ace-builds/src-noconflict/mode-json?url";
import modeHtmlUrl from 'ace-builds/src-noconflict/mode-html?url'; import modeHtmlUrl from "ace-builds/src-noconflict/mode-html?url";
import modeCssUrl from 'ace-builds/src-noconflict/mode-css?url'; import modeCssUrl from "ace-builds/src-noconflict/mode-css?url";
import modeYamlUrl from 'ace-builds/src-noconflict/mode-yaml?url'; import modeYamlUrl from "ace-builds/src-noconflict/mode-yaml?url";
import modeMarkdownUrl from 'ace-builds/src-noconflict/mode-markdown?url'; import modeMarkdownUrl from "ace-builds/src-noconflict/mode-markdown?url";
import modeShUrl from 'ace-builds/src-noconflict/mode-sh?url'; import modeShUrl from "ace-builds/src-noconflict/mode-sh?url";
import modeSqlUrl from 'ace-builds/src-noconflict/mode-sql?url'; import modeSqlUrl from "ace-builds/src-noconflict/mode-sql?url";
import modeJavaUrl from 'ace-builds/src-noconflict/mode-java?url'; import modeJavaUrl from "ace-builds/src-noconflict/mode-java?url";
import modeGolangUrl from 'ace-builds/src-noconflict/mode-golang?url'; import modeGolangUrl from "ace-builds/src-noconflict/mode-golang?url";
import modeRustUrl from 'ace-builds/src-noconflict/mode-rust?url'; import modeRustUrl from "ace-builds/src-noconflict/mode-rust?url";
import modeCsharpUrl from 'ace-builds/src-noconflict/mode-csharp?url'; import modeCsharpUrl from "ace-builds/src-noconflict/mode-csharp?url";
import modePhpUrl from 'ace-builds/src-noconflict/mode-php?url'; import modePhpUrl from "ace-builds/src-noconflict/mode-php?url";
import modeRubyUrl from 'ace-builds/src-noconflict/mode-ruby?url'; import modeRubyUrl from "ace-builds/src-noconflict/mode-ruby?url";
import modeCcppUrl from 'ace-builds/src-noconflict/mode-c_cpp?url'; import modeCcppUrl from "ace-builds/src-noconflict/mode-c_cpp?url";
import modeScssUrl from 'ace-builds/src-noconflict/mode-scss?url'; import modeScssUrl from "ace-builds/src-noconflict/mode-scss?url";
import modeLessUrl from 'ace-builds/src-noconflict/mode-less?url'; import modeLessUrl from "ace-builds/src-noconflict/mode-less?url";
import modeXmlUrl from 'ace-builds/src-noconflict/mode-xml?url'; import modeXmlUrl from "ace-builds/src-noconflict/mode-xml?url";
import modeDockerfileUrl from 'ace-builds/src-noconflict/mode-dockerfile?url'; import modeDockerfileUrl from "ace-builds/src-noconflict/mode-dockerfile?url";
import modeMakefileUrl from 'ace-builds/src-noconflict/mode-makefile?url'; import modeMakefileUrl from "ace-builds/src-noconflict/mode-makefile?url";
import modeSassUrl from 'ace-builds/src-noconflict/mode-sass?url'; import modeSassUrl from "ace-builds/src-noconflict/mode-sass?url";
import modeTextUrl from 'ace-builds/src-noconflict/mode-text?url'; import modeTextUrl from "ace-builds/src-noconflict/mode-text?url";
// Workers (for syntax validation — currently disabled via useWorker:false, // Workers (for syntax validation — currently disabled via useWorker:false,
// but registered in case we want to enable them later) // but registered in case we want to enable them later)
import workerJavascriptUrl from 'ace-builds/src-noconflict/worker-javascript?url'; import workerJavascriptUrl from "ace-builds/src-noconflict/worker-javascript?url";
import workerJsonUrl from 'ace-builds/src-noconflict/worker-json?url'; import workerJsonUrl from "ace-builds/src-noconflict/worker-json?url";
import workerCssUrl from 'ace-builds/src-noconflict/worker-css?url'; import workerCssUrl from "ace-builds/src-noconflict/worker-css?url";
import workerHtmlUrl from 'ace-builds/src-noconflict/worker-html?url'; import workerHtmlUrl from "ace-builds/src-noconflict/worker-html?url";
// Theme // Theme
import themeTomorrowUrl from 'ace-builds/src-noconflict/theme-tomorrow?url'; import themeTomorrowUrl from "ace-builds/src-noconflict/theme-tomorrow?url";
// Extensions // Extensions
import extLanguageToolsUrl from 'ace-builds/src-noconflict/ext-language_tools?url'; import extLanguageToolsUrl from "ace-builds/src-noconflict/ext-language_tools?url";
import extSearchboxUrl from 'ace-builds/src-noconflict/ext-searchbox?url'; import extSearchboxUrl from "ace-builds/src-noconflict/ext-searchbox?url";
// ── Register all modules with ACE ──────────────────────────────────────── // ── Register all modules with ACE ────────────────────────────────────────
@ -80,7 +80,7 @@ const MODE_URLS: Record<string, string> = {
const WORKER_URLS: Record<string, string> = { const WORKER_URLS: Record<string, string> = {
javascript: workerJavascriptUrl, javascript: workerJavascriptUrl,
typescript: workerJavascriptUrl, // TS mode shares the JS worker typescript: workerJavascriptUrl, // TS mode shares the JS worker
json: workerJsonUrl, json: workerJsonUrl,
css: workerCssUrl, css: workerCssUrl,
html: workerHtmlUrl, html: workerHtmlUrl,
@ -97,14 +97,14 @@ for (const [mode, url] of Object.entries(WORKER_URLS)) {
} }
// Register theme and extensions // Register theme and extensions
ace.config.setModuleUrl('ace/theme/tomorrow', themeTomorrowUrl); ace.config.setModuleUrl("ace/theme/tomorrow", themeTomorrowUrl);
ace.config.setModuleUrl('ace/ext/language_tools', extLanguageToolsUrl); ace.config.setModuleUrl("ace/ext/language_tools", extLanguageToolsUrl);
ace.config.setModuleUrl('ace/ext/searchbox', extSearchboxUrl); ace.config.setModuleUrl("ace/ext/searchbox", extSearchboxUrl);
// ── Component ──────────────────────────────────────────────────────────── // ── Component ────────────────────────────────────────────────────────────
import { WorkspaceMode } from '../lib/types'; import { WorkspaceMode } from "../lib/types";
import { socketClient } from '../lib/socket'; import { socketClient } from "../lib/socket";
interface EditorPanelProps { interface EditorPanelProps {
workspaceMode: WorkspaceMode; workspaceMode: WorkspaceMode;
@ -125,51 +125,55 @@ interface EditorState {
// Map file extensions to ACE language modes // Map file extensions to ACE language modes
function detectLanguage(filePath: string): string { function detectLanguage(filePath: string): string {
const ext = filePath.split('.').pop()?.toLowerCase(); const ext = filePath.split(".").pop()?.toLowerCase();
const languageMap: Record<string, string> = { const languageMap: Record<string, string> = {
'js': 'javascript', js: "javascript",
'jsx': 'javascript', jsx: "javascript",
'ts': 'typescript', ts: "typescript",
'tsx': 'typescript', tsx: "typescript",
'py': 'python', py: "python",
'rb': 'ruby', rb: "ruby",
'java': 'java', java: "java",
'c': 'c_cpp', c: "c_cpp",
'cpp': 'c_cpp', cpp: "c_cpp",
'h': 'c_cpp', h: "c_cpp",
'hpp': 'c_cpp', hpp: "c_cpp",
'cs': 'csharp', cs: "csharp",
'go': 'golang', go: "golang",
'rs': 'rust', rs: "rust",
'php': 'php', php: "php",
'html': 'html', html: "html",
'htm': 'html', htm: "html",
'css': 'css', css: "css",
'scss': 'scss', scss: "scss",
'sass': 'sass', sass: "sass",
'less': 'less', less: "less",
'json': 'json', json: "json",
'xml': 'xml', xml: "xml",
'yaml': 'yaml', yaml: "yaml",
'yml': 'yaml', yml: "yaml",
'md': 'markdown', md: "markdown",
'sql': 'sql', sql: "sql",
'sh': 'sh', sh: "sh",
'bash': 'sh', bash: "sh",
'dockerfile': 'dockerfile', dockerfile: "dockerfile",
'makefile': 'makefile', makefile: "makefile",
}; };
return languageMap[ext || ''] || 'text'; return languageMap[ext || ""] || "text";
} }
export default function EditorPanel({ workspaceMode, filePath, onCloseFile }: EditorPanelProps) { export default function EditorPanel({
workspaceMode,
filePath,
onCloseFile,
}: EditorPanelProps) {
const [state, setState] = useState<EditorState>({ const [state, setState] = useState<EditorState>({
content: '', content: "",
originalContent: '', originalContent: "",
isDirty: false, isDirty: false,
isLoading: false, isLoading: false,
isSaving: false, isSaving: false,
language: 'text', language: "text",
}); });
const isReadOnly = workspaceMode === WorkspaceMode.Agent; const isReadOnly = workspaceMode === WorkspaceMode.Agent;
@ -182,18 +186,18 @@ export default function EditorPanel({ workspaceMode, filePath, onCloseFile }: Ed
} else { } else {
// Clear editor when no file is selected // Clear editor when no file is selected
setState({ setState({
content: '', content: "",
originalContent: '', originalContent: "",
isDirty: false, isDirty: false,
isLoading: false, isLoading: false,
isSaving: false, isSaving: false,
language: 'text', language: "text",
}); });
} }
}, [filePath]); }, [filePath]);
const loadFile = useCallback(async (path: string) => { const loadFile = useCallback(async (path: string) => {
setState(prev => ({ ...prev, isLoading: true, error: undefined })); setState((prev) => ({ ...prev, isLoading: true, error: undefined }));
try { try {
const result = await socketClient.requestFileRead({ path }); const result = await socketClient.requestFileRead({ path });
@ -201,7 +205,6 @@ export default function EditorPanel({ workspaceMode, filePath, onCloseFile }: Ed
if (result.success && result.content !== undefined) { if (result.success && result.content !== undefined) {
const language = detectLanguage(path); const language = detectLanguage(path);
// No dynamic import needed — all modes are registered via setModuleUrl at module load time // No dynamic import needed — all modes are registered via setModuleUrl at module load time
setState({ setState({
content: result.content, content: result.content,
originalContent: result.content, originalContent: result.content,
@ -211,17 +214,17 @@ export default function EditorPanel({ workspaceMode, filePath, onCloseFile }: Ed
language, language,
}); });
} else { } else {
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
isLoading: false, isLoading: false,
error: result.error || 'Failed to load file', error: result.error || "Failed to load file",
})); }));
} }
} catch (error) { } catch (error) {
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
isLoading: false, isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load file', error: error instanceof Error ? error.message : "Failed to load file",
})); }));
} }
}, []); }, []);
@ -229,7 +232,7 @@ export default function EditorPanel({ workspaceMode, filePath, onCloseFile }: Ed
const saveFile = useCallback(async () => { const saveFile = useCallback(async () => {
if (!filePath || isReadOnly) return; if (!filePath || isReadOnly) return;
setState(prev => ({ ...prev, isSaving: true, error: undefined })); setState((prev) => ({ ...prev, isSaving: true, error: undefined }));
try { try {
const result = await socketClient.requestFileWrite({ const result = await socketClient.requestFileWrite({
@ -238,56 +241,59 @@ export default function EditorPanel({ workspaceMode, filePath, onCloseFile }: Ed
}); });
if (result.success) { if (result.success) {
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
isSaving: false, isSaving: false,
originalContent: prev.content, originalContent: prev.content,
isDirty: false, isDirty: false,
successMessage: 'File saved successfully', successMessage: "File saved successfully",
})); }));
// Clear success message after 3 seconds // Clear success message after 3 seconds
setTimeout(() => { setTimeout(() => {
setState(prev => ({ ...prev, successMessage: undefined })); setState((prev) => ({ ...prev, successMessage: undefined }));
}, 3000); }, 3000);
} else { } else {
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
isSaving: false, isSaving: false,
error: result.error || 'Failed to save file', error: result.error || "Failed to save file",
})); }));
} }
} catch (error) { } catch (error) {
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
isSaving: false, isSaving: false,
error: error instanceof Error ? error.message : 'Failed to save file', error: error instanceof Error ? error.message : "Failed to save file",
})); }));
} }
}, [filePath, state.content, isReadOnly]); }, [filePath, state.content, isReadOnly]);
const handleContentChange = useCallback((newValue: string) => { const handleContentChange = useCallback((newValue: string) => {
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
content: newValue, content: newValue,
isDirty: newValue !== prev.originalContent, isDirty: newValue !== prev.originalContent,
})); }));
}, []); }, []);
const handleKeyDown = useCallback((e: KeyboardEvent) => { const handleKeyDown = useCallback(
// Ctrl+S or Cmd+S to save (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') { // Ctrl+S or Cmd+S to save
e.preventDefault(); if ((e.ctrlKey || e.metaKey) && e.key === "s") {
if (!isReadOnly && state.isDirty) { e.preventDefault();
saveFile(); if (!isReadOnly && state.isDirty) {
saveFile();
}
} }
} },
}, [saveFile, isReadOnly, state.isDirty]); [saveFile, isReadOnly, state.isDirty],
);
// Add keyboard listener // Add keyboard listener
useEffect(() => { useEffect(() => {
window.addEventListener('keydown', handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]); }, [handleKeyDown]);
// If no file is selected, show placeholder // If no file is selected, show placeholder
@ -295,8 +301,18 @@ export default function EditorPanel({ workspaceMode, filePath, onCloseFile }: Ed
return ( return (
<div className="flex-1 flex items-center justify-center bg-bg-secondary"> <div className="flex-1 flex items-center justify-center bg-bg-secondary">
<div className="text-center text-text-muted"> <div className="text-center text-text-muted">
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> className="w-16 h-16 mx-auto mb-4 opacity-50"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg> </svg>
<p className="text-lg font-medium">No file open</p> <p className="text-lg font-medium">No file open</p>
<p className="text-sm mt-2">Select a file from the tree to edit</p> <p className="text-sm mt-2">Select a file from the tree to edit</p>
@ -332,17 +348,17 @@ export default function EditorPanel({ workspaceMode, filePath, onCloseFile }: Ed
Read-only (Agent mode) Read-only (Agent mode)
</span> </span>
)} )}
<button <button
onClick={saveFile} onClick={saveFile}
disabled={isReadOnly || !state.isDirty || state.isSaving} disabled={isReadOnly || !state.isDirty || state.isSaving}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${ className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
isReadOnly || !state.isDirty || state.isSaving isReadOnly || !state.isDirty || state.isSaving
? 'bg-bg-secondary text-text-muted cursor-not-allowed' ? "bg-bg-secondary text-text-muted cursor-not-allowed"
: 'bg-brand text-white hover:bg-brand/80' : "bg-brand text-white hover:bg-brand/80"
}`} }`}
> >
{state.isSaving ? 'Saving...' : 'Save'} {state.isSaving ? "Saving..." : "Save"}
</button> </button>
{onCloseFile && ( {onCloseFile && (
@ -351,8 +367,18 @@ export default function EditorPanel({ workspaceMode, filePath, onCloseFile }: Ed
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-bg-secondary rounded transition-colors" className="p-1.5 text-text-muted hover:text-text-primary hover:bg-bg-secondary rounded transition-colors"
title="Close file" title="Close file"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
)} )}
@ -363,8 +389,18 @@ export default function EditorPanel({ workspaceMode, filePath, onCloseFile }: Ed
{state.error && ( {state.error && (
<div className="px-4 py-3 bg-red-500/10 border-b border-red-500/20"> <div className="px-4 py-3 bg-red-500/10 border-b border-red-500/20">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
<div className="flex-1"> <div className="flex-1">
<p className="text-sm text-red-500">{state.error}</p> <p className="text-sm text-red-500">{state.error}</p>