514 lines
17 KiB
TypeScript
514 lines
17 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<string, string> = {
|
||
|
|
'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 }) => (
|
||
|
|
<Tooltip>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-6 w-6"
|
||
|
|
onClick={onPromoteToTab}
|
||
|
|
disabled={!canPromoteTextEditor({ saving })}
|
||
|
|
>
|
||
|
|
<Maximize2 size={13} />
|
||
|
|
</Button>
|
||
|
|
</TooltipTrigger>
|
||
|
|
<TooltipContent>{title}</TooltipContent>
|
||
|
|
</Tooltip>
|
||
|
|
));
|
||
|
|
TextEditorPromoteButton.displayName = 'TextEditorPromoteButton';
|
||
|
|
|
||
|
|
const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
|
||
|
|
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<Monaco.editor.IStandaloneCodeEditor | null>(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<void>>(() => Promise.resolve());
|
||
|
|
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => 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<string | null> => {
|
||
|
|
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<HTMLDivElement>) => {
|
||
|
|
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 (
|
||
|
|
<div
|
||
|
|
className="h-full flex flex-col"
|
||
|
|
onKeyDownCapture={handleDialogKeyDownCapture}
|
||
|
|
data-hotkey-close-tab={chrome === 'modal' ? 'true' : undefined}
|
||
|
|
>
|
||
|
|
{/* Header */}
|
||
|
|
<div className="h-9 px-3 py-1.5 border-b border-border/60 flex-shrink-0">
|
||
|
|
<div className="flex h-full items-center justify-between gap-3">
|
||
|
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||
|
|
<span className="flex-shrink-0 truncate text-sm font-semibold leading-5">
|
||
|
|
{fileName}
|
||
|
|
</span>
|
||
|
|
{subtitle && (
|
||
|
|
<Tooltip>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<span className="cursor-default truncate text-xs leading-4 text-muted-foreground">
|
||
|
|
{subtitle}
|
||
|
|
</span>
|
||
|
|
</TooltipTrigger>
|
||
|
|
<TooltipContent>{subtitle}</TooltipContent>
|
||
|
|
</Tooltip>
|
||
|
|
)}
|
||
|
|
{saveError && <span className="truncate text-xs leading-4 text-destructive">{saveError}</span>}
|
||
|
|
</div>
|
||
|
|
<div className="flex h-6 items-center gap-2 min-w-0">
|
||
|
|
{/* Search button */}
|
||
|
|
<Tooltip>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-6 w-6"
|
||
|
|
onClick={handleSearch}
|
||
|
|
>
|
||
|
|
<Search size={13} />
|
||
|
|
</Button>
|
||
|
|
</TooltipTrigger>
|
||
|
|
<TooltipContent>{t('common.search')}</TooltipContent>
|
||
|
|
</Tooltip>
|
||
|
|
|
||
|
|
{/* Word wrap toggle */}
|
||
|
|
<Tooltip>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant={wordWrap ? 'secondary' : 'ghost'}
|
||
|
|
size="icon"
|
||
|
|
className="h-6 w-6"
|
||
|
|
onClick={onToggleWordWrap}
|
||
|
|
>
|
||
|
|
<WrapText size={13} />
|
||
|
|
</Button>
|
||
|
|
</TooltipTrigger>
|
||
|
|
<TooltipContent>{t('sftp.editor.wordWrap')}</TooltipContent>
|
||
|
|
</Tooltip>
|
||
|
|
|
||
|
|
{/* Language selector */}
|
||
|
|
<Combobox
|
||
|
|
options={languageOptions}
|
||
|
|
value={languageId}
|
||
|
|
onValueChange={(v) => onLanguageChange(v || 'plaintext')}
|
||
|
|
placeholder={t('sftp.editor.syntaxHighlight')}
|
||
|
|
triggerClassName="h-6 max-w-[170px] min-w-[112px] text-xs"
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Save button */}
|
||
|
|
<Button
|
||
|
|
variant="default"
|
||
|
|
size="sm"
|
||
|
|
className="h-6 px-2.5 text-xs"
|
||
|
|
onClick={handleSave}
|
||
|
|
disabled={saving}
|
||
|
|
>
|
||
|
|
{saving ? (
|
||
|
|
<Loader2 size={13} className="mr-1 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<CloudUpload size={13} className="mr-1" />
|
||
|
|
)}
|
||
|
|
{saving ? t('sftp.editor.saving') : t('sftp.editor.save')}
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
{/* Maximize button — modal chrome only, when onPromoteToTab is provided */}
|
||
|
|
{chrome === 'modal' && onPromoteToTab && (
|
||
|
|
<TextEditorPromoteButton
|
||
|
|
saving={saving}
|
||
|
|
onPromoteToTab={onPromoteToTab}
|
||
|
|
title={t('sftp.editor.maximize')}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Close button — modal chrome only */}
|
||
|
|
{chrome === 'modal' && onRequestClose && (
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-6 w-6"
|
||
|
|
onClick={onRequestClose}
|
||
|
|
>
|
||
|
|
<X size={13} />
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Monaco Editor */}
|
||
|
|
<div className="flex-1 min-h-0 relative">
|
||
|
|
<Editor
|
||
|
|
height="100%"
|
||
|
|
language={monacoLanguage}
|
||
|
|
value={content}
|
||
|
|
onChange={handleEditorChange}
|
||
|
|
onMount={handleEditorMount}
|
||
|
|
theme={customThemeName}
|
||
|
|
loading={
|
||
|
|
<div className="absolute inset-0 flex items-center justify-center bg-background">
|
||
|
|
<Loader2 size={32} className="animate-spin text-muted-foreground" />
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
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',
|
||
|
|
},
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Footer */}
|
||
|
|
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
|
||
|
|
<span>
|
||
|
|
{languageName}
|
||
|
|
</span>
|
||
|
|
<span>
|
||
|
|
{contentStats.lineCount} lines • {contentStats.charCount} characters
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
export const TextEditorPane = React.memo(TextEditorPaneInner);
|
||
|
|
TextEditorPane.displayName = 'TextEditorPane';
|
||
|
|
|
||
|
|
export default TextEditorPane;
|