mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3bad1c4f2 | ||
|
|
fa5e79c85c |
No files matched your search
@@ -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]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user