feat: Implement CodeMirror 6 file editor with tab support
Implement Phase 1 of the file editor & chat UI redesign: - CodeMirror 6 integration with syntax highlighting - Multi-file tab support with dirty state tracking - Custom dark theme matching GitHub's color scheme - Keyboard shortcuts (Ctrl+S to save, Ctrl+W to close tab) - Mobile-responsive design with proper touch targets - Fallback to basic textarea if CodeMirror fails to load Technical details: - Import map for ESM modules from node_modules - Language support for JS, Python, HTML, CSS, JSON, Markdown - Auto-initialization on DOM ready - Global window.fileEditor instance for integration - Serve node_modules at /claude/node_modules for import map Files added: - public/claude-ide/components/file-editor.js (main component) - public/claude-ide/components/file-editor.css (responsive styles) Files modified: - public/claude-ide/index.html (import map, script tags) - public/claude-ide/ide.js (updated loadFile function) - server.js (serve node_modules for CodeMirror) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
421
public/claude-ide/components/file-editor.css
Normal file
421
public/claude-ide/components/file-editor.css
Normal file
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* File Editor Component Styles
|
||||
* Mobile-first responsive design for CodeMirror 6 editor
|
||||
*/
|
||||
|
||||
/* === File Editor Container === */
|
||||
.file-editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* === Editor Header (Tabs + Actions) === */
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #161b22;
|
||||
border-bottom: 1px solid #30363d;
|
||||
padding: 0;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #484f58 #161b22;
|
||||
}
|
||||
|
||||
.editor-tabs::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.editor-tabs::-webkit-scrollbar-track {
|
||||
background: #161b22;
|
||||
}
|
||||
|
||||
.editor-tabs::-webkit-scrollbar-thumb {
|
||||
background: #484f58;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.editor-tabs::-webkit-scrollbar-thumb:hover {
|
||||
background: #6e7681;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
gap: 4px;
|
||||
border-left: 1px solid #30363d;
|
||||
}
|
||||
|
||||
/* === Editor Tabs === */
|
||||
.editor-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid #30363d;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #8b949e;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.editor-tab:hover {
|
||||
background: #21262d;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.editor-tab.active {
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
border-top: 2px solid #58a6ff;
|
||||
}
|
||||
|
||||
.editor-tab.dirty .tab-name {
|
||||
color: #e3b341;
|
||||
}
|
||||
|
||||
.editor-tab.dirty .tab-dirty-indicator {
|
||||
color: #e3b341;
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.tab-dirty-indicator {
|
||||
font-size: 10px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #8b949e;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background: #484f58;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
/* === Editor Content Area === */
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* === CodeMirror Editor Instance === */
|
||||
.cm-editor-instance {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* === Editor Placeholder === */
|
||||
.editor-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #484f58;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.editor-placeholder h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.editor-placeholder p {
|
||||
font-size: 1rem;
|
||||
color: #484f58;
|
||||
}
|
||||
|
||||
/* === Fallback Editor (when CodeMirror fails) === */
|
||||
.fallback-editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: 'Fira Code', 'JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* === Action Buttons === */
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #8b949e;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: #21262d;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.btn-icon:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* === CodeMirror Customization === */
|
||||
.codemirror-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.codemirror-editor .cm-scroller {
|
||||
font-family: 'Fira Code', 'JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.codemirror-editor .cm-content {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.codemirror-editor .cm-line {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* === Mobile Responsive === */
|
||||
@media (max-width: 640px) {
|
||||
.editor-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #30363d;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
border-left: none;
|
||||
border-top: 1px solid #30363d;
|
||||
padding: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.editor-tab {
|
||||
padding: 10px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.editor-placeholder h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.editor-placeholder p {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Tablet Responsive === */
|
||||
@media (min-width: 641px) and (max-width: 1024px) {
|
||||
.tab-name {
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Touch Targets (Mobile) === */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.editor-tab {
|
||||
padding: 12px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Dark Mode Scrollbar for Editor === */
|
||||
.cm-editor-instance ::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.cm-editor-instance ::-webkit-scrollbar-track {
|
||||
background: #0d1117;
|
||||
}
|
||||
|
||||
.cm-editor-instance ::-webkit-scrollbar-thumb {
|
||||
background: #30363d;
|
||||
border-radius: 7px;
|
||||
border: 3px solid #0d1117;
|
||||
}
|
||||
|
||||
.cm-editor-instance ::-webkit-scrollbar-thumb:hover {
|
||||
background: #484f58;
|
||||
}
|
||||
|
||||
.cm-editor-instance ::-webkit-scrollbar-corner {
|
||||
background: #0d1117;
|
||||
}
|
||||
|
||||
/* === Status Messages === */
|
||||
.status-message {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
color: #3fb950;
|
||||
background: rgba(63, 185, 80, 0.1);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #f85149;
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
}
|
||||
|
||||
.status-info {
|
||||
color: #58a6ff;
|
||||
background: rgba(88, 166, 255, 0.1);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* === Loading Spinner === */
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #30363d;
|
||||
border-top-color: #58a6ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* === No Files State === */
|
||||
.no-tabs {
|
||||
padding: 8px 12px;
|
||||
color: #484f58;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* === Focus Styles for Accessibility === */
|
||||
.editor-tab:focus-visible,
|
||||
.tab-close:focus-visible,
|
||||
.btn-icon:focus-visible {
|
||||
outline: 2px solid #58a6ff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* === Print Styles === */
|
||||
@media print {
|
||||
.editor-header,
|
||||
.editor-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
663
public/claude-ide/components/file-editor.js
Normal file
663
public/claude-ide/components/file-editor.js
Normal file
@@ -0,0 +1,663 @@
|
||||
/**
|
||||
* File Editor with CodeMirror 6
|
||||
* Supports: Multi-file tabs, syntax highlighting, dirty state tracking
|
||||
*/
|
||||
|
||||
import { EditorState, Compartment } from '@codemirror/state';
|
||||
import { EditorView, keymap, highlightSpecialChars, drawSelection, dropCursor, lineNumbers, rectangularSelection, crosshairCursor, highlightActiveLine, highlightSelectionMatches, EditorView as cmEditorView } from '@codemirror/view';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { searchKeymap, highlightSelectionMatches as searchHighlightMatches } from '@codemirror/search';
|
||||
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
|
||||
import { bracketMatching, codeFolding, foldGutter, syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language';
|
||||
import { tags } from '@lezer/highlight';
|
||||
|
||||
// Language support
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { css } from '@codemirror/lang-css';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
|
||||
// Custom dark theme matching GitHub's dark theme
|
||||
const customDarkTheme = cmEditorView.theme({
|
||||
'&': {
|
||||
backgroundColor: '#0d1117',
|
||||
color: '#c9d1d9',
|
||||
fontSize: '14px',
|
||||
fontFamily: "'Fira Code', 'JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas', monospace"
|
||||
},
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'inherit',
|
||||
overflow: 'auto'
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '12px 0',
|
||||
minHeight: '100%'
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0 12px'
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: '#0d1117',
|
||||
color: '#484f58',
|
||||
border: 'none'
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'transparent',
|
||||
color: '#c9d1d9'
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: '#161b22'
|
||||
},
|
||||
'.cm-focused': {
|
||||
outline: 'none'
|
||||
},
|
||||
'.cm-selectionBackground': {
|
||||
backgroundColor: '#264f78'
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground': {
|
||||
backgroundColor: '#264f78'
|
||||
},
|
||||
'.cm-selectionMatch': {
|
||||
backgroundColor: '#264f7855'
|
||||
},
|
||||
'.cm-cursor': {
|
||||
borderLeftColor: '#58a6ff'
|
||||
},
|
||||
'.cm-foldPlaceholder': {
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#484f58'
|
||||
},
|
||||
'.cm-tooltip': {
|
||||
border: '1px solid #30363d',
|
||||
backgroundColor: '#161b22',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.5)'
|
||||
},
|
||||
'.cm-tooltip-autocomplete': {
|
||||
'& > ul': {
|
||||
maxHeight: '200px',
|
||||
fontFamily: 'inherit',
|
||||
'& > li': {
|
||||
padding: '4px 8px',
|
||||
'&[aria-selected]': {
|
||||
backgroundColor: '#1f6feb',
|
||||
color: '#ffffff'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, {
|
||||
dark: true
|
||||
});
|
||||
|
||||
// State compartments for dynamic reconfiguration
|
||||
const languageCompartment = new Compartment();
|
||||
const tabSizeCompartment = new Compartment();
|
||||
|
||||
/**
|
||||
* Get language extension based on file extension
|
||||
*/
|
||||
function getLanguageExtension(filePath) {
|
||||
const ext = filePath.split('.').pop().toLowerCase();
|
||||
|
||||
const languageMap = {
|
||||
'js': javascript(),
|
||||
'jsx': javascript({ jsx: true }),
|
||||
'ts': javascript({ typescript: true }),
|
||||
'tsx': javascript({ typescript: true, jsx: true }),
|
||||
'mjs': javascript(),
|
||||
'cjs': javascript(),
|
||||
'py': python(),
|
||||
'html': html(),
|
||||
'htm': html(),
|
||||
'css': css(),
|
||||
'scss': css(),
|
||||
'sass': css(),
|
||||
'json': json(),
|
||||
'md': markdown(),
|
||||
'markdown': markdown(),
|
||||
'txt': null
|
||||
};
|
||||
|
||||
return languageMap[ext] || javascript();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create CodeMirror editor state
|
||||
*/
|
||||
function createEditorState(filePath, content, onChange) {
|
||||
const language = getLanguageExtension(filePath);
|
||||
|
||||
return EditorState.create({
|
||||
doc: content || '',
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
foldGutter(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
codeFolding(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion(),
|
||||
rectangularSelection(),
|
||||
crosshairCursor(),
|
||||
highlightActiveLine(),
|
||||
highlightSelectionMatches(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...completionKeymap,
|
||||
{
|
||||
key: 'Mod-s',
|
||||
run: () => {
|
||||
// Trigger save - will be handled by the component
|
||||
const event = new CustomEvent('editor-save', { bubbles: true });
|
||||
document.dispatchEvent(event);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'Mod-w',
|
||||
run: (view) => {
|
||||
// Trigger close tab - will be handled by the component
|
||||
const event = new CustomEvent('editor-close-tab', { bubbles: true });
|
||||
document.dispatchEvent(event);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
]),
|
||||
customDarkTheme,
|
||||
languageCompartment.of(language || []),
|
||||
tabSizeCompartment.of(EditorState.tabSize.of(4)),
|
||||
cmEditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
// Notify that content changed
|
||||
if (onChange) onChange();
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* FileEditor Class
|
||||
*/
|
||||
class FileEditor {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.editors = new Map(); // tabId -> EditorView
|
||||
this.activeTab = null;
|
||||
this.tabs = [];
|
||||
this.nextTabId = 1;
|
||||
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
initialize() {
|
||||
// Create editor container structure
|
||||
this.container.innerHTML = `
|
||||
<div class="file-editor-container">
|
||||
<div class="editor-header">
|
||||
<div class="editor-tabs" id="editor-tabs">
|
||||
<!-- Tabs will be rendered here -->
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<button class="btn-icon" id="btn-save-all" title="Save All (Ctrl+S)">💾</button>
|
||||
<button class="btn-icon" id="btn-close-all" title="Close All">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Set up event listeners
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Save all button
|
||||
const saveAllBtn = this.container.querySelector('#btn-save-all');
|
||||
if (saveAllBtn) {
|
||||
saveAllBtn.addEventListener('click', () => this.saveAllFiles());
|
||||
}
|
||||
|
||||
// Close all button
|
||||
const closeAllBtn = this.container.querySelector('#btn-close-all');
|
||||
if (closeAllBtn) {
|
||||
closeAllBtn.addEventListener('click', () => this.closeAllTabs());
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('editor-save', () => this.saveCurrentFile());
|
||||
document.addEventListener('editor-close-tab', () => this.closeCurrentTab());
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => this.refreshActiveEditor());
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a file in the editor
|
||||
*/
|
||||
async openFile(filePath, content) {
|
||||
// Check if file is already open
|
||||
const existingTab = this.tabs.find(tab => tab.path === filePath);
|
||||
if (existingTab) {
|
||||
this.activateTab(existingTab.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new tab
|
||||
const tabId = `tab-${this.nextTabId++}`;
|
||||
const tab = {
|
||||
id: tabId,
|
||||
path: filePath,
|
||||
name: filePath.split('/').pop(),
|
||||
dirty: false,
|
||||
originalContent: content || ''
|
||||
};
|
||||
|
||||
this.tabs.push(tab);
|
||||
|
||||
// Create CodeMirror editor instance
|
||||
const editorContainer = document.createElement('div');
|
||||
editorContainer.className = 'cm-editor-instance';
|
||||
editorContainer.style.display = 'none';
|
||||
|
||||
const contentArea = this.container.querySelector('#editor-content');
|
||||
|
||||
// Remove placeholder if it exists
|
||||
const placeholder = contentArea.querySelector('.editor-placeholder');
|
||||
if (placeholder) {
|
||||
placeholder.remove();
|
||||
}
|
||||
|
||||
contentArea.appendChild(editorContainer);
|
||||
|
||||
const state = createEditorState(filePath, content || '', () => {
|
||||
this.markDirty(tabId);
|
||||
});
|
||||
|
||||
const editor = new EditorView({
|
||||
state: state,
|
||||
parent: editorContainer
|
||||
});
|
||||
|
||||
this.editors.set(tabId, editor);
|
||||
|
||||
// Activate the new tab
|
||||
this.activateTab(tabId);
|
||||
|
||||
return tabId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a tab
|
||||
*/
|
||||
activateTab(tabId) {
|
||||
if (!this.editors.has(tabId)) {
|
||||
console.error('[FileEditor] Tab not found:', tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide all editors
|
||||
this.editors.forEach((editor, id) => {
|
||||
const container = editor.dom.parentElement;
|
||||
if (container) {
|
||||
container.style.display = id === tabId ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
|
||||
this.activeTab = tabId;
|
||||
this.renderTabs();
|
||||
|
||||
// Refresh the active editor to ensure proper rendering
|
||||
setTimeout(() => this.refreshActiveEditor(), 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a tab
|
||||
*/
|
||||
async 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) {
|
||||
await this.saveFile(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy editor
|
||||
const editor = this.editors.get(tabId);
|
||||
if (editor) {
|
||||
editor.destroy();
|
||||
this.editors.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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close current tab
|
||||
*/
|
||||
closeCurrentTab() {
|
||||
if (this.activeTab) {
|
||||
this.closeTab(this.activeTab);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all tabs
|
||||
*/
|
||||
async 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) {
|
||||
await this.saveAllFiles();
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy all editors
|
||||
this.editors.forEach(editor => editor.destroy());
|
||||
this.editors.clear();
|
||||
|
||||
this.tabs = [];
|
||||
this.activeTab = null;
|
||||
|
||||
this.renderTabs();
|
||||
this.showPlaceholder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a file
|
||||
*/
|
||||
async saveFile(tabId) {
|
||||
const tab = this.tabs.find(t => t.id === tabId);
|
||||
if (!tab) return;
|
||||
|
||||
const editor = this.editors.get(tabId);
|
||||
if (!editor) return;
|
||||
|
||||
const content = editor.state.doc.toString();
|
||||
|
||||
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('[FileEditor] Error saving file:', error);
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(`❌ Failed to save ${tab.name}: ${error.message}`, 'error', 3000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current file
|
||||
*/
|
||||
async saveCurrentFile() {
|
||||
if (this.activeTab) {
|
||||
await this.saveFile(this.activeTab);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save all files
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark tab as dirty (unsaved changes)
|
||||
*/
|
||||
markDirty(tabId) {
|
||||
const tab = this.tabs.find(t => t.id === tabId);
|
||||
if (tab && !tab.dirty) {
|
||||
tab.dirty = true;
|
||||
this.renderTabs();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any tab is dirty
|
||||
*/
|
||||
hasUnsavedChanges() {
|
||||
return this.tabs.some(t => t.dirty);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current file content
|
||||
*/
|
||||
getCurrentContent() {
|
||||
if (!this.activeTab) return null;
|
||||
|
||||
const editor = this.editors.get(this.activeTab);
|
||||
return editor ? editor.state.doc.toString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current file path
|
||||
*/
|
||||
getCurrentFilePath() {
|
||||
if (!this.activeTab) return null;
|
||||
|
||||
const tab = this.tabs.find(t => t.id === this.activeTab);
|
||||
return tab ? tab.path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh active editor
|
||||
*/
|
||||
refreshActiveEditor() {
|
||||
if (!this.activeTab) return;
|
||||
|
||||
const editor = this.editors.get(this.activeTab);
|
||||
if (editor) {
|
||||
editor.requestMeasure();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show placeholder when no files are open
|
||||
*/
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render tabs
|
||||
*/
|
||||
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}">
|
||||
<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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tab count
|
||||
*/
|
||||
getTabCount() {
|
||||
return this.tabs.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dirty tab count
|
||||
*/
|
||||
getDirtyTabCount() {
|
||||
return this.tabs.filter(t => t.dirty).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy editor and cleanup
|
||||
*/
|
||||
destroy() {
|
||||
// Destroy all editors
|
||||
this.editors.forEach(editor => editor.destroy());
|
||||
this.editors.clear();
|
||||
|
||||
this.tabs = [];
|
||||
this.activeTab = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other scripts
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { FileEditor };
|
||||
}
|
||||
|
||||
// Export to window for use from non-module scripts
|
||||
if (typeof window !== 'undefined') {
|
||||
window.FileEditor = FileEditor;
|
||||
console.log('[FileEditor] Component loaded and available globally');
|
||||
}
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Create global file editor instance
|
||||
if (!window.fileEditor) {
|
||||
window.fileEditor = new FileEditor(document.getElementById('file-editor'));
|
||||
console.log('[FileEditor] Auto-initialized on DOMContentLoaded');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// DOM is already ready
|
||||
if (!window.fileEditor) {
|
||||
window.fileEditor = new FileEditor(document.getElementById('file-editor'));
|
||||
console.log('[FileEditor] Auto-initialized (DOM already ready)');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -855,60 +855,74 @@ async function loadFile(filePath) {
|
||||
const res = await fetch(`/claude/api/file/${encodeURIComponent(filePath)}`);
|
||||
const data = await res.json();
|
||||
|
||||
const isHtmlFile = filePath.toLowerCase().endsWith('.html') || filePath.toLowerCase().endsWith('.htm');
|
||||
|
||||
const editorEl = document.getElementById('file-editor');
|
||||
|
||||
if (isHtmlFile) {
|
||||
// HTML file - show with preview option
|
||||
editorEl.innerHTML = `
|
||||
<div class="file-header">
|
||||
<h2>${filePath}</h2>
|
||||
<div class="file-actions">
|
||||
<button class="btn-secondary btn-sm" onclick="editFile('${filePath}')">Edit</button>
|
||||
<button class="btn-primary btn-sm" onclick="showHtmlPreview('${filePath}')">👁️ Preview</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-content" id="file-content-view">
|
||||
<div class="view-toggle">
|
||||
<button class="toggle-btn active" data-view="code" onclick="switchFileView('code')">Code</button>
|
||||
<button class="toggle-btn" data-view="preview" onclick="switchFileView('preview')">Preview</button>
|
||||
</div>
|
||||
<div class="code-view">
|
||||
<pre><code class="language-html">${escapeHtml(data.content)}</code></pre>
|
||||
</div>
|
||||
<div class="preview-view" style="display: none;">
|
||||
<iframe id="html-preview-frame" sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Store file content for preview
|
||||
window.currentFileContent = data.content;
|
||||
window.currentFilePath = filePath;
|
||||
|
||||
// Highlight code
|
||||
if (window.hljs) {
|
||||
document.querySelectorAll('#file-content-view pre code').forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
}
|
||||
// Check if FileEditor component is available
|
||||
if (window.fileEditor) {
|
||||
// Use the new CodeMirror-based editor
|
||||
await window.fileEditor.openFile(filePath, data.content || '');
|
||||
} else {
|
||||
// Non-HTML file - show as before
|
||||
editorEl.innerHTML = `
|
||||
<div class="file-header">
|
||||
<h2>${filePath}</h2>
|
||||
<div class="file-actions">
|
||||
<button class="btn-secondary btn-sm" onclick="editFile('${filePath}')">Edit</button>
|
||||
// Fallback to the old view if FileEditor is not loaded yet
|
||||
console.warn('[loadFile] FileEditor not available, using fallback');
|
||||
const editorEl = document.getElementById('file-editor');
|
||||
|
||||
const isHtmlFile = filePath.toLowerCase().endsWith('.html') || filePath.toLowerCase().endsWith('.htm');
|
||||
|
||||
if (isHtmlFile) {
|
||||
// HTML file - show with preview option
|
||||
editorEl.innerHTML = `
|
||||
<div class="file-header">
|
||||
<h2>${filePath}</h2>
|
||||
<div class="file-actions">
|
||||
<button class="btn-secondary btn-sm" onclick="editFile('${filePath}')">Edit</button>
|
||||
<button class="btn-primary btn-sm" onclick="showHtmlPreview('${filePath}')">👁️ Preview</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-content">
|
||||
<div class="markdown-body">${data.html}</div>
|
||||
</div>
|
||||
`;
|
||||
<div class="file-content" id="file-content-view">
|
||||
<div class="view-toggle">
|
||||
<button class="toggle-btn active" data-view="code" onclick="switchFileView('code')">Code</button>
|
||||
<button class="toggle-btn" data-view="preview" onclick="switchFileView('preview')">Preview</button>
|
||||
</div>
|
||||
<div class="code-view">
|
||||
<pre><code class="language-html">${escapeHtml(data.content)}</code></pre>
|
||||
</div>
|
||||
<div class="preview-view" style="display: none;">
|
||||
<iframe id="html-preview-frame" sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Store file content for preview
|
||||
window.currentFileContent = data.content;
|
||||
window.currentFilePath = filePath;
|
||||
|
||||
// Highlight code
|
||||
if (window.hljs) {
|
||||
document.querySelectorAll('#file-content-view pre code').forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Non-HTML file - show content
|
||||
editorEl.innerHTML = `
|
||||
<div class="file-header">
|
||||
<h2>${filePath}</h2>
|
||||
</div>
|
||||
<div class="file-content">
|
||||
<pre><code>${escapeHtml(data.content || '')}</code></pre>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading file:', error);
|
||||
const editorEl = document.getElementById('file-editor');
|
||||
if (editorEl) {
|
||||
editorEl.innerHTML = `
|
||||
<div class="file-error">
|
||||
<h2>Error loading file</h2>
|
||||
<p>${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,41 @@
|
||||
<link rel="stylesheet" href="/claude/claude-ide/preview-manager.css">
|
||||
<link rel="stylesheet" href="/claude/claude-ide/chat-enhanced.css">
|
||||
<link rel="stylesheet" href="/claude/claude-ide/terminal.css">
|
||||
<link rel="stylesheet" href="/claude/claude-ide/components/file-editor.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||||
|
||||
<!-- Import Map for CodeMirror 6 -->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"@codemirror/state": "/claude/node_modules/@codemirror/state/dist/index.cjs",
|
||||
"@codemirror/view": "/claude/node_modules/@codemirror/view/dist/index.cjs",
|
||||
"@codemirror/commands": "/claude/node_modules/@codemirror/commands/dist/index.cjs",
|
||||
"@codemirror/language": "/claude/node_modules/@codemirror/language/dist/index.cjs",
|
||||
"@codemirror/autocomplete": "/claude/node_modules/@codemirror/autocomplete/dist/index.cjs",
|
||||
"@codemirror/search": "/claude/node_modules/@codemirror/search/dist/index.cjs",
|
||||
"@codemirror/lint": "/claude/node_modules/@codemirror/lint/dist/index.cjs",
|
||||
"@codemirror/lang-javascript": "/claude/node_modules/@codemirror/lang-javascript/dist/index.cjs",
|
||||
"@codemirror/lang-python": "/claude/node_modules/@codemirror/lang-python/dist/index.cjs",
|
||||
"@codemirror/lang-html": "/claude/node_modules/@codemirror/lang-html/dist/index.cjs",
|
||||
"@codemirror/lang-css": "/claude/node_modules/@codemirror/lang-css/dist/index.cjs",
|
||||
"@codemirror/lang-json": "/claude/node_modules/@codemirror/lang-json/dist/index.cjs",
|
||||
"@codemirror/lang-markdown": "/claude/node_modules/@codemirror/lang-markdown/dist/index.cjs",
|
||||
"@codemirror/gutter": "/claude/node_modules/@codemirror/gutter/dist/index.cjs",
|
||||
"@codemirror/fold": "/claude/node_modules/@codemirror/fold/dist/index.cjs",
|
||||
"@codemirror/panel": "/claude/node_modules/@codemirror/panel/dist/index.cjs",
|
||||
"@lezer/highlight": "/claude/node_modules/@lezer/highlight/dist/index.cjs",
|
||||
"@lezer/common": "/claude/node_modules/@lezer/common/dist/index.cjs",
|
||||
"@lezer/javascript": "/claude/node_modules/@lezer/javascript/dist/index.cjs",
|
||||
"@lezer/python": "/claude/node_modules/@lezer/python/dist/index.cjs",
|
||||
"@lezer/html": "/claude/node_modules/@lezer/html/dist/index.cjs",
|
||||
"@lezer/css": "/claude/node_modules/@lezer/css/dist/index.cjs",
|
||||
"@lezer/json": "/claude/node_modules/@lezer/json/dist/index.cjs",
|
||||
"@lezer/markdown": "/claude/node_modules/@lezer/markdown/dist/index.cjs",
|
||||
"@codemirror/basic-setup": "/claude/node_modules/@codemirror/basic-setup/dist/index.cjs"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
@@ -344,6 +378,7 @@
|
||||
<script src="/claude/claude-ide/preview-manager.js"></script>
|
||||
<script src="/claude/claude-ide/chat-enhanced.js"></script>
|
||||
<script src="/claude/claude-ide/terminal.js"></script>
|
||||
<script type="module" src="/claude/claude-ide/components/file-editor.js"></script>
|
||||
|
||||
<!-- Debug Panel Toggle Script -->
|
||||
<script>
|
||||
|
||||
@@ -189,6 +189,9 @@ app.get('/projects', (req, res) => {
|
||||
// Serve static files (must come after specific routes)
|
||||
app.use('/claude', express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// Serve node_modules for CodeMirror 6 import map
|
||||
app.use('/claude/node_modules', express.static(path.join(__dirname, 'node_modules')));
|
||||
|
||||
// Login route
|
||||
app.post('/claude/api/login', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
Reference in New Issue
Block a user