- Real-time error monitoring system with WebSocket - Auto-fix agent that triggers on browser errors - Bug tracker dashboard with floating button (🐛) - Live activity stream showing AI thought process - Fixed 4 JavaScript errors (SyntaxError, TypeError) - Fixed SessionPicker API endpoint error - Enhanced chat input with Monaco editor - Session picker component for project management Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
661 lines
22 KiB
JavaScript
661 lines
22 KiB
JavaScript
/**
|
||
* Monaco Editor Component
|
||
* VS Code's editor in the browser with tab system
|
||
*
|
||
* Features:
|
||
* - Tab-based multi-file editing
|
||
* - Syntax highlighting for 100+ languages
|
||
* - Auto-save on Ctrl+S
|
||
* - Dirty state indicators
|
||
* - Mobile responsive (CodeMirror fallback on touch devices)
|
||
*/
|
||
|
||
class MonacoEditor {
|
||
constructor(containerId) {
|
||
this.container = document.getElementById(containerId);
|
||
if (!this.container) {
|
||
console.error('[MonacoEditor] Container not found:', containerId);
|
||
return;
|
||
}
|
||
|
||
this.editors = new Map(); // tabId -> editor instance
|
||
this.models = new Map(); // tabId -> model instance
|
||
this.tabs = [];
|
||
this.activeTab = null;
|
||
this.monaco = null;
|
||
this.isMobile = this.detectMobile();
|
||
this.initialized = false;
|
||
}
|
||
|
||
detectMobile() {
|
||
// Check for actual mobile device (not just touch-enabled laptop)
|
||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||
// Also check screen width as additional heuristic
|
||
const isSmallScreen = window.innerWidth < 768;
|
||
return isMobile || isSmallScreen;
|
||
}
|
||
|
||
async initialize() {
|
||
if (this.initialized) return;
|
||
|
||
if (this.isMobile) {
|
||
// Use CodeMirror for mobile (touch-friendly)
|
||
console.log('[MonacoEditor] Mobile detected, using fallback');
|
||
this.initializeFallback();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Wrap AMD loader in promise
|
||
await new Promise((resolve, reject) => {
|
||
// Configure Monaco loader
|
||
require.config({
|
||
paths: {
|
||
'vs': 'https://unpkg.com/monaco-editor@0.45.0/min/vs'
|
||
}
|
||
});
|
||
|
||
// Load Monaco
|
||
require(['vs/editor/editor.main'], (monaco) => {
|
||
this.monaco = monaco;
|
||
this.setupContainer();
|
||
this.setupKeyboardShortcuts();
|
||
this.loadPersistedTabs();
|
||
this.initialized = true;
|
||
console.log('[MonacoEditor] Initialized successfully');
|
||
resolve();
|
||
}, (error) => {
|
||
console.error('[MonacoEditor] AMD loader error:', error);
|
||
reject(error);
|
||
});
|
||
});
|
||
} catch (error) {
|
||
console.error('[MonacoEditor] Failed to initialize:', error);
|
||
this.initializeFallback();
|
||
this.initialized = true;
|
||
}
|
||
}
|
||
|
||
setupContainer() {
|
||
this.container.innerHTML = `
|
||
<div class="monaco-editor-container">
|
||
<div class="editor-tabs-wrapper">
|
||
<div class="editor-tabs" id="editor-tabs"></div>
|
||
<div class="editor-tabs-actions">
|
||
<button class="btn-icon" id="btn-save-current" title="Save (Ctrl+S)" style="display: none;">💾</button>
|
||
<button class="btn-icon" id="btn-save-all" title="Save All (Ctrl+Shift+S)">💾</button>
|
||
<button class="btn-icon" id="btn-close-all" title="Close All">✕</button>
|
||
</div>
|
||
</div>
|
||
<div class="editor-content-wrapper">
|
||
<div class="editor-content" id="editor-content">
|
||
<div class="editor-placeholder">
|
||
<div class="placeholder-icon">📄</div>
|
||
<h2>No file open</h2>
|
||
<p>Select a file from the sidebar to start editing</p>
|
||
<p style="font-size: 0.9em; opacity: 0.7; margin-top: 8px;">Files are automatically editable</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="editor-statusbar">
|
||
<span class="statusbar-item" id="statusbar-cursor">Ln 1, Col 1</span>
|
||
<span class="statusbar-item" id="statusbar-language">Plain Text</span>
|
||
<span class="statusbar-item" id="statusbar-file">No file</span>
|
||
<span class="statusbar-item" id="statusbar-editable" style="display: none;">✓ Editable</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Event listeners
|
||
const saveCurrentBtn = this.container.querySelector('#btn-save-current');
|
||
if (saveCurrentBtn) {
|
||
saveCurrentBtn.addEventListener('click', () => this.saveCurrentFile());
|
||
}
|
||
|
||
const saveAllBtn = this.container.querySelector('#btn-save-all');
|
||
if (saveAllBtn) {
|
||
saveAllBtn.addEventListener('click', () => this.saveAllFiles());
|
||
}
|
||
|
||
const closeAllBtn = this.container.querySelector('#btn-close-all');
|
||
if (closeAllBtn) {
|
||
closeAllBtn.addEventListener('click', () => this.closeAllTabs());
|
||
}
|
||
}
|
||
|
||
setupKeyboardShortcuts() {
|
||
// Ctrl+S to save
|
||
document.addEventListener('keydown', (e) => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||
e.preventDefault();
|
||
this.saveCurrentFile();
|
||
}
|
||
// Ctrl+W to close tab
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
|
||
e.preventDefault();
|
||
this.closeCurrentTab();
|
||
}
|
||
});
|
||
}
|
||
|
||
getLanguageFromFile(filePath) {
|
||
const ext = filePath.split('.').pop().toLowerCase();
|
||
|
||
const languageMap = {
|
||
'js': 'javascript',
|
||
'jsx': 'javascript',
|
||
'ts': 'typescript',
|
||
'tsx': 'typescript',
|
||
'py': 'python',
|
||
'html': 'html',
|
||
'htm': 'html',
|
||
'css': 'css',
|
||
'scss': 'scss',
|
||
'sass': 'scss',
|
||
'json': 'json',
|
||
'md': 'markdown',
|
||
'markdown': 'markdown',
|
||
'xml': 'xml',
|
||
'yaml': 'yaml',
|
||
'yml': 'yaml',
|
||
'sql': 'sql',
|
||
'sh': 'shell',
|
||
'bash': 'shell',
|
||
'zsh': 'shell',
|
||
'txt': 'plaintext'
|
||
};
|
||
|
||
return languageMap[ext] || 'plaintext';
|
||
}
|
||
|
||
async openFile(filePath, content) {
|
||
if (!this.initialized && !this.isMobile) {
|
||
await this.initialize();
|
||
}
|
||
|
||
if (this.isMobile) {
|
||
this.openFileFallback(filePath, content);
|
||
return;
|
||
}
|
||
|
||
// Check if already open
|
||
const existingTab = this.tabs.find(tab => tab.path === filePath);
|
||
if (existingTab) {
|
||
this.activateTab(existingTab.id);
|
||
return;
|
||
}
|
||
|
||
// Create new tab
|
||
const tabId = `tab-${Date.now()}`;
|
||
const tab = {
|
||
id: tabId,
|
||
path: filePath,
|
||
name: filePath.split('/').pop(),
|
||
dirty: false,
|
||
originalContent: content || ''
|
||
};
|
||
|
||
this.tabs.push(tab);
|
||
|
||
// Create Monaco model
|
||
const language = this.getLanguageFromFile(filePath);
|
||
const model = this.monaco.editor.createModel(content || '', language, monaco.Uri.parse(filePath));
|
||
this.models.set(tabId, model);
|
||
|
||
// Create editor instance
|
||
const contentArea = this.container.querySelector('#editor-content');
|
||
|
||
// Remove placeholder
|
||
const placeholder = contentArea.querySelector('.editor-placeholder');
|
||
if (placeholder) placeholder.remove();
|
||
|
||
// Create editor container
|
||
const editorContainer = document.createElement('div');
|
||
editorContainer.className = 'monaco-editor-instance';
|
||
editorContainer.style.display = 'none';
|
||
contentArea.appendChild(editorContainer);
|
||
|
||
// Create editor
|
||
const editor = this.monaco.editor.create(editorContainer, {
|
||
model: model,
|
||
theme: 'vs-dark',
|
||
automaticLayout: true,
|
||
fontSize: 14,
|
||
fontFamily: "'Fira Code', 'JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas', monaco",
|
||
lineNumbers: 'on',
|
||
minimap: { enabled: true },
|
||
scrollBeyondLastLine: false,
|
||
wordWrap: 'off',
|
||
tabSize: 4,
|
||
renderWhitespace: 'selection',
|
||
cursorStyle: 'line',
|
||
folding: true,
|
||
bracketPairColorization: { enabled: true },
|
||
guides: {
|
||
indentation: true,
|
||
bracketPairs: true
|
||
}
|
||
});
|
||
|
||
// Track cursor position
|
||
editor.onDidChangeCursorPosition((e) => {
|
||
this.updateCursorPosition(e.position);
|
||
});
|
||
|
||
// Track content changes
|
||
model.onDidChangeContent(() => {
|
||
this.markDirty(tabId);
|
||
});
|
||
|
||
this.editors.set(tabId, editor);
|
||
|
||
// Activate the new tab
|
||
this.activateTab(tabId);
|
||
|
||
// Persist tabs
|
||
this.saveTabsToStorage();
|
||
|
||
return tabId;
|
||
}
|
||
|
||
activateTab(tabId) {
|
||
if (!this.editors.has(tabId)) {
|
||
console.error('[MonacoEditor] Tab not found:', tabId);
|
||
return;
|
||
}
|
||
|
||
// Hide all editors
|
||
this.editors.forEach((editor, id) => {
|
||
const container = editor.getDomNode();
|
||
if (container) {
|
||
container.style.display = id === tabId ? 'block' : 'none';
|
||
}
|
||
});
|
||
|
||
this.activeTab = tabId;
|
||
this.renderTabs();
|
||
this.updateStatusbar(tabId);
|
||
|
||
// Show save button for current file and editable indicator
|
||
const tab = this.tabs.find(t => t.id === tabId);
|
||
const saveCurrentBtn = this.container.querySelector('#btn-save-current');
|
||
const editableIndicator = this.container.querySelector('#statusbar-editable');
|
||
|
||
if (saveCurrentBtn) {
|
||
saveCurrentBtn.style.display = 'inline-flex';
|
||
saveCurrentBtn.title = `Save ${tab?.name || 'file'} (Ctrl+S)`;
|
||
}
|
||
|
||
if (editableIndicator) {
|
||
editableIndicator.style.display = 'inline-flex';
|
||
editableIndicator.textContent = tab?.dirty ? '● Unsaved changes' : '✓ Editable';
|
||
editableIndicator.style.color = tab?.dirty ? '#f48771' : '#4ec9b0';
|
||
}
|
||
|
||
// Focus the active editor and ensure it's not read-only
|
||
const editor = this.editors.get(tabId);
|
||
if (editor) {
|
||
editor.focus();
|
||
editor.updateOptions({ readOnly: false });
|
||
}
|
||
}
|
||
|
||
closeTab(tabId) {
|
||
const tab = this.tabs.find(t => t.id === tabId);
|
||
if (!tab) return;
|
||
|
||
// Check for unsaved changes
|
||
if (tab.dirty) {
|
||
const shouldSave = confirm(`Save changes to ${tab.name} before closing?`);
|
||
if (shouldSave) {
|
||
this.saveFile(tabId);
|
||
}
|
||
}
|
||
|
||
// Dispose editor and model
|
||
const editor = this.editors.get(tabId);
|
||
if (editor) {
|
||
editor.dispose();
|
||
this.editors.delete(tabId);
|
||
}
|
||
|
||
const model = this.models.get(tabId);
|
||
if (model) {
|
||
model.dispose();
|
||
this.models.delete(tabId);
|
||
}
|
||
|
||
// Remove tab from list
|
||
this.tabs = this.tabs.filter(t => t.id !== tabId);
|
||
|
||
// If we closed the active tab, activate another one
|
||
if (this.activeTab === tabId) {
|
||
if (this.tabs.length > 0) {
|
||
this.activateTab(this.tabs[0].id);
|
||
} else {
|
||
this.activeTab = null;
|
||
this.showPlaceholder();
|
||
}
|
||
}
|
||
|
||
this.renderTabs();
|
||
this.saveTabsToStorage();
|
||
}
|
||
|
||
closeCurrentTab() {
|
||
if (this.activeTab) {
|
||
this.closeTab(this.activeTab);
|
||
}
|
||
}
|
||
|
||
closeAllTabs() {
|
||
if (this.tabs.length === 0) return;
|
||
|
||
const hasUnsaved = this.tabs.some(t => t.dirty);
|
||
if (hasUnsaved) {
|
||
const shouldSaveAll = confirm('Some files have unsaved changes. Save all before closing?');
|
||
if (shouldSaveAll) {
|
||
this.saveAllFiles();
|
||
}
|
||
}
|
||
|
||
// Dispose all editors and models
|
||
this.editors.forEach(editor => editor.dispose());
|
||
this.models.forEach(model => model.dispose());
|
||
|
||
this.editors.clear();
|
||
this.models.clear();
|
||
this.tabs = [];
|
||
this.activeTab = null;
|
||
|
||
this.renderTabs();
|
||
this.showPlaceholder();
|
||
this.saveTabsToStorage();
|
||
}
|
||
|
||
async saveFile(tabId) {
|
||
const tab = this.tabs.find(t => t.id === tabId);
|
||
if (!tab) return;
|
||
|
||
const model = this.models.get(tabId);
|
||
if (!model) return;
|
||
|
||
const content = model.getValue();
|
||
|
||
try {
|
||
const response = await fetch(`/claude/api/file/${encodeURIComponent(tab.path)}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ content })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
if (data.error) {
|
||
throw new Error(data.error);
|
||
}
|
||
|
||
// Update tab state
|
||
tab.dirty = false;
|
||
tab.originalContent = content;
|
||
|
||
this.renderTabs();
|
||
|
||
// Show success toast
|
||
if (typeof showToast === 'function') {
|
||
showToast(`✅ Saved ${tab.name}`, 'success', 2000);
|
||
}
|
||
|
||
return true;
|
||
} catch (error) {
|
||
console.error('[MonacoEditor] Error saving file:', error);
|
||
if (typeof showToast === 'function') {
|
||
showToast(`❌ Failed to save ${tab.name}: ${error.message}`, 'error', 3000);
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async saveCurrentFile() {
|
||
if (this.activeTab) {
|
||
await this.saveFile(this.activeTab);
|
||
}
|
||
}
|
||
|
||
async saveAllFiles() {
|
||
const dirtyTabs = this.tabs.filter(t => t.dirty);
|
||
|
||
if (dirtyTabs.length === 0) {
|
||
if (typeof showToast === 'function') {
|
||
showToast('No unsaved changes', 'info', 2000);
|
||
}
|
||
return;
|
||
}
|
||
|
||
let saved = 0;
|
||
let failed = 0;
|
||
|
||
for (const tab of dirtyTabs) {
|
||
const result = await this.saveFile(tab.id);
|
||
if (result) {
|
||
saved++;
|
||
} else {
|
||
failed++;
|
||
}
|
||
}
|
||
|
||
if (typeof showToast === 'function') {
|
||
if (failed === 0) {
|
||
showToast(`✅ Saved ${saved} file${saved > 1 ? 's' : ''}`, 'success', 2000);
|
||
} else {
|
||
showToast(`⚠️ Saved ${saved} file${saved > 1 ? 's' : ''}, ${failed} failed`, 'warning', 3000);
|
||
}
|
||
}
|
||
}
|
||
|
||
markDirty(tabId) {
|
||
const tab = this.tabs.find(t => t.id === tabId);
|
||
if (tab && !tab.dirty) {
|
||
tab.dirty = true;
|
||
this.renderTabs();
|
||
}
|
||
}
|
||
|
||
updateCursorPosition(position) {
|
||
const cursorEl = this.container.querySelector('#statusbar-cursor');
|
||
if (cursorEl && position) {
|
||
cursorEl.textContent = `Ln ${position.lineNumber}, Col ${position.column}`;
|
||
}
|
||
}
|
||
|
||
updateStatusbar(tabId) {
|
||
const tab = this.tabs.find(t => t.id === tabId);
|
||
if (!tab) return;
|
||
|
||
const fileEl = this.container.querySelector('#statusbar-file');
|
||
const langEl = this.container.querySelector('#statusbar-language');
|
||
|
||
if (fileEl) {
|
||
fileEl.textContent = tab.path;
|
||
}
|
||
|
||
if (langEl) {
|
||
const language = this.getLanguageFromFile(tab.path);
|
||
langEl.textContent = language.charAt(0).toUpperCase() + language.slice(1);
|
||
}
|
||
}
|
||
|
||
renderTabs() {
|
||
const tabsContainer = this.container.querySelector('#editor-tabs');
|
||
if (!tabsContainer) return;
|
||
|
||
if (this.tabs.length === 0) {
|
||
tabsContainer.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
tabsContainer.innerHTML = this.tabs.map(tab => `
|
||
<div class="editor-tab ${tab.id === this.activeTab ? 'active' : ''} ${tab.dirty ? 'dirty' : ''}"
|
||
data-tab-id="${tab.id}"
|
||
title="${this.escapeHtml(tab.path)}">
|
||
<span class="tab-name">${this.escapeHtml(tab.name)}</span>
|
||
${tab.dirty ? '<span class="tab-dirty-indicator">●</span>' : ''}
|
||
<button class="tab-close" title="Close tab">×</button>
|
||
</div>
|
||
`).join('');
|
||
|
||
// Tab click handlers
|
||
tabsContainer.querySelectorAll('.editor-tab').forEach(tabEl => {
|
||
tabEl.addEventListener('click', (e) => {
|
||
if (!e.target.classList.contains('tab-close')) {
|
||
this.activateTab(tabEl.dataset.tabId);
|
||
}
|
||
});
|
||
|
||
const closeBtn = tabEl.querySelector('.tab-close');
|
||
if (closeBtn) {
|
||
closeBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
this.closeTab(tabEl.dataset.tabId);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
showPlaceholder() {
|
||
const contentArea = this.container.querySelector('#editor-content');
|
||
if (contentArea) {
|
||
contentArea.innerHTML = `
|
||
<div class="editor-placeholder">
|
||
<div class="placeholder-icon">📄</div>
|
||
<h2>No file open</h2>
|
||
<p>Select a file from the sidebar to start editing</p>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
saveTabsToStorage() {
|
||
const tabsData = this.tabs.map(tab => ({
|
||
path: tab.path,
|
||
name: tab.name,
|
||
dirty: tab.dirty,
|
||
active: tab.id === this.activeTab
|
||
}));
|
||
|
||
try {
|
||
sessionStorage.setItem('monaco-tabs', JSON.stringify(tabsData));
|
||
} catch (e) {
|
||
console.error('[MonacoEditor] Failed to save tabs:', e);
|
||
}
|
||
}
|
||
|
||
loadPersistedTabs() {
|
||
try {
|
||
const saved = sessionStorage.getItem('monaco-tabs');
|
||
if (saved) {
|
||
const tabsData = JSON.parse(saved);
|
||
console.log('[MonacoEditor] Restoring tabs:', tabsData);
|
||
// Note: Files will need to be reloaded from server
|
||
// This just restores the tab list structure
|
||
}
|
||
} catch (e) {
|
||
console.error('[MonacoEditor] Failed to load tabs:', e);
|
||
}
|
||
}
|
||
|
||
escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// Fallback for mobile devices
|
||
initializeFallback() {
|
||
this.setupContainer();
|
||
this.isMobile = true;
|
||
this.initialized = true;
|
||
|
||
// Add message about mobile limitation
|
||
const contentArea = this.container.querySelector('#editor-content');
|
||
if (contentArea) {
|
||
contentArea.innerHTML = `
|
||
<div class="editor-placeholder">
|
||
<div class="placeholder-icon">📱</div>
|
||
<h2>Mobile View</h2>
|
||
<p>Full code editing coming soon to mobile!</p>
|
||
<p>For now, please use a desktop or tablet device.</p>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
openFileFallback(filePath, content) {
|
||
// Mobile fallback - show read-only content
|
||
const contentArea = this.container.querySelector('#editor-content');
|
||
if (contentArea) {
|
||
const language = this.getLanguageFromFile(filePath);
|
||
contentArea.innerHTML = `
|
||
<div class="mobile-file-view">
|
||
<div class="file-header">
|
||
<h3>${this.escapeHtml(filePath)}</h3>
|
||
<span class="language-badge">${language}</span>
|
||
</div>
|
||
<pre class="code-content"><code>${this.escapeHtml(content || '')}</code></pre>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
destroy() {
|
||
// Dispose all editors and models
|
||
this.editors.forEach(editor => editor.dispose());
|
||
this.models.forEach(model => model.dispose());
|
||
this.editors.clear();
|
||
this.models.clear();
|
||
this.tabs = [];
|
||
this.activeTab = null;
|
||
}
|
||
}
|
||
|
||
// Global instance
|
||
let monacoEditor = null;
|
||
|
||
// Initialize when DOM is ready
|
||
async function initMonacoEditor() {
|
||
monacoEditor = new MonacoEditor('file-editor');
|
||
await monacoEditor.initialize();
|
||
return monacoEditor;
|
||
}
|
||
|
||
// Export to window
|
||
if (typeof window !== 'undefined') {
|
||
window.MonacoEditor = MonacoEditor;
|
||
|
||
// Auto-initialize
|
||
async function autoInit() {
|
||
try {
|
||
const editor = await initMonacoEditor();
|
||
window.monacoEditor = editor;
|
||
console.log('[MonacoEditor] Auto-initialization complete');
|
||
} catch (error) {
|
||
console.error('[MonacoEditor] Auto-initialization failed:', error);
|
||
window.monacoEditor = null;
|
||
}
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', () => autoInit());
|
||
} else {
|
||
autoInit();
|
||
}
|
||
}
|
||
|
||
// Export for use in other scripts
|
||
if (typeof module !== 'undefined' && module.exports) {
|
||
module.exports = { MonacoEditor };
|
||
}
|