Files
Netcatty/components/editor/TextEditorPane.tsx
T
bincxz 037b85bd66 fix(ui): smooth work-tab chrome transitions and split-pane autocomplete
Replace immersive instant-switch with animated active chrome theme sync so
top tabs match terminal sessions immediately on tab click, and clamp
autocomplete popups to the active pane so they stay anchored to the cursor
in split workspaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 01:10:33 +08:00

646 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, useState } 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 } });
import { useI18n } from '../../application/i18n/I18nProvider';
import { useClipboardBackend } from '../../application/state/useClipboardBackend';
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models';
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';
};
// Convert HSL string "h s% l%" to hex color
const hslToHex = (hslString: string): string => {
const parts = hslString.trim().split(/\s+/);
if (parts.length < 3) return '#1e1e1e';
const h = parseFloat(parts[0]) / 360;
const s = parseFloat(parts[1].replace('%', '')) / 100;
const l = parseFloat(parts[2].replace('%', '')) / 100;
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
let r: number, g: number, b: number;
if (s === 0) {
r = g = b = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
const toHex = (x: number) => {
const hex = Math.round(x * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
// Read a CSS custom-property and convert from HSL to hex
const getCssColor = (varName: string, fallback: string): string => {
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
return fallback;
}
const value = getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim();
return value ? hslToHex(value) : fallback;
};
interface EditorColors {
bg: string;
fg: string;
primary: string;
card: string;
mutedFg: string;
border: string;
}
/** Read all UI CSS variables that matter for the Monaco theme. */
const getEditorColors = (isDark: boolean): EditorColors => ({
bg: getCssColor('--background', isDark ? '#1e1e1e' : '#ffffff'),
fg: getCssColor('--foreground', isDark ? '#d4d4d4' : '#1e1e1e'),
primary: getCssColor('--primary', isDark ? '#569cd6' : '#0078d4'),
card: getCssColor('--card', isDark ? '#252526' : '#f3f3f3'),
mutedFg: getCssColor('--muted-foreground', isDark ? '#858585' : '#858585'),
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
});
/** Build a fingerprint string so we can detect UI theme color changes cheaply. */
const getThemeSignal = (): string => {
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
return '';
}
const root = document.documentElement;
return root.dataset.activeChromeTheme
?? getComputedStyle(root).getPropertyValue('--background').trim();
};
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 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 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 handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
// Track theme from document.documentElement class (syncs with app theme)
const [isDarkTheme, setIsDarkTheme] = useState(() =>
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
);
// Track a signal that changes whenever active chrome or base theme colors change
const [themeSignal, setThemeSignal] = useState(() => getThemeSignal());
// Custom theme name
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
// Define and update custom Monaco themes from active chrome / base UI colors
useEffect(() => {
if (!monaco) return;
const colors = getEditorColors(isDarkTheme);
const themeColors: Record<string, string> = {
'editor.background': colors.bg,
'editor.foreground': colors.fg,
'editorCursor.foreground': colors.primary,
'editor.selectionBackground': colors.primary + '40',
'editor.inactiveSelectionBackground': colors.primary + '25',
'editorLineNumber.foreground': colors.mutedFg,
'editorLineNumber.activeForeground': colors.fg,
'editor.lineHighlightBackground': colors.fg + '08',
'editorWidget.background': colors.card,
'editorWidget.foreground': colors.fg,
'editorWidget.border': colors.border,
'input.background': colors.card,
'input.foreground': colors.fg,
'input.border': colors.border,
};
monaco.editor.defineTheme('netcatty-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: themeColors,
});
monaco.editor.defineTheme('netcatty-light', {
base: 'vs',
inherit: true,
rules: [],
colors: themeColors,
});
monaco.editor.setTheme(customThemeName);
}, [monaco, isDarkTheme, themeSignal, customThemeName]);
// Listen for theme changes via MutationObserver on <html> class, style, and active chrome attr
useEffect(() => {
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return;
const root = document.documentElement;
const updateTheme = () => {
setIsDarkTheme(root.classList.contains('dark'));
setThemeSignal(getThemeSignal());
};
const observer = new MutationObserver(updateTheme);
observer.observe(root, {
attributes: true,
attributeFilter: ['class', 'style', 'data-active-chrome-theme'],
});
return () => observer.disconnect();
}, []);
const closeTabBinding = useMemo(
() => keyBindings.find((binding) => binding.action === 'closeTab'),
[keyBindings],
);
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, () => {
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.
// Skip custom paste when focus is inside the find/replace widget so that
// its input fields receive the pasted text via default browser behavior.
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
const active = document.activeElement;
if (active?.closest('.find-widget')) {
// Read clipboard and insert into the find/replace input field.
void (async () => {
try {
const text = await readClipboardTextRef.current();
if (!text) return;
// Monaco find widget inputs are <textarea> elements inside .monaco-inputbox
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
const start = active.selectionStart ?? active.value.length;
const end = active.selectionEnd ?? active.value.length;
active.focus();
active.setSelectionRange(start, end);
document.execCommand('insertText', false, text);
}
} catch {
// Ignore – paste simply won't work
}
})();
return;
}
void 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="text-sm font-semibold leading-none truncate flex-shrink-0">
{fileName}
</span>
{subtitle && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs leading-none text-muted-foreground truncate cursor-default">
{subtitle}
</span>
</TooltipTrigger>
<TooltipContent>{subtitle}</TooltipContent>
</Tooltip>
)}
{saveError && <span className="text-xs leading-none text-destructive truncate">{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;