[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View 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);
});

View 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;

View 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,
});
});

View 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;

View 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;/,
);
});

View 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);
};