From f63be66ede8be2154d65c0ffcc1e712dd4810600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=A4=A7=E7=8C=AB?= <16399091+binaricat@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:01:26 +0800 Subject: [PATCH] fix(sftp): keep large file panes responsive when opening and hiding (#3724) * fix(sftp): bound file panes before measurement and while hidden * fix(sftp): clamp retained windows after background listing updates --- components/sftp/SftpPaneTreeView.tsx | 6 +- .../sftp/SftpPaneVirtualization.test.ts | 106 +++++++++++ .../sftp/hooks/useSftpPaneVirtualList.ts | 25 ++- components/sftp/useSftpPaneTreeRows.tsx | 8 +- .../sftp-browser-window-performance.md | 95 ++++++++++ .../sftp-browser-window-performance.tsx | 64 +++++++ ...p-browser-window-performance.live.test.cjs | 176 ++++++++++++++++++ 7 files changed, 470 insertions(+), 10 deletions(-) create mode 100644 components/sftp/SftpPaneVirtualization.test.ts create mode 100644 docs/research/sftp-browser-window-performance.md create mode 100644 scripts/fixtures/sftp-browser-window-performance.tsx create mode 100644 scripts/sftp-browser-window-performance.live.test.cjs diff --git a/components/sftp/SftpPaneTreeView.tsx b/components/sftp/SftpPaneTreeView.tsx index 195fdc3cd..40ca50d5f 100644 --- a/components/sftp/SftpPaneTreeView.tsx +++ b/components/sftp/SftpPaneTreeView.tsx @@ -121,7 +121,11 @@ export const SftpPaneTreeView = React.memo(({ 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); diff --git a/components/sftp/SftpPaneVirtualization.test.ts b/components/sftp/SftpPaneVirtualization.test.ts new file mode 100644 index 000000000..c01aef359 --- /dev/null +++ b/components/sftp/SftpPaneVirtualization.test.ts @@ -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(); + 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; + 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); }); + 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); }); + 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; + 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); +}); diff --git a/components/sftp/hooks/useSftpPaneVirtualList.ts b/components/sftp/hooks/useSftpPaneVirtualList.ts index 6cf499cb4..e467024bf 100644 --- a/components/sftp/hooks/useSftpPaneVirtualList.ts +++ b/components/sftp/hooks/useSftpPaneVirtualList.ts @@ -24,7 +24,8 @@ export const useSftpPaneVirtualList = ({ layoutKey, }: UseSftpPaneVirtualListParams): UseSftpPaneVirtualListResult => { const fileListRef = useRef(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(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, diff --git a/components/sftp/useSftpPaneTreeRows.tsx b/components/sftp/useSftpPaneTreeRows.tsx index 20406d14f..71c85eda4 100644 --- a/components/sftp/useSftpPaneTreeRows.tsx +++ b/components/sftp/useSftpPaneTreeRows.tsx @@ -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]); diff --git a/docs/research/sftp-browser-window-performance.md b/docs/research/sftp-browser-window-performance.md new file mode 100644 index 000000000..abda8e886 --- /dev/null +++ b/docs/research/sftp-browser-window-performance.md @@ -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. diff --git a/scripts/fixtures/sftp-browser-window-performance.tsx b/scripts/fixtures/sftp-browser-window-performance.tsx new file mode 100644 index 000000000..7ec444fd3 --- /dev/null +++ b/scripts/fixtures/sftp-browser-window-performance.tsx @@ -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; 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 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 { 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('comfortable'); + const [entries, setEntries] = useState(files); + const [selected, setSelected] = useState(new Set()); + const select = (name: string) => { result.selected = name; setSelected(new Set(name ? [name] : [])); }; + return
+
+ + +
+
+ {tree ? : } +
+
; +} +const root = createRoot(document.getElementById('root')!); +measure(phase, () => root.render()); +Object.assign(window, { sftpWindowAudit: result, sftpWindowRows: rows }); diff --git a/scripts/sftp-browser-window-performance.live.test.cjs b/scripts/sftp-browser-window-performance.live.test.cjs new file mode 100644 index 000000000..b173e0aaa --- /dev/null +++ b/scripts/sftp-browser-window-performance.live.test.cjs @@ -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= 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='); + 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('SFTP Window Performance Fixture
'); + }); }, + }] }); + 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); + }); +}