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
This commit is contained in:
陈大猫 authored and GitHub committed 2026-10-18 14:01:26 +08:00
1 parent 6a7c883337
commit f63be66ede
7 files changed
+470 -10

No files matched your search

@@ -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);
});
}