Compare commits

...
7 changed files with 470 additions and 10 deletions

No files matched your search

+5 -1
View File
@@ -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,
+5 -3
View File
@@ -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]);
@@ -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,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);
});
}