mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02bd1359f7 |
No files matched your search
@@ -0,0 +1,150 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import ts from 'typescript';
|
||||
|
||||
import * as i18n from '../../application/i18n/I18nProvider';
|
||||
import * as editorTabs from '../../application/state/editorTabStore';
|
||||
import * as editorClient from '../../application/state/editorWindowClient';
|
||||
import * as editorRuntime from '../../application/state/useEditorWindow';
|
||||
import * as windowControls from '../../application/state/useWindowControls';
|
||||
import * as editorTabBar from './EditorWindowTabBar';
|
||||
import type { TextEditorPaneProps } from './TextEditorPane';
|
||||
|
||||
// Run the actual page, pane hosts, runtime and store. Only Monaco's visual
|
||||
// surface and the unrelated confirmation/toast chrome are replaced here.
|
||||
// Counting the memoized pane boundary catches content updates leaking through
|
||||
// the page root into every inactive editor, rather than testing a hand-written
|
||||
// approximation of the subscription graph.
|
||||
function loadPage(pane: React.ComponentType<TextEditorPaneProps>) {
|
||||
const source = readFileSync(new URL('./EditorWindowPage.tsx', import.meta.url), 'utf8');
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
esModuleInterop: true,
|
||||
} }).outputText;
|
||||
const require = createRequire(import.meta.url);
|
||||
const dependencies: Record<string, unknown> = {
|
||||
'../../application/i18n/I18nProvider': i18n,
|
||||
'../../application/state/editorTabStore': editorTabs,
|
||||
'../../application/state/editorWindowClient': editorClient,
|
||||
'../../application/state/useEditorWindow': editorRuntime,
|
||||
'../../application/state/useWindowControls': windowControls,
|
||||
'../ui/toast': { toast: { success() {}, error() {}, warning() {} } },
|
||||
'./UnsavedChangesDialog': {
|
||||
UnsavedChangesProvider: ({ children }: { children: () => React.ReactNode }) => children(),
|
||||
promptUnsavedChanges: async () => 'cancel',
|
||||
},
|
||||
'./EditorWindowTabBar': editorTabBar,
|
||||
'./TextEditorPane': { TextEditorPane: pane },
|
||||
};
|
||||
const module = { exports: {} as { default: React.ComponentType<{ settings: unknown }> } };
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
(id: string) => dependencies[id] ?? require(id), module, module.exports,
|
||||
);
|
||||
return module.exports.default;
|
||||
}
|
||||
|
||||
const makeTab = (index: number): editorTabs.EditorTab => ({
|
||||
id: `performance-editor-${index}`,
|
||||
kind: 'editor',
|
||||
sessionId: 'fixture-session',
|
||||
sftpTabId: 'fixture-sftp',
|
||||
hostId: 'fixture-host',
|
||||
remotePath: `/file-${index}.txt`,
|
||||
fileName: `file-${index}.txt`,
|
||||
languageId: 'text',
|
||||
content: 'original',
|
||||
baselineContent: 'original',
|
||||
wordWrap: false,
|
||||
viewState: null,
|
||||
savingState: 'idle',
|
||||
saveError: null,
|
||||
});
|
||||
|
||||
test('detached editor typing and tab switches leave unrelated editor panes untouched', async () => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
const panes = new Map<string, TextEditorPaneProps>();
|
||||
const renders = new Map<string, number>();
|
||||
const titles: string[] = [];
|
||||
const saves: unknown[] = [];
|
||||
const docked: unknown[] = [];
|
||||
let activate: ((payload: { editorId: string }) => void) | undefined;
|
||||
let finishDock: ((result: { success: boolean }) => void) | undefined;
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
setTimeout, clearTimeout, addEventListener() {}, removeEventListener() {},
|
||||
netcatty: {
|
||||
windowIsMaximized: async () => false,
|
||||
setWindowTitle: async (title: string) => { titles.push(title); },
|
||||
onEditorWindowActivateTab: (callback: typeof activate) => { activate = callback; return () => { activate = undefined; }; },
|
||||
saveEditorWindowTab: async (snapshot: unknown) => { saves.push(snapshot); return { ok: true }; },
|
||||
dockEditorWindowTab: (snapshot: unknown) => {
|
||||
docked.push(snapshot);
|
||||
return new Promise<{ success: boolean }>((resolve) => { finishDock = resolve; });
|
||||
},
|
||||
},
|
||||
} });
|
||||
const Pane = React.memo((props: TextEditorPaneProps) => {
|
||||
const name = props.fileName.replace(/ \*$/, '');
|
||||
renders.set(name, (renders.get(name) ?? 0) + 1);
|
||||
panes.set(name, props);
|
||||
return React.createElement('div', { 'data-editor': name });
|
||||
});
|
||||
const Page = loadPage(Pane);
|
||||
const tabs = Array.from({ length: 10 }, (_, index) => makeTab(index));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
for (const tab of tabs) editorTabs.editorTabStore._debugInsert(tab);
|
||||
renderer = create(React.createElement(i18n.I18nProvider, { locale: 'en-US', children: React.createElement(Page, { settings: { hotkeyScheme: 'mac', keyBindings: [] } }) }));
|
||||
});
|
||||
renders.clear();
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
await act(async () => panes.get('file-9.txt')!.onContentChange(`updated ${index}`, null));
|
||||
}
|
||||
assert.deepEqual(Object.fromEntries(renders), { 'file-9.txt': 20 });
|
||||
assert.equal(editorTabs.editorTabStore.getTab(tabs[9].id)?.content, 'updated 19');
|
||||
assert.equal(titles.at(-1), '* file-9.txt');
|
||||
|
||||
renders.clear();
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
await act(async () => activate?.({ editorId: tabs[index].id }));
|
||||
}
|
||||
assert.equal([...renders.values()].reduce((sum, count) => sum + count, 0), 0);
|
||||
assert.equal(editorTabs.editorTabStore.getTab(tabs[9].id)?.content, 'updated 19');
|
||||
assert.equal(titles.at(-1), '* file-9.txt');
|
||||
|
||||
await act(async () => { await panes.get('file-9.txt')!.onSave(); });
|
||||
assert.equal(saves.length, 1);
|
||||
assert.equal(editorTabs.editorTabStore.isDirty(tabs[9].id), false);
|
||||
assert.equal(titles.at(-1), 'file-9.txt');
|
||||
|
||||
// Dock completion must not steal focus from a tab selected while the
|
||||
// acknowledgement was pending; stable callbacks read the latest selection.
|
||||
await act(async () => { void panes.get('file-9.txt')!.onDockToMain?.(); });
|
||||
assert.equal(docked.length, 1);
|
||||
await act(async () => activate?.({ editorId: tabs[0].id }));
|
||||
await act(async () => finishDock?.({ success: true }));
|
||||
assert.equal(editorTabs.editorTabStore.getTab(tabs[9].id), undefined);
|
||||
assert.equal(titles.at(-1), 'file-0.txt');
|
||||
|
||||
await act(async () => panes.get('file-0.txt')!.onRequestClose?.());
|
||||
assert.equal(editorTabs.editorTabStore.getTab(tabs[0].id), undefined);
|
||||
assert.equal(titles.at(-1), 'file-8.txt');
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
for (const tab of tabs) editorTabs.editorTabStore.close(tab.id);
|
||||
});
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (previousAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousAct);
|
||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
}
|
||||
});
|
||||
@@ -84,7 +84,7 @@ function EditorWindowControls({ onClose }: { onClose: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function EditorWindowPaneHost({
|
||||
const EditorWindowPaneHost = React.memo(function EditorWindowPaneHost({
|
||||
tabId,
|
||||
isVisible,
|
||||
settings,
|
||||
@@ -130,6 +130,16 @@ function EditorWindowPaneHost({
|
||||
}
|
||||
}, [tabId, t]);
|
||||
|
||||
const handleLanguageChange = useCallback((language: string) => {
|
||||
editorTabStore.setLanguage(tabId, language);
|
||||
}, [tabId]);
|
||||
const handleToggleWordWrap = useCallback(() => {
|
||||
const current = editorTabStore.getTab(tabId);
|
||||
if (current) editorTabStore.setWordWrap(tabId, !current.wordWrap);
|
||||
}, [tabId]);
|
||||
const handleRequestClose = useCallback(() => onRequestClose(tabId), [onRequestClose, tabId]);
|
||||
const handleDock = useCallback(() => onDock(tabId), [onDock, tabId]);
|
||||
|
||||
if (!tab) return null;
|
||||
const isDirty = tabIsDirty(tab);
|
||||
const hostLabel = tab.hostLabel || tab.hostId;
|
||||
@@ -151,18 +161,35 @@ function EditorWindowPaneHost({
|
||||
hotkeyScheme={settings.hotkeyScheme}
|
||||
keyBindings={settings.keyBindings}
|
||||
onContentChange={handleContentChange}
|
||||
onLanguageChange={(lang) => editorTabStore.setLanguage(tabId, lang)}
|
||||
onToggleWordWrap={() => {
|
||||
const current = editorTabStore.getTab(tabId);
|
||||
if (current) editorTabStore.setWordWrap(tabId, !current.wordWrap);
|
||||
}}
|
||||
onSave={() => { void handleSave(); }}
|
||||
onRequestClose={() => onRequestClose(tabId)}
|
||||
onDockToMain={() => onDock(tabId)}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
onToggleWordWrap={handleToggleWordWrap}
|
||||
onSave={handleSave}
|
||||
onRequestClose={handleRequestClose}
|
||||
onDockToMain={handleDock}
|
||||
initialViewState={tab.viewState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** Keep content/dirty notifications out of the page and inactive editor panes. */
|
||||
function EditorWindowTitle({
|
||||
activeTabId,
|
||||
setWindowTitle,
|
||||
}: {
|
||||
activeTabId: EditorTabId | null;
|
||||
setWindowTitle: (title: string) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const activeTab = useEditorTab(activeTabId ?? "");
|
||||
const activeDirty = useEditorTabDirty(activeTabId ?? "");
|
||||
useEffect(() => {
|
||||
const name = activeTab?.fileName;
|
||||
void setWindowTitle(name
|
||||
? `${activeDirty ? "* " : ""}${name}`
|
||||
: t("sftp.editor.windowTitle"));
|
||||
}, [activeDirty, activeTab?.fileName, setWindowTitle, t]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
|
||||
@@ -171,23 +198,12 @@ function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
|
||||
const { onWindowCommandCloseRequested } = useWindowControls();
|
||||
const tabs = useEditorTabChromeList();
|
||||
const closeInFlightRef = useRef<Promise<boolean> | null>(null);
|
||||
const activeTab = useEditorTab(activeTabId ?? "");
|
||||
const activeDirty = useEditorTabDirty(activeTabId ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTabId && editorTabStore.getTab(activeTabId)) return;
|
||||
if (tabs.length > 0) setActiveTabId(tabs[tabs.length - 1].id);
|
||||
}, [activeTabId, setActiveTabId, tabs]);
|
||||
|
||||
useEffect(() => {
|
||||
const name = activeTab?.fileName;
|
||||
if (!name) {
|
||||
void setWindowTitle(t("sftp.editor.windowTitle"));
|
||||
return;
|
||||
}
|
||||
void setWindowTitle(`${activeDirty ? "* " : ""}${name}`);
|
||||
}, [activeDirty, activeTab?.fileName, setWindowTitle, t]);
|
||||
|
||||
const saveTab = useCallback(async (tabId: EditorTabId): Promise<boolean> => {
|
||||
const result = await saveDetachedEditorTab(tabId);
|
||||
if (result.ok) return true;
|
||||
@@ -221,16 +237,16 @@ function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
|
||||
await closeWindow();
|
||||
return true;
|
||||
}
|
||||
if (activeTabId === tabId) {
|
||||
setActiveTabId(remaining[remaining.length - 1]?.id ?? null);
|
||||
}
|
||||
setActiveTabId((current) => current === tabId
|
||||
? remaining[remaining.length - 1]?.id ?? null
|
||||
: current);
|
||||
return true;
|
||||
})().finally(() => {
|
||||
closeInFlightRef.current = null;
|
||||
});
|
||||
closeInFlightRef.current = task;
|
||||
return task;
|
||||
}, [activeTabId, closeTab, closeWindow, setActiveTabId]);
|
||||
}, [closeTab, closeWindow, setActiveTabId]);
|
||||
|
||||
const handleDock = useCallback(async (tabId: EditorTabId) => {
|
||||
const result = await dockDetachedEditorTab(tabId);
|
||||
@@ -244,10 +260,14 @@ function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
|
||||
await closeWindow();
|
||||
return;
|
||||
}
|
||||
if (activeTabId === tabId) {
|
||||
setActiveTabId(remaining[remaining.length - 1]?.id ?? null);
|
||||
}
|
||||
}, [activeTabId, closeWindow, setActiveTabId, t]);
|
||||
setActiveTabId((current) => current === tabId
|
||||
? remaining[remaining.length - 1]?.id ?? null
|
||||
: current);
|
||||
}, [closeWindow, setActiveTabId, t]);
|
||||
|
||||
const handleRequestClose = useCallback((tabId: EditorTabId) => {
|
||||
void closeTabAndMaybeWindow(tabId);
|
||||
}, [closeTabAndMaybeWindow]);
|
||||
|
||||
const handleWindowClose = useCallback(async () => {
|
||||
// Native close owns confirmation and blocks new transfers until it settles.
|
||||
@@ -296,6 +316,7 @@ function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||
<EditorWindowTitle activeTabId={activeTabId} setWindowTitle={setWindowTitle} />
|
||||
<div className="flex h-10 shrink-0 items-center border-b border-border/60 app-drag">
|
||||
{isMac ? <div className="w-[72px] shrink-0" /> : null}
|
||||
<div className="px-3 text-xs font-semibold text-muted-foreground app-no-drag">
|
||||
@@ -321,8 +342,8 @@ function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
|
||||
tabId={tab.id}
|
||||
isVisible={tab.id === activeTabId}
|
||||
settings={settings}
|
||||
onRequestClose={(id) => { void closeTabAndMaybeWindow(id); }}
|
||||
onDock={(id) => { void handleDock(id); }}
|
||||
onRequestClose={handleRequestClose}
|
||||
onDock={handleDock}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user