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:
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)');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user