/** * TextEditorPane — pure Monaco editor body + toolbar. * Extracted from TextEditorModal.tsx. Contains no Dialog shell. * Parents (modal or tab) own content state, saving state, and toast calls. */ import { CloudUpload, Loader2, Maximize2, Search, WrapText, X, } from 'lucide-react'; import Editor, { type OnMount, loader, useMonaco } from '@monaco-editor/react'; import type * as Monaco from 'monaco-editor'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; // Configure Monaco to use local files instead of CDN const viteEnv = import.meta.env ?? { BASE_URL: "/" }; const monacoBasePath = viteEnv.DEV ? './node_modules/monaco-editor/min/vs' : `${viteEnv.BASE_URL}monaco/vs`; loader.config({ paths: { vs: monacoBasePath } }); const isMacPlatform = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform); import { useI18n } from '../../application/i18n/I18nProvider'; import { useClipboardBackend } from '../../application/state/useClipboardBackend'; import { isPrimaryModifierWBinding } from '../../application/state/windowCommandClose'; import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models'; import { pasteForMonacoEditorCommand } from '../../infrastructure/monaco/monacoClipboardPaste'; import { useNetcattyMonacoTheme } from '../../infrastructure/monaco/useNetcattyMonacoTheme'; import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils'; import { Button } from '../ui/button'; import { Combobox } from '../ui/combobox'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; // Map our language IDs to Monaco language IDs const languageIdToMonaco = (langId: string): string => { const mapping: Record = { 'javascript': 'javascript', 'typescript': 'typescript', 'python': 'python', 'shell': 'shell', 'batch': 'bat', 'powershell': 'powershell', 'c': 'c', 'cpp': 'cpp', 'java': 'java', 'kotlin': 'kotlin', 'go': 'go', 'rust': 'rust', 'ruby': 'ruby', 'php': 'php', 'perl': 'perl', 'lua': 'lua', 'r': 'r', 'swift': 'swift', 'dart': 'dart', 'csharp': 'csharp', 'fsharp': 'fsharp', 'vb': 'vb', 'html': 'html', 'css': 'css', 'scss': 'scss', 'sass': 'sass', 'less': 'less', 'json': 'json', 'jsonc': 'json', 'json5': 'json', 'xml': 'xml', 'yaml': 'yaml', 'toml': 'ini', 'ini': 'ini', 'sql': 'sql', 'graphql': 'graphql', 'markdown': 'markdown', 'plaintext': 'plaintext', 'vue': 'html', 'svelte': 'html', 'dockerfile': 'dockerfile', 'makefile': 'makefile', 'diff': 'diff', }; return mapping[langId] || 'plaintext'; }; export interface TextEditorPaneProps { fileName: string; content: string; languageId: string; wordWrap: boolean; saving: boolean; saveError: string | null; hotkeyScheme: HotkeyScheme; keyBindings: KeyBinding[]; /** Layout mode — affects header chrome (modal shows close+maximize; tab-form only shows content controls since tab has its own close). */ chrome: 'modal' | 'tab'; /** Optional secondary label shown next to the filename in muted text — used by the tab form to display `host:remotePath`. */ subtitle?: string; onContentChange: (content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => void; onLanguageChange: (nextLanguageId: string) => void; onToggleWordWrap: () => void; onSave: () => void; onRequestClose?: () => void; // modal only onPromoteToTab?: () => void; // modal only — omit to hide the maximize button initialViewState?: Monaco.editor.ICodeEditorViewState | null; } export const isTextEditorReadOnly = ({ saving }: { saving: boolean }): boolean => saving; export const canPromoteTextEditor = ({ saving }: { saving: boolean }): boolean => !saving; export function getTextEditorContentStats(content: string): { lineCount: number; charCount: number } { let lineCount = 1; for (let i = 0; i < content.length; i += 1) { if (content.charCodeAt(i) === 10) lineCount += 1; } return { lineCount, charCount: content.length }; } export function isTextEditorCommandWEnabled({ hotkeyScheme, closeTabBinding, isMac, }: { hotkeyScheme: HotkeyScheme; closeTabBinding?: KeyBinding; isMac: boolean; }): boolean { if (hotkeyScheme === 'disabled' || !closeTabBinding) return false; return isPrimaryModifierWBinding( hotkeyScheme === 'mac' ? closeTabBinding.mac : closeTabBinding.pc, matchesKeyBinding, isMac, ); } export const TextEditorPromoteButton: React.FC<{ saving: boolean; onPromoteToTab: () => void; title: string; }> = React.memo(({ saving, onPromoteToTab, title }) => ( {title} )); TextEditorPromoteButton.displayName = 'TextEditorPromoteButton'; const TextEditorPaneInner: React.FC = ({ fileName, content, languageId, wordWrap, saving, saveError, hotkeyScheme, keyBindings, chrome, subtitle, onContentChange, onLanguageChange, onToggleWordWrap, onSave, onRequestClose, onPromoteToTab, initialViewState, }) => { const { t } = useI18n(); const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend(); const monaco = useMonaco(); const customThemeName = useNetcattyMonacoTheme(monaco); const editorRef = useRef(null); // Ref to store the latest save function to avoid stale closure in keyboard shortcut const handleSaveRef = useRef<() => void>(() => {}); const handleCloseRef = useRef<(() => void) | null>(null); const closeTabCommandWEnabledRef = useRef(false); const handlePasteRef = useRef<() => Promise>(() => Promise.resolve()); const readClipboardTextRef = useRef<() => Promise>(() => Promise.resolve(null)); const closeTabBinding = useMemo( () => keyBindings.find((binding) => binding.action === 'closeTab'), [keyBindings], ); closeTabCommandWEnabledRef.current = isTextEditorCommandWEnabled({ hotkeyScheme, closeTabBinding, isMac: isMacPlatform, }); const handleSave = useCallback(() => { if (saving) return; onSave(); }, [saving, onSave]); // Keep the ref updated with the latest handleSave function useEffect(() => { handleSaveRef.current = handleSave; }, [handleSave]); // Keep the close ref fresh so the Monaco Cmd/Ctrl+W command invokes the // latest onRequestClose handler without re-binding the Monaco command. useEffect(() => { handleCloseRef.current = onRequestClose ?? null; }, [onRequestClose]); const readClipboardText = useCallback(async (): Promise => { try { if (navigator.clipboard?.readText) { return await navigator.clipboard.readText(); } } catch { // Fall through to Electron bridge } try { return await readClipboardTextFromBridge(); } catch { // Both clipboard APIs unavailable; signal failure so caller can fall back. return null; } }, [readClipboardTextFromBridge]); useEffect(() => { readClipboardTextRef.current = readClipboardText; }, [readClipboardText]); const handlePaste = useCallback(async () => { if (saving) return; const editor = editorRef.current; if (!editor) return; const text = await readClipboardText(); if (text === null) { // Clipboard read unavailable; fall back to Monaco's native paste. editor.trigger('keyboard', 'editor.action.clipboardPasteAction', null); return; } if (!text) return; const selections = editor.getSelections(); if (!selections || selections.length === 0) return; // Match Monaco's default multicursorPaste:'spread' behavior: // distribute one line per cursor when line count equals cursor count. const lines = text.split(/\r\n|\n/); const distribute = selections.length > 1 && lines.length === selections.length; editor.executeEdits( 'netcatty-paste', selections.map((selection, i) => ({ range: selection, text: distribute ? lines[i] : text, forceMoveMarkers: true, })), ); editor.focus(); }, [readClipboardText, saving]); useEffect(() => { handlePasteRef.current = handlePaste; }, [handlePaste]); const handleEditorChange = useCallback((value: string | undefined) => { if (saving) return; const editor = editorRef.current; onContentChange(value ?? '', editor ? editor.saveViewState() : null); }, [onContentChange, saving]); const handleEditorMount: OnMount = useCallback((editor, monaco) => { editorRef.current = editor; if (initialViewState) editor.restoreViewState(initialViewState); // Add save shortcut - use ref to avoid stale closure editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { handleSaveRef.current(); }); // Close-tab shortcut inside Monaco. The capture-phase keydown on the // Pane's root div also tries to handle this, but Monaco's internal // key-event dispatcher fires first for focused editor keystrokes, so // registering the command here is the reliable path. editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyW, () => { if (!closeTabCommandWEnabledRef.current) return; handleCloseRef.current?.(); }); // Add find shortcut (Ctrl+F / Cmd+F) editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => { // Trigger Monaco's built-in find widget editor.trigger('keyboard', 'actions.find', null); }); // Fallback paste path for Electron environments where Monaco paste can fail. // When focus is in the find/replace widget, paste into that input instead of the body. editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => { void pasteForMonacoEditorCommand({ activeElement: document.activeElement, readClipboardText: () => readClipboardTextRef.current(), pasteIntoEditor: () => handlePasteRef.current(), }); }); editor.focus(); }, [initialViewState]); // Capture-phase close-tab hotkey handler. Runs in both modal and tab chrome // so Cmd/Ctrl+W works even when focus is inside Monaco (which otherwise // swallows the event). Requires an `onRequestClose` prop from the parent. const handleDialogKeyDownCapture = useCallback((e: React.KeyboardEvent) => { if (hotkeyScheme === 'disabled' || !closeTabBinding || !onRequestClose) return; const isMac = hotkeyScheme === 'mac'; const keyStr = isMac ? closeTabBinding.mac : closeTabBinding.pc; if (!matchesKeyBinding(e.nativeEvent, keyStr, isMac)) return; e.preventDefault(); e.stopPropagation(); e.nativeEvent.stopPropagation(); onRequestClose(); }, [closeTabBinding, hotkeyScheme, onRequestClose]); // Trigger search dialog const handleSearch = useCallback(() => { if (editorRef.current) { editorRef.current.trigger('keyboard', 'actions.find', null); editorRef.current.focus(); } }, []); const supportedLanguages = useMemo(() => getSupportedLanguages(), []); const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]); const languageName = useMemo(() => getLanguageName(languageId), [languageId]); const contentStats = useMemo(() => getTextEditorContentStats(content), [content]); const languageOptions = useMemo( () => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })), [supportedLanguages], ); return (
{/* Header */}
{fileName} {subtitle && ( {subtitle} {subtitle} )} {saveError && {saveError}}
{/* Search button */} {t('common.search')} {/* Word wrap toggle */} {t('sftp.editor.wordWrap')} {/* Language selector */} onLanguageChange(v || 'plaintext')} placeholder={t('sftp.editor.syntaxHighlight')} triggerClassName="h-6 max-w-[170px] min-w-[112px] text-xs" /> {/* Save button */} {/* Maximize button — modal chrome only, when onPromoteToTab is provided */} {chrome === 'modal' && onPromoteToTab && ( )} {/* Close button — modal chrome only */} {chrome === 'modal' && onRequestClose && ( )}
{/* Monaco Editor */}
} options={{ // Prefer native context menu in Electron so right-click Paste uses OS clipboard path. contextmenu: false, minimap: { enabled: true }, fontSize: 14, lineNumbers: 'on', roundedSelection: false, scrollBeyondLastLine: false, automaticLayout: true, tabSize: 2, insertSpaces: true, wordWrap: wordWrap ? 'on' : 'off', readOnly: isTextEditorReadOnly({ saving }), domReadOnly: isTextEditorReadOnly({ saving }), folding: true, renderWhitespace: 'selection', bracketPairColorization: { enabled: true }, find: { addExtraSpaceOnTop: false, autoFindInSelection: 'never', seedSearchStringFromSelection: 'selection', }, }} />
{/* Footer */}
{languageName} {contentStats.lineCount} lines • {contentStats.charCount} characters
); }; export const TextEditorPane = React.memo(TextEditorPaneInner); TextEditorPane.displayName = 'TextEditorPane'; export default TextEditorPane;