Files
NeoCode/src/App.jsx
2026-01-13 00:31:55 -06:00

1261 lines
50 KiB
JavaScript

import React, { useState, useEffect, useRef } from 'react';
import { FileCode, Plus, X, FolderOpen, FileText, Save, Download, Copy, Trash2, Eye, EyeOff, Play, RotateCw } from 'lucide-react';
import * as monaco from 'monaco-editor';
// Import workers
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
// Configure Monaco Environment
self.MonacoEnvironment = {
getWorker(_, label) {
if (label === 'json') return new jsonWorker();
if (label === 'css' || label === 'scss' || label === 'less') return new cssWorker();
if (label === 'html' || label === 'handlebars' || label === 'razor') return new htmlWorker();
if (label === 'typescript' || label === 'javascript') return new tsWorker();
return new editorWorker();
}
};
const MonacoEditor = () => {
const [files, setFiles] = useState(() => {
// Load saved files from localStorage on mount
try {
const saved = localStorage.getItem('monaco-editor-files');
if (saved) {
const parsed = JSON.parse(saved);
console.log('[Storage] Restored', parsed.length, 'files from localStorage');
return parsed;
}
} catch (err) {
console.error('[Storage] Error loading files:', err);
}
return [];
});
const [activeFileId, setActiveFileId] = useState(() => {
// Load active file ID from localStorage
try {
const saved = localStorage.getItem('monaco-editor-active-file');
if (saved) {
console.log('[Storage] Restored active file:', saved);
return JSON.parse(saved);
}
} catch (err) {
console.error('[Storage] Error loading active file:', err);
}
return null;
});
const [showPreview, setShowPreview] = useState(false);
const [previewContent, setPreviewContent] = useState('');
const [pythonOutput, setPythonOutput] = useState('');
const editorRef = useRef(null);
const containerRef = useRef(null);
const modelsRef = useRef({});
const iframeRef = useRef(null);
const [error, setError] = useState(null);
const [selectedPreviewFile, setSelectedPreviewFile] = useState(null);
const [filePaths, setFilePaths] = useState({}); // Store file paths: { fileId: realPath }
// Add these state variables with your other useState declarations
const [showPrompt, setShowPrompt] = useState(false);
const [promptValue, setPromptValue] = useState('');
const [promptCallback, setPromptCallback] = useState(null);
const [workspaceFolder, setWorkspaceFolder] = useState(null);
const [folderContents, setFolderContents] = useState([]);
const [expandedFolders, setExpandedFolders] = useState(new Set());
const [showExplorer, setShowExplorer] = useState(true);
const [contextMenu, setContextMenu] = useState(null);
const [draggedItem, setDraggedItem] = useState(null);
// Add this custom prompt function
const customPrompt = (message, defaultValue = '') => {
return new Promise((resolve) => {
setPromptValue(defaultValue);
setShowPrompt(true);
setPromptCallback(() => resolve);
});
};
// Save files to localStorage whenever they change
useEffect(() => {
if (files.length > 0) {
try {
localStorage.setItem('monaco-editor-files', JSON.stringify(files));
console.log('[Storage] Saved', files.length, 'files to localStorage');
} catch (err) {
console.error('[Storage] Error saving files:', err);
}
}
}, [files]);
// Save active file ID whenever it changes
useEffect(() => {
if (activeFileId !== null) {
try {
localStorage.setItem('monaco-editor-active-file', JSON.stringify(activeFileId));
console.log('[Storage] Saved active file ID:', activeFileId);
} catch (err) {
console.error('[Storage] Error saving active file ID:', err);
}
}
}, [activeFileId]);
useEffect(() => {
if (!containerRef.current || editorRef.current) return;
try {
const editor = monaco.editor.create(containerRef.current, {
value: '// Start typing...',
language: 'javascript',
theme: 'vs-dark',
automaticLayout: true,
fontSize: 14,
minimap: { enabled: true },
scrollbar: { vertical: 'visible', horizontal: 'visible' }
});
editorRef.current = editor;
editor.onDidChangeModelContent(() => {
if (activeFileId && modelsRef.current[activeFileId]) {
const content = editor.getValue();
setFiles(prev => prev.map(f =>
f.id === activeFileId ? { ...f, content, modified: true } : f
));
}
});
} catch (err) {
setError(`Failed to create editor: ${err.message}`);
}
return () => {
if (editorRef.current) {
editorRef.current.dispose();
editorRef.current = null;
}
Object.values(modelsRef.current).forEach(model => {
if (model && !model.isDisposed()) model.dispose();
});
};
}, [files.length]);
useEffect(() => {
if (!editorRef.current) {
console.log('[Monaco] Editor not ready, skipping model update');
return;
}
const activeFile = files.find(f => f.id === activeFileId);
console.log('[Monaco] Switching to file:', activeFile?.name, 'ID:', activeFileId);
if (!activeFile) {
console.log('[Monaco] No active file');
return;
}
try {
// Create model if it doesn't exist
if (!modelsRef.current[activeFile.id]) {
console.log('[Monaco] Creating new model for:', activeFile.name);
const uri = monaco.Uri.parse(`file:///${activeFile.name}`);
// Check if a model with this URI already exists
const existingModel = monaco.editor.getModel(uri);
if (existingModel) {
console.log('[Monaco] Model already exists, reusing it');
modelsRef.current[activeFile.id] = existingModel;
} else {
modelsRef.current[activeFile.id] = monaco.editor.createModel(
activeFile.content || '',
activeFile.language,
uri
);
console.log('[Monaco] Model created successfully');
}
}
const model = modelsRef.current[activeFile.id];
// Check if model is disposed
if (model.isDisposed()) {
console.error('[Monaco] Model is disposed, recreating...');
const uri = monaco.Uri.parse(`file:///${activeFile.name}`);
modelsRef.current[activeFile.id] = monaco.editor.createModel(
activeFile.content || '',
activeFile.language,
uri
);
}
console.log('[Monaco] Setting model to editor');
editorRef.current.setModel(modelsRef.current[activeFile.id]);
editorRef.current.focus();
console.log('[Monaco] ✅ Model switched successfully');
} catch (err) {
console.error('[Monaco] ❌ Error switching model:', err);
setError(`Error switching file: ${err.message}`);
}
}, [activeFileId, files]);
// Add keyboard shortcut handler
useEffect(() => {
const handleKeyDown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveFile();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [activeFileId, files, filePaths]);
const getLanguageFromExtension = (filename) => {
const ext = filename.split('.').pop().toLowerCase();
const languageMap = {
'js': 'javascript', 'jsx': 'javascript', 'ts': 'typescript', 'tsx': 'typescript',
'py': 'python', 'java': 'java', 'cpp': 'cpp', 'c': 'c', 'cs': 'csharp',
'php': 'php', 'rb': 'ruby', 'go': 'go', 'rs': 'rust', 'html': 'html',
'css': 'css', 'scss': 'scss', 'json': 'json', 'xml': 'xml',
'md': 'markdown', 'sql': 'sql', 'sh': 'shell', 'yaml': 'yaml', 'yml': 'yaml',
'txt': 'plaintext'
};
return languageMap[ext] || 'plaintext';
};
const canPreview = (file) => {
return ['html', 'javascript', 'python'].includes(file?.language);
};
const buildPreview = () => {
console.log('[Preview] Building preview...');
// Get LATEST content from Monaco models, not from state
const currentFiles = files.map(f => {
const model = modelsRef.current[f.id];
return {
...f,
content: model && !model.isDisposed() ? model.getValue() : f.content
};
});
console.log('[Preview] Files available:', currentFiles.map(f => `${f.name} (${f.language})`));
// Use selected file or fall back to first HTML file
// Use selected file for preview
const selectedFile = currentFiles.find(f => f.id === selectedPreviewFile);
// If selected file is HTML, use it. If it's JS/JSX, show it as standalone. Otherwise find first HTML.
let htmlFile;
if (selectedFile?.language === 'html') {
htmlFile = selectedFile;
} else if (selectedFile?.language === 'javascript') {
// For JS/JSX files, create a basic HTML wrapper just for this file
htmlFile = null; // Will be handled below
} else {
htmlFile = currentFiles.find(f => f.language === 'html');
}
const jsFiles = currentFiles.filter(f => f.language === 'javascript');
const cssFiles = currentFiles.filter(f => f.language === 'css');
// If viewing a specific JS file, only include that one
if (selectedFile?.language === 'javascript') {
const jsFilesToUse = [selectedFile];
let html = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body></body></html>';
const jsContent = jsFilesToUse.map(f => f.content).join('\n');
html = html.replace('</body>', `<script>\n${jsContent}\n</script>\n</body>`);
const timestamp = Date.now();
html = html.replace('<head>', `<head><!-- Updated: ${timestamp} -->`);
setPreviewContent(html);
return;
}
console.log('[Preview] HTML file:', htmlFile?.name);
console.log('[Preview] JS files:', jsFiles.map(f => f.name));
console.log('[Preview] CSS files:', cssFiles.map(f => f.name));
if (!htmlFile && jsFiles.length === 0) {
const noContentHtml = '<html><body><h2 style="color: #888; text-align: center; margin-top: 50px;">No HTML or JS files to preview</h2></body></html>';
console.log('[Preview] No files to preview');
setPreviewContent(noContentHtml);
return;
}
let html = htmlFile?.content || '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body></body></html>';
// Inject CSS
if (cssFiles.length > 0) {
const cssContent = cssFiles.map(f => f.content).join('\n');
if (html.includes('</head>')) {
html = html.replace('</head>', `<style>\n${cssContent}\n</style>\n</head>`);
} else {
html = html.replace('<head>', `<head><style>\n${cssContent}\n</style>`);
}
}
// Inject JS
if (jsFiles.length > 0) {
const jsContent = jsFiles.map(f => f.content).join('\n');
if (html.includes('</body>')) {
html = html.replace('</body>', `<script>\n${jsContent}\n</script>\n</body>`);
} else {
html += `<script>\n${jsContent}\n</script>`;
}
}
const timestamp = Date.now();
html = html.replace('<head>', `<head><!-- Updated: ${timestamp} -->`);
setPreviewContent(html);
};
const runPython = () => {
const selectedFile = files.find(f => f.id === selectedPreviewFile);
const pythonFile = selectedFile?.language === 'python' ? selectedFile : files.find(f => f.language === 'python');
if (!pythonFile) {
setPythonOutput('No Python file found');
return;
}
try {
setPythonOutput('Note: This is a basic simulation. Use a real Python environment for actual execution.\n\n');
const code = pythonFile.content;
const printMatches = code.matchAll(/print\s*\((.*?)\)/g);
let output = '';
for (const match of printMatches) {
const content = match[1].trim();
const value = content.replace(/^["']|["']$/g, '');
output += value + '\n';
}
setPythonOutput(output || 'No print statements found');
} catch (err) {
setPythonOutput(`Error: ${err.message}`);
}
};
const runPreview = () => {
console.log('[Preview] Run preview clicked');
// Set active file as default if none selected
if (!selectedPreviewFile) {
setSelectedPreviewFile(activeFileId);
}
const activeFile = files.find(f => f.id === activeFileId);
if (activeFile?.language === 'python') {
runPython();
} else {
buildPreview();
}
setShowPreview(true);
};
const refreshPreview = () => {
console.log('[Preview] Refresh clicked');
const fileToPreview = files.find(f => f.id === selectedPreviewFile);
if (fileToPreview?.language === 'python') {
runPython();
} else {
setPreviewContent('');
setTimeout(() => {
buildPreview();
}, 50);
}
};
const promptForFilename = async () => {
const filename = await customPrompt('Enter filename (e.g., script.js, index.html, styles.css):', 'untitled.txt');
if (filename) {
const newFile = {
id: Date.now(),
name: filename,
content: '',
language: getLanguageFromExtension(filename),
modified: false
};
setFiles(prev => [...prev, newFile]);
setActiveFileId(newFile.id);
}
};
// Replace saveFile function
const saveFile = async () => {
const activeFile = files.find(f => f.id === activeFileId);
if (!activeFile) return;
if (!window.electronAPI) {
// Browser fallback
const blob = new Blob([activeFile.content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = activeFile.name;
a.click();
URL.revokeObjectURL(url);
setFiles(prev => prev.map(f => f.id === activeFileId ? { ...f, modified: false } : f));
return;
}
// Electron save
const filePath = filePaths[activeFileId];
if (filePath) {
// Save to existing file
const result = await window.electronAPI.writeFile(filePath, activeFile.content);
if (result.success) {
setFiles(prev => prev.map(f => f.id === activeFileId ? { ...f, modified: false } : f));
console.log('File saved:', filePath);
}
} else {
// Save As - no existing path
saveFileAs();
}
};
// Add new saveFileAs function
const saveFileAs = async () => {
const activeFile = files.find(f => f.id === activeFileId);
if (!activeFile) return;
if (!window.electronAPI) {
saveFile();
return;
}
const result = await window.electronAPI.saveFileDialog(activeFile.name);
if (result.success) {
const writeResult = await window.electronAPI.writeFile(result.filePath, activeFile.content);
if (writeResult.success) {
setFilePaths(prev => ({ ...prev, [activeFileId]: result.filePath }));
setFiles(prev => prev.map(f => f.id === activeFileId ? { ...f, modified: false } : f));
console.log('File saved as:', result.filePath);
}
}
};
const saveAllFiles = () => {
files.forEach(file => {
const blob = new Blob([file.content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.name;
a.click();
URL.revokeObjectURL(url);
});
setFiles(prev => prev.map(f => ({ ...f, modified: false })));
};
const copyContent = () => {
const activeFile = files.find(f => f.id === activeFileId);
if (activeFile) {
navigator.clipboard.writeText(activeFile.content);
alert('Content copied to clipboard!');
}
};
const closeFile = (id, e) => {
e.stopPropagation();
const fileToClose = files.find(f => f.id === id);
if (fileToClose?.modified) {
const confirm = window.confirm(`${fileToClose.name} has unsaved changes. Close anyway?`);
if (!confirm) return;
}
console.log('[App] Closing file:', id);
// Dispose the model safely
if (modelsRef.current[id]) {
try {
if (!modelsRef.current[id].isDisposed()) {
modelsRef.current[id].dispose();
console.log('[App] Model disposed for:', id);
}
delete modelsRef.current[id];
} catch (err) {
console.error('[App] Error disposing model:', err);
}
}
setFiles(prev => {
const newFiles = prev.filter(f => f.id !== id);
if (activeFileId === id && newFiles.length > 0) {
// Switch to the first available file
const newActiveId = newFiles[0].id;
console.log('[App] Switching to file:', newActiveId);
setActiveFileId(newActiveId);
} else if (newFiles.length === 0) {
setActiveFileId(null);
}
return newFiles;
});
};
const closeAllFiles = () => {
const hasModified = files.some(f => f.modified);
if (hasModified) {
const confirm = window.confirm('Some files have unsaved changes. Close all anyway?');
if (!confirm) return;
}
console.log('[App] Closing all files');
// Dispose all models safely
Object.keys(modelsRef.current).forEach(id => {
try {
const model = modelsRef.current[id];
if (model && !model.isDisposed()) {
model.dispose();
}
} catch (err) {
console.error('[App] Error disposing model:', err);
}
});
modelsRef.current = {};
setFiles([]);
setActiveFileId(null);
setShowPreview(false);
// Clear localStorage
try {
localStorage.removeItem('monaco-editor-files');
localStorage.removeItem('monaco-editor-active-file');
console.log('[Storage] Cleared localStorage');
} catch (err) {
console.error('[Storage] Error clearing localStorage:', err);
}
};
// Replace openFileDialog function
const openFileDialog = async () => {
if (!window.electronAPI) {
// Fallback to browser file picker
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.onchange = (e) => {
Array.from(e.target.files).forEach(file => {
const reader = new FileReader();
reader.onload = (event) => {
const newFile = {
id: Date.now() + Math.random(),
name: file.name,
content: event.target.result,
language: getLanguageFromExtension(file.name),
modified: false
};
setFiles(prev => [...prev, newFile]);
setActiveFileId(newFile.id);
};
reader.readAsText(file);
});
};
input.click();
return;
}
// Electron file picker
const result = await window.electronAPI.openFileDialog();
if (result.success) {
for (const filePath of result.filePaths) {
const readResult = await window.electronAPI.readFile(filePath);
if (readResult.success) {
const fileName = filePath.split(/[\\/]/).pop();
const newFile = {
id: Date.now() + Math.random(),
name: fileName,
content: readResult.content,
language: getLanguageFromExtension(fileName),
modified: false
};
setFiles(prev => [...prev, newFile]);
setFilePaths(prev => ({ ...prev, [newFile.id]: filePath }));
setActiveFileId(newFile.id);
}
}
}
};
const openFolder = async () => {
const result = await window.electronAPI.openFolderDialog();
if (result.success) {
setWorkspaceFolder(result.folderPath);
loadFolder(result.folderPath);
}
};
const loadFolder = async (folderPath) => {
const result = await window.electronAPI.readDirectory(folderPath);
if (result.success) {
// Completely replace folder contents
setFolderContents(result.files);
}
};
const refreshFolder = async () => {
if (workspaceFolder) {
// Clear expanded folders except root
setExpandedFolders(new Set());
// Do a fresh load
await loadFolder(workspaceFolder);
}
};
const toggleFolder = async (folderPath) => {
const newExpanded = new Set(expandedFolders);
if (newExpanded.has(folderPath)) {
newExpanded.delete(folderPath);
setExpandedFolders(newExpanded);
} else {
newExpanded.add(folderPath);
setExpandedFolders(newExpanded);
const result = await window.electronAPI.readDirectory(folderPath);
if (result.success) {
setFolderContents(prev => {
const existing = new Map(prev.map(item => [item.path, item]));
result.files.forEach(file => {
if (!existing.has(file.path)) {
existing.set(file.path, file);
}
});
return Array.from(existing.values());
});
}
}
};
const openFileFromExplorer = async (filePath) => {
// Check if this file is already open
const existingFile = Object.entries(filePaths).find(([id, path]) => path === filePath);
if (existingFile) {
// File is already open, just switch to it
setActiveFileId(Number(existingFile[0]));
return;
}
const result = await window.electronAPI.readFile(filePath);
if (result.success) {
const fileName = filePath.split(/[\\/]/).pop();
const newFile = {
id: Date.now() + Math.random(),
name: fileName,
content: result.content,
language: getLanguageFromExtension(fileName),
modified: false
};
setFiles(prev => [...prev, newFile]);
setFilePaths(prev => ({ ...prev, [newFile.id]: filePath }));
setActiveFileId(newFile.id);
}
};
const createNewFileInFolder = async (folderPath) => {
const filename = await customPrompt('Enter filename:', 'newfile.txt');
if (filename) {
const separator = folderPath.includes('\\') ? '\\' : '/';
const filePath = `${folderPath}${separator}${filename}`;
const result = await window.electronAPI.createFile(filePath);
if (result.success) {
await loadFolder(workspaceFolder);
setExpandedFolders(prev => new Set([...prev, folderPath]));
openFileFromExplorer(filePath);
} else {
alert(`Failed to create file: ${result.error || 'Unknown error'}`);
}
}
};
const createNewFolderInFolder = async (folderPath) => {
const foldername = await customPrompt('Enter folder name:', 'newfolder');
if (foldername) {
const separator = folderPath.includes('\\') ? '\\' : '/';
const newFolderPath = `${folderPath}${separator}${foldername}`;
const result = await window.electronAPI.createFolder(newFolderPath);
if (result.success) {
await loadFolder(workspaceFolder);
setExpandedFolders(prev => new Set([...prev, newFolderPath]));
}
}
};
const handleContextMenu = (e, item) => {
e.preventDefault();
setContextMenu({
x: e.clientX,
y: e.clientY,
item: item
});
};
const openCommandPalette = () => {
if (editorRef.current) {
editorRef.current.focus();
editorRef.current.trigger('keyboard', 'editor.action.quickCommand', null);
}
};
const PromptModal = () => {
if (!showPrompt) return null;
const handleSubmit = (e) => {
e.preventDefault();
if (promptCallback) {
const callback = promptCallback;
setShowPrompt(false);
setPromptCallback(null);
callback(promptValue || null);
}
};
const handleCancel = () => {
if (promptCallback) {
const callback = promptCallback;
setShowPrompt(false);
setPromptCallback(null);
callback(null);
}
};
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000 }}>
<div style={{ backgroundColor: '#2d2d30', padding: '24px', borderRadius: '8px', minWidth: '400px', border: '1px solid #454545' }}>
<h3 style={{ color: '#cccccc', marginTop: 0, marginBottom: '16px', fontSize: '16px' }}>Enter filename</h3>
<form onSubmit={handleSubmit}>
<input
type="text"
value={promptValue}
onChange={(e) => setPromptValue(e.target.value)}
placeholder="e.g., script.js, index.html, styles.css"
autoFocus
style={{ width: '100%', padding: '8px 12px', backgroundColor: '#3c3c3c', color: '#cccccc', border: '1px solid #454545', borderRadius: '4px', fontSize: '14px', outline: 'none', boxSizing: 'border-box' }}
/>
<div style={{ display: 'flex', gap: '8px', marginTop: '16px', justifyContent: 'flex-end' }}>
<button
type="button"
onClick={handleCancel}
style={{ padding: '8px 16px', backgroundColor: '#3c3c3c', color: '#cccccc', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '13px' }}
>
Cancel
</button>
<button
type="submit"
style={{ padding: '8px 16px', backgroundColor: '#0078d4', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '13px' }}
>
Create
</button>
</div>
</form>
</div>
</div>
);
};
const FileExplorer = () => {
const renderTree = (items, parentPath = workspaceFolder) => {
return items
.filter(item => {
if (!item.path) return false;
const itemParent = item.path.substring(0, item.path.lastIndexOf(item.path.includes('\\') ? '\\' : '/'));
return itemParent === parentPath;
})
.sort((a, b) => {
if (a.isDirectory === b.isDirectory) return a.name.localeCompare(b.name);
return a.isDirectory ? -1 : 1;
})
.map(item => (
<div key={item.path}>
<div
draggable={!item.isDirectory}
onDragStart={(e) => {
if (!item.isDirectory) {
setDraggedItem(item);
e.dataTransfer.effectAllowed = 'move';
}
}}
onDragOver={(e) => {
if (item.isDirectory && draggedItem && draggedItem.path !== item.path) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
}}
onDrop={async (e) => {
if (item.isDirectory && draggedItem) {
e.preventDefault();
e.stopPropagation();
const separator = draggedItem.path.includes('\\') ? '\\' : '/';
const newPath = `${item.path}${separator}${draggedItem.name}`;
// Check if file is open in editor
const openFile = files.find(f => filePaths[f.id] === draggedItem.path);
const result = await window.electronAPI.renameFile(draggedItem.path, newPath);
if (result.success) {
// Update file paths if the file is open
if (openFile) {
setFilePaths(prev => ({ ...prev, [openFile.id]: newPath }));
}
// Refresh the entire folder structure
setDraggedItem(null);
await loadFolder(workspaceFolder); // Full reload instead of refresh
setExpandedFolders(prev => new Set([...prev, item.path]));
} else {
alert(`Failed to move file: ${result.error || 'Unknown error'}`);
}
setDraggedItem(null);
}
}}
onDragEnd={() => setDraggedItem(null)}
onClick={() => item.isDirectory ? toggleFolder(item.path) : openFileFromExplorer(item.path)}
onContextMenu={(e) => handleContextMenu(e, item)}
style={{
padding: '4px 8px',
cursor: item.isDirectory ? 'pointer' : 'grab',
display: 'flex',
alignItems: 'center',
gap: '6px',
color: '#cccccc',
fontSize: '13px',
backgroundColor: 'transparent',
transition: 'background-color 0.1s',
paddingLeft: `${(item.path.split(/[\\/]/).length - workspaceFolder.split(/[\\/]/).length) * 12 + 8}px`,
opacity: draggedItem?.path === item.path ? 0.5 : 1
}}
onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#2a2d2e'}
onMouseOut={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
{item.isDirectory ? (
expandedFolders.has(item.path) ? '▼' : '▶'
) : (
<FileText size={14} />
)}
<span>{item.name}</span>
</div>
{item.isDirectory && expandedFolders.has(item.path) && renderTree(folderContents, item.path)}
</div>
));
};
return (
<div style={{ width: '250px', backgroundColor: '#252526', borderRight: '1px solid #2d2d30', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '8px', backgroundColor: '#2d2d30', borderBottom: '1px solid #1e1e1e', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ color: '#cccccc', fontSize: '11px', fontWeight: '600', textTransform: 'uppercase' }}>Explorer</span>
<div style={{ display: 'flex', gap: '4px' }}>
{workspaceFolder && (
<>
<button
onClick={refreshFolder}
style={{ background: 'none', border: 'none', color: '#cccccc', cursor: 'pointer', padding: '2px', display: 'flex', alignItems: 'center' }}
title="Refresh Folder"
>
<RotateCw size={14} />
</button>
<button
onClick={openFolder}
style={{ background: 'none', border: 'none', color: '#cccccc', cursor: 'pointer', padding: '2px', display: 'flex', alignItems: 'center' }}
title="Change Folder"
>
<FolderOpen size={14} />
</button>
</>
)}
<button
onClick={() => setShowExplorer(false)}
style={{ background: 'none', border: 'none', color: '#cccccc', cursor: 'pointer', padding: '2px', display: 'flex', alignItems: 'center' }}
title="Close Explorer"
>
<X size={14} />
</button>
</div>
</div>
{!workspaceFolder ? (
<div style={{ padding: '16px', textAlign: 'center' }}>
<button
onClick={openFolder}
style={{ padding: '8px 16px', backgroundColor: '#0078d4', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '13px' }}
>
Open Folder
</button>
</div>
) : (
<div
style={{ flex: 1, overflow: 'auto' }}
onContextMenu={(e) => handleContextMenu(e, { path: workspaceFolder, isDirectory: true, name: workspaceFolder.split(/[\\/]/).pop() })}
>
<div style={{ padding: '8px 8px 4px 8px', color: '#858585', fontSize: '12px', fontWeight: '600' }}>
{workspaceFolder.split(/[\\/]/).pop()}
</div>
{renderTree(folderContents)}
</div>
)}
</div>
);
};
const ContextMenu = () => {
if (!contextMenu) return null;
const handleNewFile = async () => {
setContextMenu(null);
await createNewFileInFolder(contextMenu.item.path);
};
const handleNewFolder = async () => {
setContextMenu(null);
await createNewFolderInFolder(contextMenu.item.path);
};
const handleDelete = async () => {
// Only allow deleting files, NOT folders
if (contextMenu.item.isDirectory) {
setContextMenu(null);
alert('Deleting folders is disabled for safety. Please delete files individually or use your file manager.');
return;
}
if (window.confirm(`Delete file "${contextMenu.item.name}"?`)) {
setContextMenu(null);
const result = await window.electronAPI.deleteFile(contextMenu.item.path);
if (result.success) {
// Close the file if it's open
const openFile = files.find(f => filePaths[f.id] === contextMenu.item.path);
if (openFile) {
// Close the file tab
if (modelsRef.current[openFile.id]) {
try {
if (!modelsRef.current[openFile.id].isDisposed()) {
modelsRef.current[openFile.id].dispose();
}
delete modelsRef.current[openFile.id];
} catch (err) {
console.error('[App] Error disposing model:', err);
}
}
setFiles(prev => {
const newFiles = prev.filter(f => f.id !== openFile.id);
if (activeFileId === openFile.id && newFiles.length > 0) {
setActiveFileId(newFiles[0].id);
} else if (newFiles.length === 0) {
setActiveFileId(null);
}
return newFiles;
});
setFilePaths(prev => {
const newPaths = { ...prev };
delete newPaths[openFile.id];
return newPaths;
});
}
await loadFolder(workspaceFolder);
} else {
alert(`Failed to delete: ${result.error || 'Unknown error'}`);
}
} else {
setContextMenu(null);
}
};
const handleRename = async () => {
// Only allow renaming files, NOT folders
if (contextMenu.item.isDirectory) {
setContextMenu(null);
alert('Renaming folders is disabled for safety. Please use your file manager.');
return;
}
setContextMenu(null);
const newName = await customPrompt('Enter new name:', contextMenu.item.name);
if (newName) {
const separator = contextMenu.item.path.includes('\\') ? '\\' : '/';
const parentPath = contextMenu.item.path.substring(0, contextMenu.item.path.lastIndexOf(separator));
const newPath = `${parentPath}${separator}${newName}`;
const result = await window.electronAPI.renameFile(contextMenu.item.path, newPath);
if (result.success) {
// Update file paths if the file is open
const openFile = files.find(f => filePaths[f.id] === contextMenu.item.path);
if (openFile) {
setFilePaths(prev => ({ ...prev, [openFile.id]: newPath }));
setFiles(prev => prev.map(f =>
f.id === openFile.id ? { ...f, name: newName } : f
));
}
await loadFolder(workspaceFolder);
} else {
alert(`Failed to rename: ${result.error || 'Unknown error'}`);
}
}
};
return (
<>
<div
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 9998 }}
onClick={() => setContextMenu(null)}
/>
<div
style={{
position: 'fixed',
top: contextMenu.y,
left: contextMenu.x,
backgroundColor: '#2d2d30',
border: '1px solid #454545',
borderRadius: '4px',
padding: '4px',
zIndex: 9999,
minWidth: '150px'
}}
>
{contextMenu.item.isDirectory && (
<>
<div
onClick={handleNewFile}
style={{ padding: '6px 12px', color: '#cccccc', cursor: 'pointer', fontSize: '13px', borderRadius: '2px' }}
onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#094771'}
onMouseOut={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
New File
</div>
<div
onClick={handleNewFolder}
style={{ padding: '6px 12px', color: '#cccccc', cursor: 'pointer', fontSize: '13px', borderRadius: '2px' }}
onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#094771'}
onMouseOut={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
New Folder
</div>
<div style={{ height: '1px', backgroundColor: '#454545', margin: '4px 0' }} />
</>
)}
{!contextMenu.item.isDirectory && (
<>
<div
onClick={handleRename}
style={{ padding: '6px 12px', color: '#cccccc', cursor: 'pointer', fontSize: '13px', borderRadius: '2px' }}
onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#094771'}
onMouseOut={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
Rename
</div>
<div
onClick={handleDelete}
style={{ padding: '6px 12px', color: '#f48771', cursor: 'pointer', fontSize: '13px', borderRadius: '2px' }}
onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#094771'}
onMouseOut={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
Delete
</div>
</>
)}
</div>
</>
);
};
const WelcomeScreen = () => (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', backgroundColor: '#1f1f1f', color: '#cccccc' }}>
<div style={{ textAlign: 'center', padding: '32px' }}>
<FileCode size={64} style={{ margin: '0 auto 32px', color: '#0078d4' }} />
<h1 style={{ fontSize: '32px', fontWeight: '300', marginBottom: '32px' }}>Welcome</h1>
{error && (
<div style={{ backgroundColor: '#f48771', color: '#1e1e1e', padding: '12px', borderRadius: '4px', marginBottom: '16px', fontSize: '14px' }}>
Error: {error}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<button onClick={promptForFilename} style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '12px 24px', backgroundColor: '#2d2d30', color: '#cccccc', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '14px', width: '256px', margin: '0 auto', transition: 'background-color 0.2s' }} onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#3e3e42'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#2d2d30'}>
<Plus size={20} />
<span>New File</span>
</button>
<button onClick={openFileDialog} style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '12px 24px', backgroundColor: '#2d2d30', color: '#cccccc', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '14px', width: '256px', margin: '0 auto', transition: 'background-color 0.2s' }} onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#3e3e42'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#2d2d30'}>
<FolderOpen size={20} />
<span>Open File</span>
</button>
<div style={{ paddingTop: '16px', fontSize: '12px', color: '#858585' }}>
<p>Supported: HTML, CSS, JS, Python, TypeScript, and more</p>
<p style={{ marginTop: '12px', color: '#0078d4' }}>Press F1 for Command Palette</p>
</div>
</div>
</div>
</div>
);
const activeFile = files.find(f => f.id === activeFileId);
const editorReady = editorRef.current !== null;
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', backgroundColor: '#1f1f1f', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }}>
<div style={{ backgroundColor: '#323233', color: '#cccccc', padding: '8px 16px', display: 'flex', alignItems: 'center', gap: '8px', borderBottom: '1px solid #2d2d30' }}>
<PromptModal />
<FileCode size={20} style={{ color: '#0078d4' }} />
<span style={{ fontWeight: '600' }}>NeoEditor</span>
<span style={{ fontSize: '11px', color: '#858585', marginLeft: '8px' }}>
{editorReady ? '● Ready' : '○ No files open'}
</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '8px' }}>
{!showExplorer && (
<button onClick={() => setShowExplorer(true)} style={{ padding: '4px 12px', backgroundColor: '#2d2d30', color: '#cccccc', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px', display: 'flex', alignItems: 'center', gap: '6px', transition: 'background-color 0.2s' }} onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#3e3e42'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#2d2d30'} title="Show Explorer">
<FolderOpen size={14} />Explorer
</button>
)}
{activeFile && (
<>
<button onClick={saveFile} style={{ padding: '4px 12px', backgroundColor: '#2d2d30', color: '#cccccc', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px', display: 'flex', alignItems: 'center', gap: '6px', transition: 'background-color 0.2s' }} onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#3e3e42'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#2d2d30'} title="Save File">
<Save size={14} />Save
</button>
<button onClick={saveFileAs} style={{ padding: '4px 12px', backgroundColor: '#2d2d30', color: '#cccccc', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px', display: 'flex', alignItems: 'center', gap: '6px', transition: 'background-color 0.2s' }} onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#3e3e42'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#2d2d30'} title="Save As">
<Save size={14} />Save As
</button>
<button onClick={copyContent} style={{ padding: '4px 12px', backgroundColor: '#2d2d30', color: '#cccccc', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px', display: 'flex', alignItems: 'center', gap: '6px', transition: 'background-color 0.2s' }} onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#3e3e42'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#2d2d30'} title="Copy Content">
<Copy size={14} />
</button>
</>
)}
<button onClick={openFileDialog} style={{ padding: '4px 12px', backgroundColor: '#2d2d30', color: '#cccccc', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px', display: 'flex', alignItems: 'center', gap: '6px', transition: 'background-color 0.2s' }} onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#3e3e42'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#2d2d30'} title="Open File">
<FolderOpen size={14} />Open
</button>
{files.length > 0 && (
<>
{canPreview(activeFile) && (
<>
{/* comment here
<button onClick={runPreview} style={{ padding: '4px 12px', backgroundColor: '#0078d4', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px', display: 'flex', alignItems: 'center', gap: '6px', transition: 'background-color 0.2s' }} onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#006abc'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#0078d4'} title="Run Preview">
<Play size={14} />Run
</button>
*/}
{showPreview && (
<>
<button onClick={refreshPreview} style={{ padding: '4px 12px', backgroundColor: '#2d2d30', color: '#cccccc', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px', display: 'flex', alignItems: 'center', gap: '6px', transition: 'background-color 0.2s' }} onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#3e3e42'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#2d2d30'} title="Refresh">
<RotateCw size={14} />
</button>
<button onClick={() => setShowPreview(false)} style={{ padding: '4px 12px', backgroundColor: '#2d2d30', color: '#cccccc', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px', display: 'flex', alignItems: 'center', gap: '6px', transition: 'background-color 0.2s' }} onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#3e3e42'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#2d2d30'} title="Hide Preview">
<EyeOff size={14} />
</button>
</>
)}
</>
)}
<button onClick={saveAllFiles} style={{ padding: '4px 12px', backgroundColor: '#2d2d30', color: '#cccccc', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px', display: 'flex', alignItems: 'center', gap: '6px', transition: 'background-color 0.2s' }} onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#3e3e42'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#2d2d30'} title="Save All">
<Download size={14} />Save All
</button>
<button onClick={closeAllFiles} style={{ padding: '4px 12px', backgroundColor: '#2d2d30', color: '#cccccc', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px', display: 'flex', alignItems: 'center', gap: '6px', transition: 'background-color 0.2s' }} onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#3e3e42'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#2d2d30'} title="Close All">
<Trash2 size={14} />Close All
</button>
<button onClick={openCommandPalette} style={{ padding: '4px 12px', backgroundColor: '#2d2d30', color: '#cccccc', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px', transition: 'background-color 0.2s' }} onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#3e3e42'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#2d2d30'} title="Command Palette (F1)">
Command Palette
</button>
</>
)}
</div>
</div>
<div style={{ backgroundColor: '#2d2d30', display: 'flex', alignItems: 'center', borderBottom: '1px solid #252526' }}>
{files.map(file => (
<div key={file.id} onClick={() => setActiveFileId(file.id)} style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 16px', cursor: 'pointer', borderRight: '1px solid #252526', backgroundColor: activeFileId === file.id ? '#1e1e1e' : '#2d2d30', color: activeFileId === file.id ? '#ffffff' : '#969696', transition: 'background-color 0.2s' }} onMouseOver={(e) => { if (activeFileId !== file.id) e.currentTarget.style.backgroundColor = '#343437'; }} onMouseOut={(e) => { if (activeFileId !== file.id) e.currentTarget.style.backgroundColor = '#2d2d30'; }}>
<FileText size={14} />
<span style={{ fontSize: '13px' }}>{file.name}{file.modified && ' •'}</span>
<button onClick={(e) => closeFile(file.id, e)} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', padding: '2px', borderRadius: '3px', color: 'inherit', transition: 'background-color 0.2s' }} onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#45454a'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
<X size={14} />
</button>
</div>
))}
<button onClick={promptForFilename} title="New File" style={{ padding: '8px 12px', color: '#969696', background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', transition: 'background-color 0.2s, color 0.2s' }} onMouseOver={(e) => { e.currentTarget.style.color = '#ffffff'; e.currentTarget.style.backgroundColor = '#343437'; }} onMouseOut={(e) => { e.currentTarget.style.color = '#969696'; e.currentTarget.style.backgroundColor = 'transparent'; }}>
<Plus size={16} />
</button>
</div>
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{showExplorer && <FileExplorer />}
<ContextMenu />
<div style={{ flex: showPreview ? 1 : 1, position: 'relative', overflow: 'hidden' }}>
{files.length === 0 ? (
<WelcomeScreen />
) : (
<div ref={containerRef} style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, width: '100%', height: '100%' }} />
)}
</div>
{showPreview && (
<div style={{ flex: 1, borderLeft: '1px solid #2d2d30', backgroundColor: '#1f1f1f', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '8px 16px', backgroundColor: '#2d2d30', borderBottom: '1px solid #252526', color: '#cccccc', fontSize: '13px', fontWeight: '600', display: 'flex', alignItems: 'center', gap: '12px' }}>
<span>Preview</span>
<select
value={selectedPreviewFile || ''}
onChange={(e) => {
const newFileId = Number(e.target.value);
setSelectedPreviewFile(newFileId);
const fileToPreview = files.find(f => f.id === newFileId);
if (fileToPreview?.language === 'python') {
runPython();
} else {
setPreviewContent('');
setTimeout(() => buildPreview(), 50);
}
}}
style={{
backgroundColor: '#3c3c3c',
color: '#cccccc',
border: '1px solid #454545',
borderRadius: '4px',
padding: '4px 8px',
fontSize: '12px',
cursor: 'pointer',
outline: 'none'
}}
>
{files.filter(f => canPreview(f)).map(file => (
<option key={file.id} value={file.id}>
{file.name}
</option>
))}
</select>
</div>
{files.find(f => f.id === selectedPreviewFile)?.language === 'python' ? (
<pre style={{ flex: 1, margin: 0, padding: '16px', color: '#cccccc', backgroundColor: '#1f1f1f', overflow: 'auto', fontFamily: 'monospace', fontSize: '13px', whiteSpace: 'pre-wrap' }}>
{pythonOutput}
</pre>
) : (
previewContent ? (
<iframe
key={Date.now()}
ref={iframeRef}
srcDoc={previewContent}
style={{ flex: 1, border: 'none', backgroundColor: '#fff' }}
sandbox="allow-scripts"
title="Preview"
/>
) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#888' }}>
Loading preview...
</div>
)
)}
</div>
)}
</div>
</div>
);
};
export default MonacoEditor;