mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3bad1c4f2 | ||
|
|
fa5e79c85c | ||
|
|
b073162eaf | ||
|
|
f26bd5fa68 | ||
|
|
8184a1361d |
No files matched your search
@@ -195,3 +195,82 @@ test("normal manual and debounced auto-sync upload panel creation, edits and del
|
||||
Object.assign(manager, originals);
|
||||
}
|
||||
});
|
||||
|
||||
test("pending-sync indication coalesces edit bursts without repeatedly collecting the vault", async () => {
|
||||
const manager = getCloudSyncManager();
|
||||
const snapshot = {
|
||||
...manager.getState(),
|
||||
securityState: "UNLOCKED" as const,
|
||||
syncState: "IDLE" as const,
|
||||
autoSyncEnabled: true,
|
||||
providers: {
|
||||
...manager.getState().providers,
|
||||
webdav: { provider: "webdav" as const, status: "connected" as const },
|
||||
},
|
||||
};
|
||||
const originals = {
|
||||
getState: manager.getState,
|
||||
loadSyncBase: manager.loadSyncBase,
|
||||
inspectProviderRemote: manager.inspectProviderRemote,
|
||||
setPendingLocalSync: manager.setPendingLocalSync,
|
||||
setAvailablePluginSyncProviders: manager.setAvailablePluginSyncProviders,
|
||||
};
|
||||
manager.getState = () => snapshot;
|
||||
manager.loadSyncBase = async () => null;
|
||||
manager.inspectProviderRemote = async () => ({ remoteFile: null, payload: null, remoteChanged: false });
|
||||
const pending: boolean[] = [];
|
||||
manager.setPendingLocalSync = (value) => { pending.push(value); };
|
||||
manager.setAvailablePluginSyncProviders = () => {};
|
||||
let collects = 0;
|
||||
let collectBarrier: Promise<void> | undefined;
|
||||
Object.defineProperty(window, "netcatty", { configurable: true, value: {
|
||||
collectPluginSyncSidecars: async () => { collects++; await collectBarrier; return { version: 1, entries: [] }; },
|
||||
} });
|
||||
const apply = async () => {};
|
||||
const config = { hosts: [], keys: [], snippets: [], customGroups: ["fixture"], startupReady: true,
|
||||
onApplyPayload: apply, onApplyConvergentPayload: apply };
|
||||
function Probe({ enabled = true }: { enabled?: boolean }) { useAutoSync({ ...config, enabled }); return null; }
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const wait = async (ms: number) => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, ms)); }); };
|
||||
try {
|
||||
store.clear();
|
||||
writeMonitorPanels([{ id: "burst", name: "Before", description: "", hostIds: ["h"], view: "list", interval: 5, range: 15, sort: "manual" }]);
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await wait(500);
|
||||
const initial = collects;
|
||||
assert.ok(initial > 0, "startup still collects a real sync baseline");
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await act(async () => {
|
||||
writeMonitorPanels([{ id: "burst", name: `Edit ${i}`, description: "", hostIds: ["h"], view: "list", interval: 5, range: 15, sort: "manual" }]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
});
|
||||
}
|
||||
assert.equal(collects, initial, "editing must not repeatedly collect all sync data");
|
||||
await wait(350);
|
||||
assert.equal(collects, initial + 1, "the final edit updates the pending indicator once");
|
||||
assert.equal(pending.at(-1), true);
|
||||
writeMonitorPanels([]);
|
||||
await wait(10);
|
||||
await act(async () => { renderer!.update(React.createElement(Probe, { enabled: false })); });
|
||||
const disabled = collects;
|
||||
await wait(350);
|
||||
assert.equal(collects, disabled, "disabling sync cancels the pending collection");
|
||||
assert.equal(pending.at(-1), false);
|
||||
await act(async () => { renderer!.update(React.createElement(Probe)); });
|
||||
await wait(350);
|
||||
let releaseCollection!: () => void;
|
||||
collectBarrier = new Promise<void>((resolve) => { releaseCollection = resolve; });
|
||||
const beforeInflight = collects;
|
||||
writeMonitorPanels([{ id: "burst", name: "New edit", description: "", hostIds: ["h"], view: "list", interval: 5, range: 15, sort: "manual" }]);
|
||||
await wait(10);
|
||||
await wait(350);
|
||||
assert.equal(collects, beforeInflight + 1, "the pending collection has started");
|
||||
await act(async () => { renderer!.update(React.createElement(Probe, { enabled: false })); });
|
||||
await act(async () => { releaseCollection(); });
|
||||
assert.equal(pending.at(-1), false, "an old collection cannot re-enable the indicator after disabling sync");
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
Reflect.deleteProperty(window, "netcatty");
|
||||
Object.assign(manager, originals);
|
||||
}
|
||||
});
|
||||
@@ -603,7 +603,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
getDataHashRef.current = getDataHash;
|
||||
}, [getDataHash]);
|
||||
|
||||
const refreshPendingLocalSync = useCallback(async () => {
|
||||
const refreshPendingLocalSync = useCallback(async (isCurrent = () => true) => {
|
||||
if (
|
||||
!enabled
|
||||
|| convergentSyncPaused
|
||||
@@ -618,6 +618,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
try {
|
||||
const currentHash = await getDataHashRef.current();
|
||||
if (!isCurrent()) return;
|
||||
const hashDecision = resolveAutoSyncHashDecision({
|
||||
currentHash,
|
||||
lastSyncedHash: lastSyncedDataRef.current,
|
||||
@@ -1148,13 +1149,27 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
// Reflect unsynced local edits in the top-bar cloud indicator.
|
||||
useEffect(() => {
|
||||
void refreshPendingLocalSync();
|
||||
if (!enabled || convergentSyncPaused || !sync.hasAnyConnectedProvider || !sync.isUnlocked) {
|
||||
manager.setPendingLocalSync(false);
|
||||
return;
|
||||
}
|
||||
// Collecting the pending hash builds the same full payload as an upload.
|
||||
// Let rapid edits settle before doing that work, even with auto-sync off.
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
void refreshPendingLocalSync(() => !cancelled);
|
||||
}, 200);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [
|
||||
refreshPendingLocalSync,
|
||||
getDataHash,
|
||||
sync.hasAnyConnectedProvider,
|
||||
sync.isUnlocked,
|
||||
sync.isSyncing,
|
||||
convergentSyncPaused,
|
||||
config.settingsVersion,
|
||||
enabled,
|
||||
bookmarksVersion,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -121,7 +121,11 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
useLayoutEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const update = () => setViewportHeight(container.clientHeight);
|
||||
const update = () => {
|
||||
const height = container.clientHeight;
|
||||
setViewportHeight(height);
|
||||
if (height > 0) setScrollTop(container.scrollTop);
|
||||
};
|
||||
update();
|
||||
const raf = window.requestAnimationFrame(update);
|
||||
const resizeObserver = new ResizeObserver(update);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { useLayoutEffect } from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { useSftpPaneVirtualList } from './hooks/useSftpPaneVirtualList';
|
||||
import { useSftpPaneTreeRows } from './useSftpPaneTreeRows';
|
||||
import type { SftpFileEntry } from '../../types';
|
||||
import { installDomEnvironment } from '../test-support/renderReactDom';
|
||||
|
||||
const files: SftpFileEntry[] = Array.from({ length: 5000 }, (_, i) => ({
|
||||
name: `file-${i}.txt`, type: 'file', size: 1, lastModified: 0,
|
||||
sizeFormatted: '1 B', lastModifiedFormatted: '--',
|
||||
}));
|
||||
|
||||
test('file windows stay bounded on the first commit, hidden tabs and measured scrolling', async (t) => {
|
||||
const dom = installDomEnvironment();
|
||||
const previousObserver = globalThis.ResizeObserver;
|
||||
let resize = () => {};
|
||||
globalThis.ResizeObserver = class implements ResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) { resize = () => callback([], this); }
|
||||
observe() {} unobserve() {} disconnect() {}
|
||||
};
|
||||
let nextFrame = 0;
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
dom.window.requestAnimationFrame = callback => { frames.set(++nextFrame, callback); return nextFrame; };
|
||||
dom.window.cancelAnimationFrame = id => { frames.delete(id); };
|
||||
const flushFrames = async () => {
|
||||
const batch = [...frames.values()]; frames.clear();
|
||||
await act(async () => { batch.forEach(callback => callback(0)); });
|
||||
};
|
||||
const node = { clientHeight: 600, scrollTop: 0, querySelector: () => ({ getBoundingClientRect: () => ({ height: measuredHeight }) }) };
|
||||
let measuredHeight = 44;
|
||||
let result!: ReturnType<typeof useSftpPaneVirtualList>;
|
||||
const commits: number[] = [];
|
||||
function Fixture({ active, entries = files, density = 'comfortable' }: { active: boolean; entries?: SftpFileEntry[]; density?: string }) {
|
||||
result = useSftpPaneVirtualList({ isActive: active, sortedDisplayFiles: entries, layoutKey: density });
|
||||
useLayoutEffect(() => { commits.push(result.visibleRows.length); });
|
||||
return React.createElement('div', { ref: result.fileListRef });
|
||||
}
|
||||
let renderer!: ReactTestRenderer;
|
||||
t.after(async () => {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
globalThis.ResizeObserver = previousObserver;
|
||||
dom.cleanup();
|
||||
});
|
||||
await act(async () => { renderer = create(React.createElement(Fixture, { active: true }), { createNodeMock: () => node }); });
|
||||
assert.ok(commits.every(count => count < 100), `initial commits must be bounded: ${commits}`);
|
||||
await flushFrames();
|
||||
node.scrollTop = 4999 * result.rowHeight;
|
||||
await act(async () => { result.handleFileListScroll({ currentTarget: node } as unknown as React.UIEvent<HTMLDivElement>); });
|
||||
await flushFrames();
|
||||
assert.equal(result.visibleRows.at(-1)?.entry, files.at(-1), 'the final file remains reachable');
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { active: false })); });
|
||||
assert.ok(result.shouldVirtualize && result.visibleRows.length < 100, 'hiding must retain the file window');
|
||||
assert.equal(result.visibleRows.at(-1)?.entry, files.at(-1));
|
||||
node.clientHeight = 0;
|
||||
await act(async () => { resize(); });
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { active: true })); });
|
||||
assert.ok(result.visibleRows.length < 100, 'a zero-size viewport must not mount every file');
|
||||
assert.equal(result.visibleRows.at(-1)?.entry, files.at(-1), 'restoring retains the last-file window');
|
||||
const fewerFiles = files.slice(0, 100);
|
||||
node.clientHeight = 600;
|
||||
node.scrollTop = fewerFiles.length * result.rowHeight - node.clientHeight;
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { active: false, entries: fewerFiles })); });
|
||||
assert.ok(result.visibleRows.length > 0, 'an inactive listing shrink must not leave an empty window');
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { active: true, entries: fewerFiles })); });
|
||||
assert.equal(result.visibleRows.at(-1)?.entry, fewerFiles.at(-1));
|
||||
assert.ok(result.visibleRows[0].top <= node.scrollTop, 'restoring reads the browser-clamped scroll position');
|
||||
assert.ok(result.visibleRows.at(-1)!.top + result.rowHeight >= node.scrollTop + node.clientHeight);
|
||||
measuredHeight = 24;
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { active: true, density: 'compact' })); resize(); });
|
||||
await flushFrames();
|
||||
assert.equal(result.rowHeight, 24, 'density is still measured from real rows');
|
||||
node.scrollTop = 0;
|
||||
await act(async () => { result.handleFileListScroll({ currentTarget: node } as unknown as React.UIEvent<HTMLDivElement>); });
|
||||
await flushFrames();
|
||||
assert.equal(result.visibleRows[0]?.entry, files[0]);
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { active: true, entries: files.slice(0, 10) })); });
|
||||
assert.equal(result.shouldVirtualize, false);
|
||||
assert.equal(result.visibleRows.length, 10);
|
||||
});
|
||||
|
||||
test('tree windows stay bounded before measurement and when the viewport becomes hidden', async (t) => {
|
||||
const dom = installDomEnvironment();
|
||||
let result!: ReturnType<typeof useSftpPaneTreeRows>;
|
||||
const descriptors = files.map(entry => ({ type: 'node', entry, entryPath: `/fixture/${entry.name}`, depth: 0 }));
|
||||
function Fixture({ height, top = 0, count = files.length }: { height: number; top?: number; count?: number }) {
|
||||
result = useSftpPaneTreeRows({ nodeDescriptors: descriptors.slice(0, count), viewportHeight: height, scrollTop: top, selectedPaths: new Set(), tRef: { current: (key: string) => key } });
|
||||
return null;
|
||||
}
|
||||
let renderer!: ReactTestRenderer;
|
||||
t.after(async () => { await act(async () => { renderer?.unmount(); }); dom.cleanup(); });
|
||||
await act(async () => { renderer = create(React.createElement(Fixture, { height: 0 })); });
|
||||
assert.ok(result.visibleRange.virtualized && result.treeRows.length < 100, 'initial tree commit must be bounded');
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { height: 600, top: 4999 * 28 })); });
|
||||
assert.equal(result.visibleRange.end, 4999);
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { height: 0, top: 4999 * 28 })); });
|
||||
assert.ok(result.treeRows.length < 100, 'zero height must not mount every tree row');
|
||||
assert.equal(result.visibleRange.end, 4999);
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { height: 0, top: 4999 * 28, count: 100 })); });
|
||||
assert.ok(result.treeRows.length > 0, 'a hidden tree refresh must retain rows after the listing shrinks');
|
||||
assert.equal(result.visibleRange.end, 99);
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { height: 600, top: 4999 * 28, count: 100 })); });
|
||||
assert.ok(result.treeRows.length > 0);
|
||||
assert.equal(result.visibleRange.end, 99);
|
||||
});
|
||||
@@ -24,7 +24,8 @@ export const useSftpPaneVirtualList = ({
|
||||
layoutKey,
|
||||
}: UseSftpPaneVirtualListParams): UseSftpPaneVirtualListResult => {
|
||||
const fileListRef = useRef<HTMLDivElement>(null);
|
||||
const [rowHeight, setRowHeight] = useState(0);
|
||||
// Bound the first commit too; the mounted rows refine this estimate below.
|
||||
const [rowHeight, setRowHeight] = useState(44);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const scrollFrameRef = useRef<number | null>(null);
|
||||
@@ -32,7 +33,14 @@ export const useSftpPaneVirtualList = ({
|
||||
useLayoutEffect(() => {
|
||||
const container = fileListRef.current;
|
||||
if (!container || !isActive || !enabled) return;
|
||||
const update = () => setViewportHeight(container.clientHeight);
|
||||
const update = () => {
|
||||
const height = container.clientHeight;
|
||||
setViewportHeight(height);
|
||||
// Scroll events are ignored while parked; the browser may still clamp
|
||||
// its position when a refreshed directory becomes shorter. Hidden
|
||||
// elements report zero geometry, so preserve their last visible offset.
|
||||
if (height > 0) setScrollTop(container.scrollTop);
|
||||
};
|
||||
update();
|
||||
const raf = window.requestAnimationFrame(update);
|
||||
const resizeObserver = new ResizeObserver(update);
|
||||
@@ -82,18 +90,23 @@ export const useSftpPaneVirtualList = ({
|
||||
|
||||
const { shouldVirtualize, totalHeight, visibleRows } = useMemo(() => {
|
||||
const overscan = 6;
|
||||
const canVirtualize = enabled && isActive && viewportHeight > 0 && rowHeight > 0;
|
||||
// Hidden/unmeasured panes still need a window, rather than all file rows.
|
||||
const canVirtualize = enabled && rowHeight > 0;
|
||||
const shouldVirtualizeLocal = canVirtualize && sortedDisplayFiles.length > 50;
|
||||
const totalHeightLocal = shouldVirtualizeLocal
|
||||
? sortedDisplayFiles.length * rowHeight
|
||||
: 0;
|
||||
const windowTop = Math.max(0, Math.min(
|
||||
scrollTop,
|
||||
totalHeightLocal - viewportHeight,
|
||||
));
|
||||
const startIndex = shouldVirtualizeLocal
|
||||
? Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
|
||||
? Math.max(0, Math.floor(windowTop / rowHeight) - overscan)
|
||||
: 0;
|
||||
const endIndex = shouldVirtualizeLocal
|
||||
? Math.min(
|
||||
sortedDisplayFiles.length - 1,
|
||||
Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan,
|
||||
Math.ceil((windowTop + viewportHeight) / rowHeight) + overscan,
|
||||
)
|
||||
: sortedDisplayFiles.length - 1;
|
||||
const visibleRowsLocal = shouldVirtualizeLocal
|
||||
@@ -115,7 +128,7 @@ export const useSftpPaneVirtualList = ({
|
||||
totalHeight: totalHeightLocal,
|
||||
visibleRows: visibleRowsLocal,
|
||||
};
|
||||
}, [enabled, isActive, rowHeight, scrollTop, sortedDisplayFiles, viewportHeight]);
|
||||
}, [enabled, rowHeight, scrollTop, sortedDisplayFiles, viewportHeight]);
|
||||
|
||||
return {
|
||||
fileListRef,
|
||||
|
||||
@@ -15,15 +15,17 @@ export function useSftpPaneTreeRows(props: SftpPaneTreeRowsProps) {
|
||||
const { totalHeight, visibleRange } = useMemo(() => {
|
||||
const totalCount = nodeDescriptors.length;
|
||||
const total = totalCount * TREE_ROW_HEIGHT;
|
||||
const shouldVirtualize = viewportHeight > 0 && totalCount > 50;
|
||||
// A zero viewport also occurs on first mount and while parked/hidden.
|
||||
const shouldVirtualize = totalCount > 50;
|
||||
|
||||
if (!shouldVirtualize) {
|
||||
return { totalHeight: 0, visibleRange: { start: 0, end: totalCount - 1, virtualized: false } };
|
||||
}
|
||||
|
||||
const overscan = 6;
|
||||
const start = Math.max(0, Math.floor(scrollTop / TREE_ROW_HEIGHT) - overscan);
|
||||
const end = Math.min(totalCount - 1, Math.ceil((scrollTop + viewportHeight) / TREE_ROW_HEIGHT) + overscan);
|
||||
const windowTop = Math.max(0, Math.min(scrollTop, total - viewportHeight));
|
||||
const start = Math.max(0, Math.floor(windowTop / TREE_ROW_HEIGHT) - overscan);
|
||||
const end = Math.min(totalCount - 1, Math.ceil((windowTop + viewportHeight) / TREE_ROW_HEIGHT) + overscan);
|
||||
return { totalHeight: total, visibleRange: { start, end, virtualized: true } };
|
||||
}, [nodeDescriptors.length, scrollTop, viewportHeight]);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import { getCompletions } from "./autocomplete/completionEngine";
|
||||
import { DEFAULT_AUTOCOMPLETE_SETTINGS } from "./autocomplete/useTerminalAutocomplete";
|
||||
import { getPinyinCacheStatsForTests, resetPinyinCacheForTests } from "../../lib/searchMatcher";
|
||||
import type { Snippet } from "../../domain/models";
|
||||
|
||||
const deploySnippet: Snippet = { id: "d", label: "deploy", command: "kubectl apply -f ." };
|
||||
@@ -83,3 +84,34 @@ test("getCompletions returns more than 8 snippet matches when default maxSuggest
|
||||
);
|
||||
assert.equal(snippetMatches.length, 20);
|
||||
});
|
||||
|
||||
|
||||
test("argument completion skips ASCII transliteration in a large snippet library", async () => {
|
||||
const snippets: Snippet[] = Array.from({ length: 5000 }, (_, index) => ({
|
||||
id: `snippet-${index}`,
|
||||
label: `deploy-service-${index}`,
|
||||
command: `kubectl --namespace production rollout restart deployment service-${index}`,
|
||||
}));
|
||||
snippets.push({ id: "mail", label: "邮箱", command: "user@example.com" });
|
||||
resetPinyinCacheForTests();
|
||||
try {
|
||||
for (const input of ["git pull zz", "git pull zzq", "git pull zzqx"]) {
|
||||
assert.deepEqual(await getCompletions(input, { snippets, skipPathCompletion: true }), []);
|
||||
}
|
||||
assert.deepEqual(
|
||||
getPinyinCacheStatsForTests().keys,
|
||||
["邮箱"],
|
||||
"ASCII command tokens cannot produce a new pinyin prefix and must not enter its cache",
|
||||
);
|
||||
const result = await getCompletions("git pull yx", { snippets, skipPathCompletion: true });
|
||||
assert.equal(result.find((suggestion) => suggestion.source === "snippet")?.snippet?.id, "mail");
|
||||
} finally {
|
||||
resetPinyinCacheForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test("ASCII argument completion retains compact query prefixes", async () => {
|
||||
const snippets: Snippet[] = [{ id: "compact", label: "production-foobar", command: "echo ready" }];
|
||||
const result = await getCompletions("git pull foo_bar", { snippets, skipPathCompletion: true });
|
||||
assert.equal(result.find((suggestion) => suggestion.source === "snippet")?.snippet?.id, "compact");
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
# SFTP browser window performance
|
||||
|
||||
## Finding
|
||||
|
||||
The production file-list hook rendered every file until both row and viewport
|
||||
measurements arrived. It also disabled its window when a pane became inactive.
|
||||
The production tree rendered every descriptor whenever its viewport height was
|
||||
zero, including the first commit and a hidden tree's ResizeObserver update.
|
||||
Thus opening or leaving a large directory could mount thousands of file rows.
|
||||
Both conditions are already present in v1.1.83; this is an existing bottleneck
|
||||
found during the post-release performance audit, not a regression attributed to
|
||||
the newer compact-list UI.
|
||||
|
||||
An isolated Electron renderer using the actual list/sorting/files hooks, list
|
||||
component, tree component and application CSS reproduced:
|
||||
|
||||
| Files | Initial list mount | Hide list | Hidden list rows |
|
||||
| --- | --- | --- | --- |
|
||||
| 1,000 | 293 ms | 129 ms | 1,001 |
|
||||
| 5,000 | 1,309 ms | 696 ms | 5,001 |
|
||||
|
||||
The 5,000-file tree initially took 604 ms and mounted 5,001 rows again when
|
||||
hidden. The extra row is the normal parent-directory entry. These are controlled
|
||||
macOS measurements, not the original Windows environment from #3155.
|
||||
|
||||
## Repair and regression gate
|
||||
|
||||
Large lists retain their window while inactive or unmeasured. A conservative
|
||||
initial file-row height permits a bounded first commit; the existing real-row
|
||||
measurement still corrects it for either density. Tree rows already have a fixed
|
||||
height, so a zero viewport simply keeps the overscan window. Small directories
|
||||
and the complete file/selection data remain unchanged.
|
||||
|
||||
`components/sftp/SftpPaneVirtualization.test.ts` failed on both original hooks
|
||||
with 5,000 initial rows, then passed after the repair. It checks first commits,
|
||||
inactive/zero-height panes, the last file, density remeasurement, small lists and
|
||||
tree visibility. The separate native script exercises the actual rendered
|
||||
components and records timings without machine-dependent time assertions.
|
||||
|
||||
## Durable native acceptance
|
||||
|
||||
Run `node_modules/.bin/electron scripts/sftp-browser-window-performance.live.test.cjs`.
|
||||
For the red control, also set `NETCATTY_SFTP_WINDOW_BASELINE=1` and
|
||||
`NETCATTY_SFTP_WINDOW_BASELINE_REF=b073162eafd39ce2a337c9acbf36da2eb7456f63`.
|
||||
The control overrides only the two hooks, keeping the same inputs and current
|
||||
components/CSS. It completes the interaction checks, then fails with
|
||||
`SFTP_FILE_WINDOW_UNBOUNDED` (maximum 5,003 mounted rows). The repaired version
|
||||
passes, retaining at most 39 rows across every recorded commit. Initially hidden
|
||||
list/tree panes retain at most 22/32 rows; the small directory keeps all 22 rows.
|
||||
|
||||
In this Vite development-renderer fixture, initial list mount was 2,975 -> 61ms,
|
||||
first hide 1,950 -> 11ms, restore 148 -> 12ms, and initial tree 715 -> 18ms.
|
||||
These timings are distinct from the earlier bundled-renderer probe above.
|
||||
The deterministic gate is bounded mounted rows, rather than a hardware-dependent
|
||||
time threshold. Actual application CSS row heights are asserted at 44/24px.
|
||||
|
||||
The native script scrolls to the final file with Electron wheel events, selects
|
||||
and opens files, expands a tree directory and opens its child, toggles density,
|
||||
and hides/restores both views after selecting the final file. Selection and
|
||||
position persist (one CSS-pixel boundary tolerance). It uses its own sandboxed
|
||||
BrowserWindow/profile, dedicated port 5379 and private Vite cache, allows only
|
||||
its local fixture requests, and closes its listener/profile afterward. Results
|
||||
and window pixels are kept in Netcatty's dedicated temporary directory. This is
|
||||
programmatic native-window input; it does not use the user's SSH or unlock the
|
||||
macOS session, and is not a claim of manual CUA acceptance while macOS was locked.
|
||||
|
||||
## Review follow-up: shorter background listings
|
||||
|
||||
Review found that a retained scroll offset could outlive a shorter listing.
|
||||
The browser clamps its real scroll position, but inactive-list scroll events
|
||||
are ignored; the calculated start could exceed the final file and render an
|
||||
empty slice. The same range issue affects tree descriptors. Clamp both windows
|
||||
to the available content, and synchronize the measured scroll position when
|
||||
positive viewport geometry returns. Preserve the last visible offset while
|
||||
geometry is zero.
|
||||
|
||||
Both hook tests failed at the exact shrink scenario before this follow-up.
|
||||
The native control loading all three original product files from `fa5e79c85`
|
||||
also failed: list 5,000 -> 100 files and tree 100 -> 60 each recorded zero rows
|
||||
during the hidden update and first restore commit. On this macOS run, later
|
||||
browser scroll events automatically recovered; a permanent blank pane was not
|
||||
observed. The repaired run retains 21 list rows and 6 -> 31 tree rows across
|
||||
those commits, without another user scroll. It asserts nonempty bounded rows,
|
||||
the last file's visibility and each rendered row's position against the actual
|
||||
browser-clamped scroll offset. The previous complete native interactions also
|
||||
remain passing. To replay this narrower red control, use the same baseline
|
||||
environment variables with `NETCATTY_SFTP_WINDOW_BASELINE_REF=fa5e79c85`.
|
||||
|
||||
181 related SFTP checks, full lint (zero errors, eight existing warnings) and
|
||||
production build pass locally. Full-project typechecking still reports existing
|
||||
errors; there are no diagnostics in the changed product/test/fixture files.
|
||||
|
||||
The directory-window problem is independent of the expanded transfer-center
|
||||
problem fixed in #3722. Related to #3155 only; neither observation establishes
|
||||
the cause of the original Windows report, so this PR does not close it.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Pending sync indicator performance
|
||||
|
||||
The pending-local-sync indicator independently collected and serialized the full sync payload after every local edit. Upload debounce did not cover this path. New monitor panel and portable chat changes also trigger it. No competing open repair was found during the 2026-10-18 audit.
|
||||
|
||||
A production React hook in an isolated Electron renderer, with 2,000 synthetic hosts and mocked cloud network edges, received 50 monitor-name edits spaced 5 ms apart:
|
||||
|
||||
| Checkpoint | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Collections during the edit burst | 50 | 0 |
|
||||
| Collections for the settled indicator | 0 additional | 1 |
|
||||
| Uploads after the existing upload debounce | 1 | 1 |
|
||||
| Pending indicator after edits | true | true |
|
||||
|
||||
The indicator now waits 200 ms after the latest change before collecting. Disabling, locking, losing the provider, pausing, or unmounting cancels pending work; stale in-flight indicator results are ignored. Startup baseline collection and explicit/manual sync keep their existing timing. The change does not alter cloud payloads or conflict/merge decisions.
|
||||
|
||||
`application/state/useAutoSync.monitorPanels.test.ts` exercises the actual hook, adapter notifications, and sidecar collection boundary. It failed before the fix (13 collections vs 3 startup collections after ten edits), and passes after the fix. It also checks settled pending state, cancellation before collection, and cancellation while collection is in flight. Existing panel create/edit/delete upload and sync baseline tests continue to pass.
|
||||
|
||||
Validation: 47 related tests passed, full lint has zero errors (eight existing warnings), and production build passed. Full-platform CI must be checked at the final PR head. The fixture does not claim to reproduce the Windows long-running terminal report or to benchmark a real cloud provider.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Terminal argument completion performance
|
||||
|
||||
Issue: #3715. This follows #3623's argument-position matching for #3622; it does not resolve #3622's remaining interactive-email UX request.
|
||||
|
||||
`matchesWordPrefixQuery` tried pinyin for every word after a failed literal prefix comparison. A large snippet library therefore repeated transliteration/cache work for ordinary ASCII command words on each completion query. Printable ASCII words cannot gain another pinyin prefix, so they now return a miss immediately after the literal check. Chinese and other non-ASCII words keep the existing path.
|
||||
|
||||
## Verification
|
||||
|
||||
The regression exercises the actual `getCompletions` argument path with 5,000 snippets. Before the fix, it fails because the pinyin cache contains 5,011 entries, including ASCII commands and numbers. Afterward, only the Chinese label is cached and its initials still find the snippet. Additional cases cover empty queries, case, fullwidth normalization, word boundaries, Chinese full pinyin/initials, and mixed ASCII/Chinese words.
|
||||
|
||||
Run the real Electron renderer measurement:
|
||||
|
||||
```sh
|
||||
node_modules/.bin/electron scripts/terminal-snippet-completion-performance.live.test.cjs
|
||||
```
|
||||
|
||||
This uses isolated application data and synthetic snippets, calls the production completion engine, measures completion time and a queued input timer, and checks that ASCII words bypass pinyin while Chinese initials still work. It prints timings; the regression gate is the unnecessary-work assertion, avoiding a machine-specific timing threshold.
|
||||
|
||||
On the audit machine, an initial fixture with Chinese labels and ASCII commands measured:
|
||||
|
||||
| Library | Before | After |
|
||||
| --- | --- | --- |
|
||||
| 100 snippets | 0.4–2.4 ms | 0.1–1.3 ms |
|
||||
| 1,000 snippets | 4.2–7.0 ms | 1.7–2.3 ms |
|
||||
| 5,000 snippets | 52.9–86.0 ms | 8.8–9.9 ms |
|
||||
|
||||
The corresponding 5,000-entry queued input delay fell from 53.1–86.1 ms to 8.8–9.9 ms. The committed fixture uses ASCII labels plus one Chinese label; the final three rounds measured 6.5–11.0 ms. These are local completion/render-thread measurements, not network or terminal throughput measurements.
|
||||
|
||||
An isolated real Electron window also mounted production `TerminalAutocomplete` with real xterm and 5,001 snippets. Typing `git pull yx` displayed the Chinese mailbox snippet; clicking it selected the correct snippet. No connected SSH server, personal snippets, or user settings were used.
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getHostSearchMatch,
|
||||
matchesHostSearchQuery,
|
||||
matchesSearchQuery,
|
||||
matchesWordPrefixQuery,
|
||||
matchesWorkspaceHostPickerQuery,
|
||||
resetPinyinCacheForTests,
|
||||
} from "../lib/searchMatcher.ts";
|
||||
@@ -274,3 +275,19 @@ test("pinyin cache stays hard-bounded by retained characters, not just entries",
|
||||
assert.ok(getPinyinCacheStatsForTests().size > 0);
|
||||
resetPinyinCacheForTests();
|
||||
});
|
||||
|
||||
|
||||
test("word prefixes retain literal, normalized, and non-ASCII matching", () => {
|
||||
assert.equal(matchesWordPrefixQuery("", "deploy"), true);
|
||||
assert.equal(matchesWordPrefixQuery("DEP", "deploy-service"), true);
|
||||
assert.equal(matchesWordPrefixQuery("ser", "deploy-service"), true);
|
||||
assert.equal(matchesWordPrefixQuery("123", "item-1234"), true);
|
||||
assert.equal(matchesWordPrefixQuery("dep", "DEPLOY"), true);
|
||||
assert.equal(matchesWordPrefixQuery("caf", "café"), true);
|
||||
assert.equal(matchesWordPrefixQuery("mail", "gmail"), false);
|
||||
assert.equal(matchesWordPrefixQuery("zz", "deploy-service"), false);
|
||||
assert.equal(matchesWordPrefixQuery("--de", "deploy-service"), false);
|
||||
assert.equal(matchesWordPrefixQuery("bushu", "部署服务"), true);
|
||||
assert.equal(matchesWordPrefixQuery("bsfw", "部署服务"), true);
|
||||
assert.equal(matchesWordPrefixQuery("apibu", "API部署"), true);
|
||||
});
|
||||
@@ -334,6 +334,9 @@ export function matchesWordPrefixQuery(
|
||||
return words.some((word) => {
|
||||
if (word.startsWith(normalizedQuery)) return true;
|
||||
if (!compactQuery) return false;
|
||||
// ASCII command words cannot gain a different pinyin prefix. Avoid the
|
||||
// transliteration/cache work for every token in large snippet libraries.
|
||||
if (/^[\x20-\x7E]+$/.test(word)) return word.startsWith(compactQuery);
|
||||
const { full, initials } = getPinyinVariants(word);
|
||||
return (
|
||||
(full !== "" && full.startsWith(compactQuery))
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import React, { Profiler, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { SftpPaneFileList } from '../../components/sftp/SftpPaneFileList';
|
||||
import { SftpPaneTreeView } from '../../components/sftp/SftpPaneTreeView';
|
||||
import { useSftpPaneVirtualList } from '../../components/sftp/hooks/useSftpPaneVirtualList';
|
||||
import { useSftpPaneFiles } from '../../components/sftp/hooks/useSftpPaneFiles';
|
||||
import { useSftpPaneSorting } from '../../application/state/sftp/useSftpPaneSorting';
|
||||
import { I18nProvider } from '../../application/i18n/I18nProvider';
|
||||
import { TooltipProvider } from '../../components/ui/tooltip';
|
||||
import type { SftpFileEntry } from '../../types';
|
||||
import type { SftpPane } from '../../application/state/sftp/types';
|
||||
import type { SftpListDensity } from '../../domain/sftpListDensity';
|
||||
|
||||
const query = new URLSearchParams(location.search);
|
||||
const count = Number(query.get('count') || 5000);
|
||||
const initiallyHidden = query.has('hidden');
|
||||
const initiallyTree = query.has('tree');
|
||||
const file = (name: string): SftpFileEntry => ({ name, type: 'file', size: 1024, sizeFormatted: '1 KB', lastModified: 0, lastModifiedFormatted: '--' });
|
||||
const files = [{ ...file('folder'), type: 'directory' as const }, ...Array.from({ length: count }, (_, index) => file(`file-${String(index).padStart(6, '0')}.txt`))];
|
||||
const noop = () => {};
|
||||
const pane: SftpPane = { id: 'fixture', connection: { id: 'remote', hostId: 'fixture', hostLabel: 'Fixture', isLocal: false, status: 'connected', currentPath: '/fixture' }, files, loading: false, reconnecting: false, error: null, connectionLogs: [], selectedFiles: new Set(), filter: '', filenameEncoding: 'auto', showHiddenFiles: true, transferMutationToken: 0 };
|
||||
const result = { count, commits: [] as { phase: string; rows: number }[], timings: [] as { phase: string; ms: number }[], selected: '', opened: [] as string[], loads: [] as string[] };
|
||||
let phase = `${initiallyTree ? 'tree' : 'list'}:first`;
|
||||
const rows = () => document.querySelectorAll('[data-sftp-row], [data-section="terminal-sftp-tree-row"]').length;
|
||||
const record = () => result.commits.push({ phase, rows: rows() });
|
||||
const measure = (name: string, action: () => void) => { phase = name; const started = performance.now(); flushSync(action); result.timings.push({ phase, ms: performance.now() - started }); };
|
||||
function List({ active, density, selected, select, entries }: { entries: SftpFileEntry[]; active: boolean; density: SftpListDensity; selected: Set<string>; select: (name: string) => void }) {
|
||||
const sorting = useSftpPaneSorting();
|
||||
const { sortedDisplayFiles } = useSftpPaneFiles({ files: entries, filter: '', connection: pane.connection, showHiddenFiles: true, enableListView: true, ...sorting });
|
||||
const virtual = useSftpPaneVirtualList({ isActive: active, sortedDisplayFiles, layoutKey: density });
|
||||
return <SftpPaneFileList t={key => key} pane={{ ...pane, files: entries, selectedFiles: selected }} side="left" isPaneFocused={active} sorting={sorting} sortedDisplayFiles={sortedDisplayFiles} listDensity={density} {...virtual}
|
||||
isDragOverPane={false} draggedFiles={null} dragOverEntry={null} onRefresh={noop} onNavigateTo={noop} onClearSelection={() => select('')}
|
||||
setShowNewFolderDialog={noop} setShowNewFileDialog={noop} getNextUntitledName={() => ''} setNewFileName={noop} setFileNameError={noop}
|
||||
handleRowSelect={entry => select(entry.name)} handleRowOpen={entry => result.opened.push(entry.name)} handleFileDragStart={noop} onDragEnd={noop}
|
||||
handleEntryDragOver={noop} handleRowDragLeave={noop} handleEntryDrop={noop} onCopyToOtherPane={noop} onMoveEntriesToPath={async () => {}} openRenameDialog={noop} openDeleteConfirm={noop} />;
|
||||
}
|
||||
function Tree({ entries }: { entries: SftpFileEntry[] }) {
|
||||
const sorting = useSftpPaneSorting();
|
||||
return <SftpPaneTreeView pane={{ ...pane, files: entries }} side="left" sorting={sorting} draggedFiles={null} reloadRequest={{ token: 0 }}
|
||||
onPrepareSelection={noop} onLoadChildren={async path => { result.loads.push(path); return [file('nested-child.txt')]; }} onMoveEntriesToPath={async () => {}}
|
||||
onNavigateUp={noop} onNavigateTo={noop} onRefresh={noop} onOpenEntry={entry => result.opened.push(entry.name)} onDragStart={noop} onDragEnd={noop}
|
||||
openRenameDialog={noop} openDeleteConfirm={noop} onCopyToOtherPane={noop} onReceiveFromOtherPane={noop} openNewFolderDialog={noop} openNewFileDialog={noop} />;
|
||||
}
|
||||
function App() {
|
||||
const [active, setActive] = useState(!initiallyHidden);
|
||||
const [tree, setTree] = useState(initiallyTree);
|
||||
const [density, setDensity] = useState<SftpListDensity>('comfortable');
|
||||
const [entries, setEntries] = useState(files);
|
||||
const [selected, setSelected] = useState(new Set<string>());
|
||||
const select = (name: string) => { result.selected = name; setSelected(new Set(name ? [name] : [])); };
|
||||
return <I18nProvider locale="en"><TooltipProvider><div className="flex h-full w-full flex-col bg-background text-foreground">
|
||||
<div className="flex shrink-0 gap-4 p-2"><button id="visibility" onClick={() => measure(`${tree ? 'tree' : 'list'}:${active ? 'hide' : 'restore'}`, () => setActive(!active))}>Hide / restore</button>
|
||||
<button id="view" onClick={() => measure('tree:first', () => { setTree(!tree); setActive(true); })}>List / tree</button>
|
||||
<button id="density" onClick={() => measure('list:density', () => setDensity(density === 'comfortable' ? 'compact' : 'comfortable'))}>Density</button>
|
||||
<button id="shrink" onClick={() => measure(`${tree ? 'tree' : 'list'}:shrink-hidden`, () => setEntries(files.slice(0, tree ? 61 : 101)))}>Refresh smaller listing</button></div>
|
||||
<div id="pane" className="min-h-0 flex-1 flex-col" style={{ display: active ? 'flex' : 'none' }}><Profiler id="pane" onRender={record}>
|
||||
{tree ? <Tree entries={entries} /> : <List entries={entries} active={active} density={density} selected={selected} select={select} />}
|
||||
</Profiler></div>
|
||||
</div></TooltipProvider></I18nProvider>;
|
||||
}
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
measure(phase, () => root.render(<App />));
|
||||
Object.assign(window, { sftpWindowAudit: result, sftpWindowRows: rows });
|
||||
@@ -0,0 +1,176 @@
|
||||
"use strict";
|
||||
/* global process, __dirname, console, setTimeout, clearTimeout */
|
||||
// Run with Electron. NETCATTY_SFTP_WINDOW_BASELINE=1 loads the list/tree virtualization sources from Git
|
||||
// with NETCATTY_SFTP_WINDOW_BASELINE_REF=<unfixed commit> for the red control.
|
||||
// All fixture inputs stay identical; no implicit HEAD baseline changes after commit.
|
||||
if (!process.versions.electron) {
|
||||
require('node:test')('large SFTP browser windows stay bounded', { skip: 'run with Electron for native layout and input' }, () => {});
|
||||
} else {
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const temp = require('../electron/bridges/tempDirBridge.cjs');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const profile = fs.mkdtempSync(`${temp.getTempFilePath('sftp-window-performance')}-`);
|
||||
const artifacts = fs.mkdtempSync(`${temp.getTempFilePath('sftp-window-results')}-`);
|
||||
app.setPath('userData', profile);
|
||||
app.on('window-all-closed', () => {});
|
||||
let window, vite;
|
||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
const run = code => window.webContents.executeJavaScript(code).catch(error => { throw new Error(`${error.message}\nWhile executing: ${code}`); });
|
||||
async function until(code, label) {
|
||||
for (let i = 0; i < 200; i++) { if (await run(code)) return; await wait(25); }
|
||||
throw new Error(label);
|
||||
}
|
||||
async function click(selector, double = false) {
|
||||
app.focus({ steal: true }); window.focus(); window.webContents.focus();
|
||||
const point = await run(`(() => {const e=document.querySelector(${JSON.stringify(selector)});const r=e?.getBoundingClientRect();return r&&{x:Math.round(r.x+Math.min(150,r.width/2)),y:Math.round(r.y+r.height/2)}})()`);
|
||||
assert.ok(point, `click target ${selector}`);
|
||||
assert.ok(point.y > 0 && point.y < 800, 'row is inside the viewport');
|
||||
window.webContents.sendInputEvent({ type: 'mouseMove', ...point });
|
||||
await wait(20);
|
||||
for (const clickCount of double ? [1, 2] : [1]) {
|
||||
for (const type of ['mouseDown', 'mouseUp']) window.webContents.sendInputEvent({ type, button: 'left', clickCount, ...point });
|
||||
}
|
||||
await run('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))');
|
||||
}
|
||||
async function hideRestoreSelectedLast() {
|
||||
await click('#visibility'); await wait(100);
|
||||
await click('#visibility'); await wait(100);
|
||||
await until(`(() => {const e=document.querySelector('[data-entry-name="file-004999.txt"]');const r=e?.getBoundingClientRect();return e?.dataset.selected==='true'&&r.height>0&&r.top>=-1&&r.bottom<=innerHeight+1})()`, 'last selected row remains visible after hide/restore');
|
||||
}
|
||||
async function scrollToEnd(selector) {
|
||||
const point = await run(`(() => {const r=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)}})()`);
|
||||
window.webContents.sendInputEvent({ type: 'mouseMove', ...point });
|
||||
// Chromium caps each injected wheel to approximately one viewport. Repeat
|
||||
// native wheel inputs instead of treating a huge delta as a jump-to-end.
|
||||
for (let i = 0; i < 450; i++) {
|
||||
const done = await run(`(() => {const e=document.querySelector(${JSON.stringify(selector)});return e.scrollTop+e.clientHeight>=e.scrollHeight-1})()`);
|
||||
if (done) { await wait(50); return; }
|
||||
window.webContents.sendInputEvent({ type: 'mouseWheel', deltaX: 0, deltaY: -1000000, canScroll: true, ...point });
|
||||
await wait(5);
|
||||
}
|
||||
throw new Error('native wheel did not reach the end');
|
||||
}
|
||||
const cleanup = async code => { window?.destroy(); await vite?.close(); fs.rmSync(profile, { recursive: true, force: true }); app.exit(code); };
|
||||
const watchdog = setTimeout(() => { console.error('SFTP_WINDOW_TIMEOUT'); void cleanup(1); }, 120000);
|
||||
void app.whenReady().then(async () => {
|
||||
const { createServer } = await import('vite');
|
||||
const baseline = process.env.NETCATTY_SFTP_WINDOW_BASELINE === '1';
|
||||
const baselineRef = process.env.NETCATTY_SFTP_WINDOW_BASELINE_REF;
|
||||
if (baseline) assert.ok(baselineRef, 'baseline control requires NETCATTY_SFTP_WINDOW_BASELINE_REF=<unfixed commit>');
|
||||
const paths = ['components/sftp/hooks/useSftpPaneVirtualList.ts', 'components/sftp/useSftpPaneTreeRows.tsx', 'components/sftp/SftpPaneTreeView.tsx'];
|
||||
const originals = new Map(baseline ? paths.map(file => [path.join(root, file), execFileSync('git', ['show', `${baselineRef}:${file}`], { cwd: root, encoding: 'utf8' })]) : []);
|
||||
const tailwind = (await import('@tailwindcss/vite')).default;
|
||||
vite = await createServer({ configFile: false, root, cacheDir: path.join(profile, 'vite-cache'), resolve: { alias: { '@': root } }, optimizeDeps: { entries: ['scripts/fixtures/sftp-browser-window-performance.tsx'] }, logLevel: 'error', clearScreen: false, server: { host: '127.0.0.1', port: 5379, strictPort: true, hmr: false }, plugins: [tailwind(), { name: 'sftp-window-audit', enforce: 'pre',
|
||||
load(id) { return originals.get(id.split('?')[0]); },
|
||||
configureServer(server) { server.middlewares.use('/__sftp_window_fixture', async (_req, res) => {
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.end('<!doctype html><html><head><meta charset="utf-8"><title>SFTP Window Performance Fixture</title><link rel="stylesheet" href="/index.css"><style>html,body,#root{width:100%;height:100%;margin:0;overflow:hidden}</style></head><body><div id="root"></div><script type="module" src="/scripts/fixtures/sftp-browser-window-performance.tsx"></script></body></html>');
|
||||
}); },
|
||||
}] });
|
||||
await vite.listen();
|
||||
const origin = `http://127.0.0.1:${vite.httpServer.address().port}`;
|
||||
window = new BrowserWindow({ show: true, width: 1280, height: 800, title: 'SFTP Window Performance Fixture', webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false } });
|
||||
const errors = [];
|
||||
window.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) errors.push(event.message); });
|
||||
window.webContents.session.webRequest.onBeforeRequest((details, callback) => callback({ cancel: !details.url.startsWith(origin) && !details.url.startsWith('data:') }));
|
||||
await window.loadURL(`${origin}/__sftp_window_fixture`);
|
||||
await until('window.sftpWindowAudit && document.querySelector("[data-sftp-row]")', 'list mounts');
|
||||
await wait(120);
|
||||
assert.equal(await run('Math.round(document.querySelector("[data-sftp-row]").getBoundingClientRect().height)'), 44, 'real comfortable-row CSS loaded');
|
||||
await click('#visibility'); await wait(60);
|
||||
await click('#visibility'); await wait(60);
|
||||
await scrollToEnd('[data-section=terminal-sftp-list]');
|
||||
await until(`!!document.querySelector('[data-entry-name="file-004999.txt"]')`, 'list scroll reaches final file');
|
||||
await click('[data-entry-name="file-004999.txt"]');
|
||||
await until(`document.querySelector('[data-entry-name="file-004999.txt"]')?.dataset.selected==="true"`, 'last file selection');
|
||||
await click('[data-entry-name="file-004999.txt"]', true);
|
||||
await until('sftpWindowAudit.opened.includes("file-004999.txt")', 'last file double-click opens');
|
||||
await click('#density'); await wait(100);
|
||||
assert.equal(await run('document.querySelector("[data-sftp-row]").dataset.sftpListDensity'), 'compact');
|
||||
assert.equal(await run('Math.round(document.querySelector("[data-sftp-row]").getBoundingClientRect().height)'), 24, 'real compact-row CSS loaded');
|
||||
await scrollToEnd('[data-section=terminal-sftp-list]');
|
||||
await until(`!!document.querySelector('[data-entry-name="file-004999.txt"]')`, 'compact list reaches final file');
|
||||
await click('#density'); await wait(100);
|
||||
await hideRestoreSelectedLast();
|
||||
await click('#view'); await wait(100);
|
||||
await click('[data-section="terminal-sftp-tree-row"][data-entry-name="folder"]', true);
|
||||
await until(`sftpWindowAudit.loads.includes("/fixture/folder") && !!document.querySelector('[data-entry-name="nested-child.txt"]')`, 'directory expands using fixture child loader');
|
||||
await click('[data-entry-name="nested-child.txt"]', true);
|
||||
await until('sftpWindowAudit.opened.includes("nested-child.txt")', 'expanded tree child opens');
|
||||
await click('#visibility'); await wait(100);
|
||||
await click('#visibility'); await wait(100);
|
||||
await run(`document.querySelector('[data-section="terminal-sftp-tree-row"]').closest('.overflow-y-auto').id='tree-scroll-fixture'`);
|
||||
await scrollToEnd('#tree-scroll-fixture');
|
||||
await until(`!!document.querySelector('[data-entry-name="file-004999.txt"]')`, 'tree scroll reaches final file');
|
||||
await click('[data-entry-name="file-004999.txt"]');
|
||||
await until(`document.querySelector('[data-entry-name="file-004999.txt"]')?.dataset.selected==="true"`, 'tree last file selection');
|
||||
await hideRestoreSelectedLast();
|
||||
const result = await run('sftpWindowAudit');
|
||||
result.baseline = baseline;
|
||||
result.baselineRef = baseline ? baselineRef : null;
|
||||
result.maxRows = Math.max(...result.commits.map(commit => commit.rows));
|
||||
const screenshot = await window.webContents.capturePage();
|
||||
result.additional = [];
|
||||
for (const scenario of [{ query: '?count=20', small: true }, { query: '?hidden=1' }, { query: '?hidden=1&tree=1' }]) {
|
||||
await window.loadURL(`${origin}/__sftp_window_fixture${scenario.query}`);
|
||||
await until('!!window.sftpWindowAudit', 'additional scenario mounts');
|
||||
await wait(100);
|
||||
await click('#visibility'); await wait(80);
|
||||
await click('#visibility'); await wait(80);
|
||||
const sample = await run('sftpWindowAudit');
|
||||
result.additional.push({ query: scenario.query, ...sample });
|
||||
if (scenario.small) assert.ok(sample.commits.every(commit => commit.rows === 22), 'small directory retains all rows');
|
||||
else assert.ok(await run('document.querySelector("#pane").style.display==="none"'), 'initially hidden pane survives restore and rehide');
|
||||
}
|
||||
// Parked panes may receive a smaller refresh while their scroll handlers
|
||||
// cannot publish the browser-clamped position. Keep every commit nonempty.
|
||||
result.shrinkWhileHidden = [];
|
||||
for (const scenario of [
|
||||
{ view: 'list', query: '?shrink=1', before: 'file-004999.txt', after: 'file-000099.txt', height: 44 },
|
||||
{ view: 'tree', query: '?count=100&tree=1&shrink=1', before: 'file-000099.txt', after: 'file-000059.txt', height: 28 },
|
||||
]) {
|
||||
await window.loadURL(`${origin}/__sftp_window_fixture${scenario.query}`);
|
||||
await until('!!window.sftpWindowAudit', 'shrink scenario mounts');
|
||||
await wait(100);
|
||||
await scrollToEnd('#pane .overflow-y-auto');
|
||||
await until(`!!document.querySelector('[data-entry-name="${scenario.before}"]')`, 'shrink scenario starts at the final page');
|
||||
await click('#visibility'); await wait(100);
|
||||
await click('#shrink'); await wait(100);
|
||||
await click('#visibility'); await wait(100);
|
||||
const shrink = await run(`(() => {
|
||||
const container=document.querySelector('#pane .overflow-y-auto');
|
||||
const viewport=container.getBoundingClientRect();
|
||||
const rows=[...container.querySelectorAll('[data-sftp-row],[data-section="terminal-sftp-tree-row"]')].map(element=>({name:element.dataset.entryName,top:element.getBoundingClientRect().top,bottom:element.getBoundingClientRect().bottom,height:element.getBoundingClientRect().height}));
|
||||
return {scrollTop:container.scrollTop,scrollHeight:container.scrollHeight,clientHeight:container.clientHeight,viewport:viewport.toJSON(),rows,audit:sftpWindowAudit};
|
||||
})()`);
|
||||
result.shrinkWhileHidden.push({ view: scenario.view, ...shrink });
|
||||
console.log('SFTP_WINDOW_SHRINK_RESULT', JSON.stringify({ view: scenario.view, ...shrink }));
|
||||
fs.writeFileSync(path.join(artifacts, `shrink-${scenario.view}.json`), JSON.stringify(shrink, null, 2));
|
||||
fs.writeFileSync(path.join(artifacts, `shrink-${scenario.view}.png`), (await window.webContents.capturePage()).toPNG());
|
||||
assert.ok(shrink.rows.length > 0 && shrink.rows.length <= 100, 'restored smaller listing has a bounded nonempty row window without another scroll');
|
||||
const last = shrink.rows.find(row => row.name === scenario.after);
|
||||
assert.ok(last && last.top >= shrink.viewport.top - 1 && last.bottom <= shrink.viewport.bottom + 1, 'the browser-clamped final page shows its last file');
|
||||
const visible = shrink.rows.filter(row => row.bottom > shrink.viewport.top && row.top < shrink.viewport.bottom);
|
||||
assert.ok(visible.length > 0, 'restored rows intersect the actual viewport');
|
||||
for (const row of shrink.rows) {
|
||||
const index = row.name === '..' ? 0 : row.name === 'folder' ? 1 : Number(row.name.slice(5, 11)) + 2;
|
||||
assert.ok(Math.abs(row.top - (shrink.viewport.top + index * scenario.height - shrink.scrollTop)) <= 1, 'row positions match the real clamped scroll offset');
|
||||
}
|
||||
}
|
||||
assert.ok(result.shrinkWhileHidden.every(sample => sample.audit.commits.filter(commit => commit.phase.endsWith(':shrink-hidden') || commit.phase.endsWith(':restore')).every(commit => commit.rows > 0 && commit.rows <= 100)), 'hidden refresh and first restore commit keep bounded nonempty list/tree row windows');
|
||||
console.log('SFTP_WINDOW_PERFORMANCE_RESULT', JSON.stringify(result));
|
||||
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2));
|
||||
fs.writeFileSync(path.join(artifacts, 'window.png'), screenshot.toPNG());
|
||||
console.log('SFTP_WINDOW_ARTIFACTS', artifacts);
|
||||
assert.deepEqual(errors, [], 'no renderer errors');
|
||||
for (const phase of ['list:first', 'list:hide', 'list:restore', 'tree:first', 'tree:hide', 'tree:restore']) assert.ok(result.commits.some(commit => commit.phase === phase), `recorded ${phase}`);
|
||||
assert.ok([result, ...result.additional.filter(sample => !sample.query.includes('count=20'))].every(sample => sample.commits.every(commit => commit.rows <= 100)), `SFTP_FILE_WINDOW_UNBOUNDED: max ${result.maxRows} mounted rows`);
|
||||
console.log('SFTP_WINDOW_PERFORMANCE_OK');
|
||||
}).catch(async error => { process.exitCode = 1; console.error(error); if(window && !window.isDestroyed()) console.error('SFTP_WINDOW_FAILURE_STATE', await run(`(() => {const e=document.querySelector('[data-entry-name="file-004999.txt"]');const r=e?.getBoundingClientRect();const sc=document.querySelector('#tree-scroll-fixture');return {selected:e?.dataset.selected,rect:r?.toJSON(),innerHeight,scroll:sc&&{top:sc.scrollTop,height:sc.clientHeight,max:sc.scrollHeight},rows:document.querySelectorAll('[data-sftp-row],[data-section="terminal-sftp-tree-row"]').length}})()`)); if(window && !window.isDestroyed()) fs.writeFileSync(path.join(artifacts, 'failure.png'), (await window.webContents.capturePage()).toPNG()); console.error('SFTP_WINDOW_ARTIFACTS', artifacts); process.exitCode = 1; }).finally(async () => {
|
||||
clearTimeout(watchdog); await cleanup(process.exitCode || 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use strict";
|
||||
/* global process, __dirname, console */
|
||||
|
||||
if (!process.versions.electron) {
|
||||
require("node:test")("large snippet libraries keep argument completion responsive", {
|
||||
skip: "run with Electron to measure the actual renderer completion path",
|
||||
}, () => {});
|
||||
} else {
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const esbuild = require("esbuild");
|
||||
const temp = require("../electron/bridges/tempDirBridge.cjs");
|
||||
const userData = fs.mkdtempSync(`${temp.getTempFilePath("snippet-completion-perf")}-`);
|
||||
app.setPath("userData", userData);
|
||||
app.on("window-all-closed", () => {});
|
||||
let window;
|
||||
const cleanup = (code) => {
|
||||
window?.destroy();
|
||||
fs.rmSync(userData, { recursive: true, force: true });
|
||||
app.exit(code);
|
||||
};
|
||||
|
||||
void app.whenReady().then(async () => {
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const bundle = esbuild.buildSync({
|
||||
stdin: {
|
||||
contents: [
|
||||
'export { getCompletions } from "./components/terminal/autocomplete/completionEngine";',
|
||||
'export { resetPinyinCacheForTests, getPinyinCacheStatsForTests } from "./lib/searchMatcher";',
|
||||
].join("\n"),
|
||||
resolveDir: root,
|
||||
loader: "ts",
|
||||
},
|
||||
bundle: true,
|
||||
format: "cjs",
|
||||
platform: "browser",
|
||||
write: false,
|
||||
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
|
||||
}).outputFiles[0].text;
|
||||
window = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false, backgroundThrottling: false },
|
||||
});
|
||||
await window.loadURL("data:text/html,<body>");
|
||||
const result = await window.webContents.executeJavaScript(`(async () => {
|
||||
const loaded = { exports: {} };
|
||||
((module, exports) => { ${bundle} })(loaded, loaded.exports);
|
||||
const { getCompletions, resetPinyinCacheForTests, getPinyinCacheStatsForTests } = loaded.exports;
|
||||
const snippets = Array.from({ length: 5000 }, (_, index) => ({
|
||||
id: String(index), label: 'deploy-service-' + index,
|
||||
command: 'kubectl --namespace production rollout restart deployment service-' + index,
|
||||
}));
|
||||
snippets.push({ id: 'mail', label: '邮箱', command: 'user@example.com' });
|
||||
await getCompletions('git pull zz', { snippets: [] });
|
||||
resetPinyinCacheForTests();
|
||||
const rows = [];
|
||||
for (let round = 0; round < 3; round++) {
|
||||
for (const input of ['git pull zz', 'git pull zzq', 'git pull zzqx']) {
|
||||
const started = performance.now();
|
||||
let resolveInput;
|
||||
const inputTurn = new Promise(resolve => { resolveInput = resolve; });
|
||||
setTimeout(() => resolveInput(performance.now() - started), 0);
|
||||
const suggestions = await getCompletions(input, { snippets, skipPathCompletion: true });
|
||||
const completionMs = performance.now() - started;
|
||||
rows.push({ round, input, completionMs, inputDelayMs: await inputTurn, matches: suggestions.length });
|
||||
}
|
||||
}
|
||||
const pinyinKeys = getPinyinCacheStatsForTests().keys;
|
||||
const chinese = await getCompletions('git pull yx', { snippets, skipPathCompletion: true });
|
||||
return { rows, pinyinKeys, chineseSnippetId: chinese.find(item => item.source === 'snippet')?.snippet?.id };
|
||||
})()`);
|
||||
assert.ok(result.rows.every(row => row.matches === 0));
|
||||
assert.deepEqual(result.pinyinKeys, ["邮箱"], "ASCII command words must bypass transliteration");
|
||||
assert.equal(result.chineseSnippetId, "mail", "Chinese initials must still reach the snippet");
|
||||
// Report timings without a machine-dependent threshold. The cache-work
|
||||
// assertion above is the deterministic regression gate on this real path.
|
||||
console.log("TERMINAL_SNIPPET_COMPLETION_PERFORMANCE_OK", JSON.stringify(result));
|
||||
cleanup(0);
|
||||
}).catch(error => { console.error(error); cleanup(1); });
|
||||
}
|
||||
Reference in new issue
Block a user