Files
extension-pteromonaco/components/Editor.tsx
Zephrynis bec87d1493 Fix save shortcut to handle uppercase 'S' key
Updated the keyboard shortcut handler to use event.key.toLowerCase() for detecting 's', ensuring the save action works regardless of key case.
2025-08-06 23:53:16 +01:00

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;