[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
69
components/editor/TextEditorPane.test.tsx
Normal file
69
components/editor/TextEditorPane.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import {
|
||||
canPromoteTextEditor,
|
||||
getTextEditorContentStats,
|
||||
isTextEditorCommandWEnabled,
|
||||
isTextEditorReadOnly,
|
||||
TextEditorPromoteButton,
|
||||
} from "./TextEditorPane.tsx";
|
||||
import { TooltipProvider } from "../ui/tooltip.tsx";
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../domain/models/keyBindings.ts";
|
||||
|
||||
const wrap = (child: React.ReactElement) =>
|
||||
React.createElement(TooltipProvider, null, child);
|
||||
|
||||
test("disables promoting a modal editor to a tab while a save is running", () => {
|
||||
assert.equal(canPromoteTextEditor({ saving: true }), false);
|
||||
assert.equal(canPromoteTextEditor({ saving: false }), true);
|
||||
assert.equal(isTextEditorReadOnly({ saving: true }), true);
|
||||
assert.equal(isTextEditorReadOnly({ saving: false }), false);
|
||||
});
|
||||
|
||||
test("renders the promote button disabled while a save is running", () => {
|
||||
const savingMarkup = renderToStaticMarkup(
|
||||
wrap(
|
||||
React.createElement(TextEditorPromoteButton, {
|
||||
saving: true,
|
||||
onPromoteToTab: () => {},
|
||||
title: "Maximize",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const idleMarkup = renderToStaticMarkup(
|
||||
wrap(
|
||||
React.createElement(TextEditorPromoteButton, {
|
||||
saving: false,
|
||||
onPromoteToTab: () => {},
|
||||
title: "Maximize",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(savingMarkup, /disabled=""/);
|
||||
assert.doesNotMatch(idleMarkup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("counts editor content without allocating line arrays", () => {
|
||||
assert.deepEqual(getTextEditorContentStats(""), { lineCount: 1, charCount: 0 });
|
||||
assert.deepEqual(getTextEditorContentStats("one\ntwo\n"), { lineCount: 3, charCount: 8 });
|
||||
});
|
||||
|
||||
test("Monaco Cmd+W follows the current close-tab binding", () => {
|
||||
const closeTabBinding = DEFAULT_KEY_BINDINGS.find((binding) => binding.action === "closeTab");
|
||||
assert.ok(closeTabBinding);
|
||||
|
||||
assert.equal(isTextEditorCommandWEnabled({ hotkeyScheme: "mac", closeTabBinding, isMac: true }), true);
|
||||
assert.equal(isTextEditorCommandWEnabled({ hotkeyScheme: "pc", closeTabBinding, isMac: false }), true);
|
||||
assert.equal(isTextEditorCommandWEnabled({ hotkeyScheme: "pc", closeTabBinding, isMac: true }), false);
|
||||
assert.equal(isTextEditorCommandWEnabled({ hotkeyScheme: "mac", closeTabBinding, isMac: false }), false);
|
||||
assert.equal(isTextEditorCommandWEnabled({ hotkeyScheme: "disabled", closeTabBinding, isMac: true }), false);
|
||||
assert.equal(isTextEditorCommandWEnabled({
|
||||
hotkeyScheme: "mac",
|
||||
closeTabBinding: { ...closeTabBinding, mac: "⌘ + E" },
|
||||
isMac: true,
|
||||
}), false);
|
||||
});
|
||||
513
components/editor/TextEditorPane.tsx
Normal file
513
components/editor/TextEditorPane.tsx
Normal file
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* 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;
|
||||
30
components/editor/TextEditorTabView.test.ts
Normal file
30
components/editor/TextEditorTabView.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
},
|
||||
});
|
||||
|
||||
const { getTextEditorTabShellStyle } = await import('./TextEditorTabView');
|
||||
|
||||
test('visible editor tab leaves room for the terminal host sidebar', () => {
|
||||
assert.deepEqual(getTextEditorTabShellStyle(true, 280), {
|
||||
zIndex: 20,
|
||||
left: 280,
|
||||
});
|
||||
});
|
||||
|
||||
test('hidden editor tab stays hidden', () => {
|
||||
assert.deepEqual(getTextEditorTabShellStyle(false, 280), {
|
||||
pointerEvents: 'none',
|
||||
visibility: 'hidden',
|
||||
zIndex: 20,
|
||||
left: 280,
|
||||
});
|
||||
});
|
||||
132
components/editor/TextEditorTabView.tsx
Normal file
132
components/editor/TextEditorTabView.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* TextEditorTabView — thin wrapper that binds an editorTab entry to TextEditorPane.
|
||||
*
|
||||
* Each tab has its own instance (keyed by tabId), so Monaco is never torn down
|
||||
* on tab-switch — we just toggle CSS visibility via the `isVisible` prop.
|
||||
*/
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { saveEditorTab } from '../../application/state/editorTabSave';
|
||||
import { editorTabStore, useEditorTab, type EditorTabId } from '../../application/state/editorTabStore';
|
||||
import { useIsEditorTabActive } from '../../application/state/activeTabStore';
|
||||
import { useTerminalHostTreeLayoutWidth } from '../../application/state/terminalHostTreeStore';
|
||||
import type { HotkeyScheme, KeyBinding } from '../../domain/models';
|
||||
import type { Host } from '../../types';
|
||||
import { toast } from '../ui/toast';
|
||||
import { TextEditorPane } from './TextEditorPane';
|
||||
|
||||
export interface TextEditorTabViewProps {
|
||||
tabId: EditorTabId;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
/** Host lookup for building the `host:remotePath` subtitle next to the filename. */
|
||||
hostById: Map<string, Host>;
|
||||
/** Routed into Monaco's Cmd/Ctrl+W command so closing the editor tab works
|
||||
* even when focus is inside the editor (Monaco otherwise swallows the event). */
|
||||
onRequestClose: (tabId: EditorTabId) => void;
|
||||
}
|
||||
|
||||
export function getTextEditorTabShellStyle(isVisible: boolean, hostTreeLayoutWidth: number): React.CSSProperties {
|
||||
return {
|
||||
...(isVisible ? null : { pointerEvents: 'none', visibility: 'hidden' }),
|
||||
zIndex: 20,
|
||||
left: hostTreeLayoutWidth,
|
||||
};
|
||||
}
|
||||
|
||||
export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
|
||||
tabId,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
hostById,
|
||||
onRequestClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const tab = useEditorTab(tabId);
|
||||
// Self-subscribe visibility so switching tabs only re-renders this editor
|
||||
// instance, not AppView/App.
|
||||
const isVisible = useIsEditorTabActive(tabId);
|
||||
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => {
|
||||
editorTabStore.updateContent(tabId, content, viewState);
|
||||
},
|
||||
[tabId],
|
||||
);
|
||||
|
||||
const handleLanguageChange = useCallback(
|
||||
(lang: string) => {
|
||||
editorTabStore.setLanguage(tabId, lang);
|
||||
},
|
||||
[tabId],
|
||||
);
|
||||
|
||||
const handleToggleWordWrap = useCallback(() => {
|
||||
const current = editorTabStore.getTab(tabId);
|
||||
if (!current) return;
|
||||
editorTabStore.setWordWrap(tabId, !current.wordWrap);
|
||||
}, [tabId]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const ok = await saveEditorTab(tabId);
|
||||
if (ok) {
|
||||
toast.success(t('sftp.editor.saved'), 'SFTP');
|
||||
} else {
|
||||
const msg = editorTabStore.getTab(tabId)?.saveError ?? t('sftp.editor.saveFailed');
|
||||
toast.error(msg, 'SFTP');
|
||||
}
|
||||
}, [tabId, t]);
|
||||
|
||||
const handleRequestClose = useCallback(() => {
|
||||
onRequestClose(tabId);
|
||||
}, [onRequestClose, tabId]);
|
||||
|
||||
// Tab has been closed — render nothing (parent should remove this instance,
|
||||
// but guard here in case of a transient render before unmount).
|
||||
if (!tab) return null;
|
||||
|
||||
const isDirty = tab.content !== tab.baselineContent;
|
||||
// Subtitle shown next to the filename in the Pane header, e.g.
|
||||
// "Rainyun-114.66.26.174:/root/hello-server.go". Falls back to hostId when
|
||||
// we don't have a Host record (session may have been removed).
|
||||
const host = hostById.get(tab.hostId);
|
||||
const hostLabel = host?.label ?? tab.hostId;
|
||||
const subtitle = `${hostLabel}:${tab.remotePath}`;
|
||||
|
||||
return (
|
||||
// Sibling tab panels (VaultView, SftpView, TerminalLayerMount, LogView)
|
||||
// all fill their flex-1 parent via `absolute inset-0`. Match that here so
|
||||
// an inactive editor tab doesn't collapse to zero height in normal flow,
|
||||
// and an active one fills the viewport instead of stacking beneath others.
|
||||
// z-index high enough to stay above the terminal workspace while leaving
|
||||
// room for the shared host sidebar when it is open.
|
||||
<div
|
||||
style={getTextEditorTabShellStyle(isVisible, hostTreeLayoutWidth)}
|
||||
className="absolute top-0 right-0 bottom-0 min-h-0 flex flex-col bg-background"
|
||||
>
|
||||
<TextEditorPane
|
||||
chrome="tab"
|
||||
fileName={`${tab.fileName}${isDirty ? ' *' : ''}`}
|
||||
subtitle={subtitle}
|
||||
onRequestClose={handleRequestClose}
|
||||
content={tab.content}
|
||||
languageId={tab.languageId}
|
||||
wordWrap={tab.wordWrap}
|
||||
saving={tab.savingState === 'saving'}
|
||||
saveError={tab.saveError}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onContentChange={handleContentChange}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
onToggleWordWrap={handleToggleWordWrap}
|
||||
onSave={handleSave}
|
||||
initialViewState={tab.viewState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextEditorTabView;
|
||||
16
components/editor/UnsavedChangesDialog.test.ts
Normal file
16
components/editor/UnsavedChangesDialog.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./UnsavedChangesDialog.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("unsaved prompt singleton registers during render, not only in useEffect", () => {
|
||||
// AppView close / Cmd+W call promptUnsavedChanges outside the render-prop.
|
||||
// Assigning only in useEffect leaves a first-paint window where the
|
||||
// singleton is null and dirty closes silently resolve to "cancel".
|
||||
assert.match(source, /promptSingleton = prompt;/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/useEffect\(\(\) => \{\s*promptSingleton = prompt;/,
|
||||
);
|
||||
});
|
||||
104
components/editor/UnsavedChangesDialog.tsx
Normal file
104
components/editor/UnsavedChangesDialog.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
|
||||
export type UnsavedChoice = "save" | "discard" | "cancel";
|
||||
|
||||
interface Pending {
|
||||
fileName: string;
|
||||
resolve: (choice: UnsavedChoice) => void;
|
||||
}
|
||||
|
||||
interface UnsavedChangesAPI {
|
||||
prompt: (fileName: string) => Promise<UnsavedChoice>;
|
||||
}
|
||||
|
||||
export const UnsavedChangesProvider: React.FC<{
|
||||
children: (api: UnsavedChangesAPI) => React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
const { t } = useI18n();
|
||||
const [pending, setPending] = useState<Pending | null>(null);
|
||||
const pendingRef = useRef<Pending | null>(null);
|
||||
pendingRef.current = pending;
|
||||
|
||||
const prompt = useCallback(
|
||||
(fileName: string) =>
|
||||
new Promise<UnsavedChoice>((resolve) => {
|
||||
// Re-entrance: if a prior prompt is still pending, cancel it so its caller
|
||||
// doesn't hang forever waiting for a resolve that now belongs to a new prompt.
|
||||
const prior = pendingRef.current;
|
||||
if (prior) prior.resolve("cancel");
|
||||
setPending({ fileName, resolve });
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Keep the singleton current during render so AppView close handlers and
|
||||
// hotkeys never race the post-commit useEffect registration window.
|
||||
promptSingleton = prompt;
|
||||
useEffect(() => () => {
|
||||
promptSingleton = null;
|
||||
}, []);
|
||||
|
||||
// On unmount, resolve any in-flight prompt as "cancel" so awaiting callers don't leak.
|
||||
useEffect(() => () => {
|
||||
const prior = pendingRef.current;
|
||||
if (prior) {
|
||||
prior.resolve("cancel");
|
||||
pendingRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resolveWith = useCallback((choice: UnsavedChoice) => {
|
||||
if (!pending) return;
|
||||
pending.resolve(choice);
|
||||
setPending(null);
|
||||
}, [pending]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children({ prompt })}
|
||||
<Dialog open={!!pending} onOpenChange={(o) => { if (!o) resolveWith("cancel"); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sftp.editor.unsavedTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sftp.editor.unsavedMessage", { fileName: pending?.fileName ?? "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" onClick={() => resolveWith("cancel")}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => resolveWith("discard")}>
|
||||
{t("sftp.editor.discardChanges")}
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => resolveWith("save")}>
|
||||
{t("sftp.editor.saveAndClose")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-level singleton — lets non-React code call the dialog without
|
||||
// prop-drilling. Registered/unregistered by UnsavedChangesProvider above.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let promptSingleton: ((fileName: string) => Promise<UnsavedChoice>) | null = null;
|
||||
|
||||
export const promptUnsavedChanges = (fileName: string): Promise<UnsavedChoice> => {
|
||||
if (!promptSingleton) return Promise.resolve("cancel");
|
||||
return promptSingleton(fileName);
|
||||
};
|
||||
Reference in New Issue
Block a user