mirror of
https://github.com/blueprint-community/extension-pteromonaco.git
synced 2026-02-18 19:41:53 +00:00
Updated the keyboard shortcut handler to use event.key.toLowerCase() for detecting 's', ensuring the save action works regardless of key case.
432 lines
16 KiB
TypeScript
432 lines
16 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { useHistory, useLocation, useParams } from 'react-router';
|
|
import { dirname } from 'path';
|
|
|
|
import { encodePathSegments, hashToPath } from '@/helpers';
|
|
import { httpErrorToHuman } from '@/api/http';
|
|
import getFileContents from '@/api/server/files/getFileContents';
|
|
import saveFileContents from '@/api/server/files/saveFileContents';
|
|
import FileNameModal from '@/components/server/files/FileNameModal';
|
|
import { ServerContext } from '@/state/server';
|
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
|
import useFlash from '@/plugins/useFlash';
|
|
import Can from '@/components/elements/Can';
|
|
import Select from '@/components/elements/Select';
|
|
import Button from '@/components/elements/Button';
|
|
import modes from '@/modes';
|
|
declare global {
|
|
interface Window {
|
|
monaco: any;
|
|
require: any;
|
|
}
|
|
}
|
|
|
|
// Add CSS to document head for responsive button styling
|
|
const addButtonStyles = () => {
|
|
const styleId = 'responsive-button-styles';
|
|
if (!document.getElementById(styleId)) {
|
|
const style = document.createElement('style');
|
|
style.id = styleId;
|
|
style.textContent = `
|
|
.responsive-button {
|
|
flex: 1 1 0%;
|
|
}
|
|
@media (min-width: 640px) {
|
|
.responsive-button {
|
|
flex: none;
|
|
}
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
};
|
|
|
|
const Editor = () => {
|
|
// Router hooks
|
|
const { hash } = useLocation();
|
|
const history = useHistory();
|
|
const { action } = useParams<{ action: 'new' | string }>();
|
|
|
|
// Component state
|
|
const [content, setContent] = useState('');
|
|
const [modalVisible, setModalVisible] = useState(false);
|
|
const [loading, setLoading] = useState(action === 'edit');
|
|
const [monacoLoaded, setMonacoLoaded] = useState(false);
|
|
const [lang, setLang] = useState('text/plain');
|
|
|
|
// Refs
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const editorRef = useRef<any>(null);
|
|
|
|
// Context and hooks
|
|
const id = ServerContext.useStoreState((state) => state.server.data!.id);
|
|
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
|
const setDirectory = ServerContext.useStoreActions((actions) => actions.files.setDirectory);
|
|
const { addError, clearFlashes } = useFlash();
|
|
|
|
// Auto-detect language based on file extension
|
|
const detectLanguageFromPath = (filePath: string): string => {
|
|
const extension = filePath.split('.').pop()?.toLowerCase();
|
|
|
|
const extensionToMime: { [key: string]: string } = {
|
|
// JavaScript/TypeScript
|
|
'js': 'text/javascript',
|
|
'jsx': 'text/javascript',
|
|
'ts': 'application/typescript',
|
|
'tsx': 'application/typescript',
|
|
|
|
// Web technologies
|
|
'html': 'text/html',
|
|
'htm': 'text/html',
|
|
'css': 'text/css',
|
|
'scss': 'text/x-scss',
|
|
'sass': 'text/x-sass',
|
|
'xml': 'application/xml',
|
|
|
|
// Data formats
|
|
'json': 'application/json',
|
|
'yaml': 'text/x-yaml',
|
|
'yml': 'text/x-yaml',
|
|
'toml': 'text/x-toml',
|
|
|
|
// Programming languages
|
|
'py': 'text/x-python',
|
|
'php': 'text/x-php',
|
|
'java': 'text/x-java',
|
|
'c': 'text/x-csrc',
|
|
'cpp': 'text/x-c++src',
|
|
'cxx': 'text/x-c++src',
|
|
'cc': 'text/x-c++src',
|
|
'cs': 'text/x-csharp',
|
|
'go': 'text/x-go',
|
|
'rs': 'text/x-rustsrc',
|
|
'rb': 'text/x-ruby',
|
|
'lua': 'text/x-lua',
|
|
|
|
// Shell/Config
|
|
'sh': 'text/x-sh',
|
|
'bash': 'text/x-sh',
|
|
'zsh': 'text/x-sh',
|
|
'dockerfile': 'text/x-dockerfile',
|
|
'env': 'text/x-properties',
|
|
'properties': 'text/x-properties',
|
|
'conf': 'text/x-nginx-conf',
|
|
'nginx': 'text/x-nginx-conf',
|
|
|
|
// Database
|
|
'sql': 'text/x-sql',
|
|
|
|
// Documentation
|
|
'md': 'text/x-markdown',
|
|
'markdown': 'text/x-markdown',
|
|
'txt': 'text/plain',
|
|
|
|
// Other
|
|
'diff': 'text/x-diff',
|
|
'patch': 'text/x-diff',
|
|
'vue': 'script/x-vue'
|
|
};
|
|
|
|
return extensionToMime[extension || ''] || 'text/plain';
|
|
};
|
|
|
|
// Map MIME types to Monaco language identifiers
|
|
const getMonacoLanguage = (mimeType: string): string => {
|
|
const languageMap: { [key: string]: string } = {
|
|
'text/plain': 'plaintext',
|
|
'application/json': 'json',
|
|
'text/javascript': 'javascript',
|
|
'application/javascript': 'javascript',
|
|
'application/typescript': 'typescript',
|
|
'text/typescript': 'typescript',
|
|
'text/html': 'html',
|
|
'text/css': 'css',
|
|
'text/xml': 'xml',
|
|
'application/xml': 'xml',
|
|
'text/yaml': 'yaml',
|
|
'application/x-yaml': 'yaml',
|
|
'text/x-yaml': 'yaml',
|
|
'application/yaml': 'yaml',
|
|
'text/x-python': 'python',
|
|
'application/x-python': 'python',
|
|
'text/python': 'python',
|
|
'application/x-php': 'php',
|
|
'text/x-php': 'php',
|
|
'text/php': 'php',
|
|
'text/x-java': 'java',
|
|
'application/java': 'java',
|
|
'text/x-csharp': 'csharp',
|
|
'text/csharp': 'csharp',
|
|
'text/x-sql': 'sql',
|
|
'application/sql': 'sql',
|
|
'text/x-sh': 'shell',
|
|
'application/x-sh': 'shell',
|
|
'text/x-shellscript': 'shell',
|
|
'application/x-shellscript': 'shell',
|
|
'text/x-dockerfile': 'dockerfile',
|
|
'application/x-dockerfile': 'dockerfile',
|
|
'text/markdown': 'markdown',
|
|
'text/x-markdown': 'markdown',
|
|
'text/x-gfm': 'markdown',
|
|
'application/x-httpd-php': 'php',
|
|
'text/x-c': 'c',
|
|
'text/x-csrc': 'c',
|
|
'text/x-c++': 'cpp',
|
|
'text/x-c++src': 'cpp',
|
|
'text/x-cpp': 'cpp',
|
|
'text/x-go': 'go',
|
|
'text/x-ruby': 'ruby',
|
|
'text/x-rustsrc': 'rust',
|
|
'text/x-lua': 'lua',
|
|
'text/x-sass': 'scss',
|
|
'text/x-scss': 'scss',
|
|
'text/x-toml': 'ini',
|
|
'text/x-nginx-conf': 'nginx',
|
|
'text/x-properties': 'ini',
|
|
'text/x-diff': 'diff',
|
|
'text/x-cassandra': 'sql',
|
|
'text/x-mariadb': 'mysql',
|
|
'text/x-mssql': 'sql',
|
|
'text/x-mysql': 'mysql',
|
|
'text/x-pgsql': 'pgsql',
|
|
'text/x-sqlite': 'sql',
|
|
'message/http': 'http',
|
|
'script/x-vue': 'html'
|
|
};
|
|
return languageMap[mimeType] || 'plaintext';
|
|
};
|
|
|
|
// Auto-detect and set language when editing existing files
|
|
useEffect(() => {
|
|
if (action === 'edit' && hash) {
|
|
const path = hashToPath(hash);
|
|
const detectedMime = detectLanguageFromPath(path);
|
|
setLang(detectedMime);
|
|
}
|
|
}, [action, hash]);
|
|
|
|
const save = (name?: string) => {
|
|
if (!editorRef.current) {
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
clearFlashes('files:view');
|
|
|
|
const editorContent = editorRef.current.getValue();
|
|
const filePath = name || hashToPath(hash);
|
|
|
|
saveFileContents(uuid, filePath, editorContent)
|
|
.then(() => {
|
|
// Update the content state to match what was saved
|
|
setContent(editorContent);
|
|
|
|
if (name) {
|
|
// For new files, navigate to edit mode with the new file
|
|
history.push(`/server/${id}/files/edit#/${encodePathSegments(name)}`);
|
|
// Update the directory context
|
|
setDirectory(dirname(name));
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error('Error saving file:', error);
|
|
addError({ message: httpErrorToHuman(error), key: 'files:view' });
|
|
})
|
|
.then(() => setLoading(false));
|
|
};
|
|
// Load file contents for existing files
|
|
useEffect(() => {
|
|
if (action === 'new') return;
|
|
|
|
setLoading(true);
|
|
const path = hashToPath(hash);
|
|
setDirectory(dirname(path));
|
|
|
|
getFileContents(uuid, path)
|
|
.then(setContent)
|
|
.catch((error) => {
|
|
console.error(error);
|
|
addError({ message: httpErrorToHuman(error), key: 'files:view' });
|
|
})
|
|
.then(() => setLoading(false));
|
|
}, [action, uuid, hash]);
|
|
// Load Monaco Editor from CDN
|
|
useEffect(() => {
|
|
const loadMonaco = () => {
|
|
// Check if Monaco is already loaded
|
|
if (window.monaco) {
|
|
setMonacoLoaded(true);
|
|
return;
|
|
}
|
|
|
|
// Load the Monaco Editor CSS
|
|
const cssLink = document.createElement('link');
|
|
cssLink.rel = 'stylesheet';
|
|
cssLink.href = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/editor/editor.main.css';
|
|
document.head.appendChild(cssLink);
|
|
|
|
// Load the Monaco Editor JavaScript
|
|
const script = document.createElement('script');
|
|
script.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js';
|
|
script.onload = () => {
|
|
window.require.config({
|
|
paths: {
|
|
vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs'
|
|
}
|
|
});
|
|
window.require(['vs/editor/editor.main'], () => {
|
|
setMonacoLoaded(true);
|
|
});
|
|
};
|
|
document.head.appendChild(script);
|
|
};
|
|
|
|
loadMonaco();
|
|
|
|
// Cleanup function
|
|
return () => {
|
|
if (editorRef.current) {
|
|
editorRef.current.dispose();
|
|
}
|
|
};
|
|
}, []);
|
|
// Initialize editor when both Monaco is loaded and we have content (or for new files)
|
|
useEffect(() => {
|
|
if (!monacoLoaded || !containerRef.current) {
|
|
return;
|
|
}
|
|
|
|
// For new files, initialize immediately
|
|
// For edit files, wait until content is loaded (loading is false)
|
|
// But don't recreate editor when loading changes during save operations
|
|
if ((action === 'new' && !editorRef.current) || (action === 'edit' && !loading && !editorRef.current)) {
|
|
// Destroy existing editor if it exists (should not happen with new logic)
|
|
if (editorRef.current) {
|
|
editorRef.current.dispose();
|
|
}
|
|
|
|
// For new files, use empty string if content is empty
|
|
const initialContent = action === 'new' && !content ? '' : content;
|
|
|
|
// Always start with plaintext for new files, language will be set separately
|
|
const editorLanguage = 'plaintext';
|
|
|
|
// Create the editor
|
|
editorRef.current = window.monaco.editor.create(containerRef.current, {
|
|
value: initialContent,
|
|
language: editorLanguage,
|
|
theme: 'vs-dark',
|
|
automaticLayout: true,
|
|
minimap: {
|
|
enabled: true
|
|
},
|
|
fontSize: 14,
|
|
wordWrap: 'off',
|
|
scrollBeyondLastLine: false
|
|
});
|
|
|
|
// Apply the current language after editor is fully initialized
|
|
setTimeout(() => {
|
|
if (editorRef.current && lang !== 'text/plain') {
|
|
const monacoLanguage = getMonacoLanguage(lang);
|
|
const model = editorRef.current.getModel();
|
|
if (model && window.monaco) {
|
|
window.monaco.editor.setModelLanguage(model, monacoLanguage);
|
|
}
|
|
}
|
|
}, 100);
|
|
}
|
|
}, [monacoLoaded, content, loading, action, lang]);
|
|
// Update editor language when lang changes
|
|
useEffect(() => {
|
|
if (editorRef.current && window.monaco) {
|
|
const model = editorRef.current.getModel();
|
|
if (model) {
|
|
const monacoLanguage = getMonacoLanguage(lang);
|
|
|
|
// Check if the language is supported by Monaco
|
|
const supportedLanguages = window.monaco.languages.getLanguages();
|
|
const isSupported = supportedLanguages.some((l: any) => l.id === monacoLanguage);
|
|
|
|
if (isSupported) {
|
|
window.monaco.editor.setModelLanguage(model, monacoLanguage);
|
|
} else {
|
|
window.monaco.editor.setModelLanguage(model, 'plaintext');
|
|
}
|
|
}
|
|
}
|
|
}, [lang]);
|
|
|
|
// Add keyboard shortcut for Ctrl+S (Windows/Linux) and Cmd+S (macOS)
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
|
|
event.preventDefault();
|
|
if (action === 'edit') {
|
|
save();
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => {
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, [action, save]);
|
|
|
|
// Update editor content when content state changes (for existing editor)
|
|
useEffect(() => {
|
|
if (editorRef.current && window.monaco && content !== editorRef.current.getValue()) {
|
|
editorRef.current.setValue(content);
|
|
}
|
|
}, [content]);
|
|
|
|
|
|
return (
|
|
<>
|
|
<FileNameModal
|
|
visible={modalVisible}
|
|
onDismissed={() => setModalVisible(false)}
|
|
onFileNamed={(name) => {
|
|
setModalVisible(false);
|
|
// Auto-detect language based on the new file name
|
|
const detectedMime = detectLanguageFromPath(name);
|
|
setLang(detectedMime);
|
|
save(name);
|
|
}}
|
|
/>
|
|
|
|
<div style={{ position: 'relative' }}>
|
|
<SpinnerOverlay visible={loading} />
|
|
<div ref={containerRef} id="monaco-container" style={{borderRadius: 'var(--borderRadius)', overflow: 'hidden'}} />
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
|
|
<div className='FileEditContainer___StyledDiv5-sc-48rzpu-9 arKOj'>
|
|
<Select value={lang} onChange={(e) => setLang(e.currentTarget.value)}>
|
|
{modes.map((mode) => (
|
|
<option key={`${mode.name}_${mode.mime}`} value={mode.mime}>
|
|
{mode.name}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
|
|
{action === 'edit' ? (
|
|
<Can action={'file.update'}>
|
|
<Button onClick={() => save()}>
|
|
Save Content
|
|
</Button>
|
|
</Can>
|
|
) : (
|
|
<Can action={'file.create'}>
|
|
<Button onClick={() => setModalVisible(true)}>
|
|
Create File
|
|
</Button>
|
|
</Can>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
export default Editor; |