Files
Netcatty/components/ui/virtualListMath.ts
T
陈大猫 aaff0edc7f perf(vault): virtualize host pickers for large inventories (#2553)
* perf(vault): virtualize host pickers for large inventories

Virtualize Select Host, Quick Switcher, SFTP host picker, and workspace
dialogs so thousands of hosts only mount a viewport window. Shared scroll
and keyboard clamp helpers keep selection and filter-while-scrolled paths
stable under virtualization.

* fix: address Codex review on PR #2553

* fix(vault): plugin row heights and Select Host keyboard types

Give Quick Switcher plugin command/view rows a taller virtual slot so
two-line chrome does not overflow into the next absolute row. Narrow
Select Host navigable rows to group|host so Enter keyboard activation
typechecks cleanly.

* fix(vault): finish host-picker review feedback

Clip and truncate fixed-height Quick Switcher / workspace rows so long
labels never overlap the next virtual slot. Wire Select Host listbox
aria-activedescendant to stable option ids for keyboard AT feedback.

* fix: address Codex review on PR #2553

* fix: address Codex review on PR #2553

* fix: address Codex review on PR #2553

* fix: address Codex review on PR #2553

* fix(vault): clear remaining host-picker codex findings

Declare multi-select listboxes, drop empty SFTP Hosts headers when only
connected rows remain, size the SFTP list to content under a 360px cap,
and give Quick Switcher / workspace pickers a definite max scroll height
so large inventories remain reachable.

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-07-28 11:48:01 +08:00

69 lines
2.3 KiB
TypeScript

/**
* Pure math for virtual lists and keyboard cursors.
* Kept free of React so tests drive the same functions the pickers use.
*/
export function clampScrollTop(
scrollTop: number,
totalHeight: number,
viewportHeight: number,
): number {
const maxScroll = Math.max(0, totalHeight - Math.max(viewportHeight, 0));
return Math.min(Math.max(0, scrollTop), maxScroll);
}
export function getFixedSizeVirtualWindow({
itemCount,
itemHeight,
scrollTop,
viewportHeight,
overscan,
}: {
itemCount: number;
itemHeight: number;
scrollTop: number;
viewportHeight: number;
overscan: number;
}): { startIndex: number; endIndex: number; effectiveScrollTop: number; totalHeight: number } {
const totalHeight = Math.max(0, itemCount * itemHeight);
const effectiveScrollTop = clampScrollTop(scrollTop, totalHeight, viewportHeight);
if (itemCount <= 0 || itemHeight <= 0) {
return { startIndex: 0, endIndex: 0, effectiveScrollTop, totalHeight };
}
const startIndex = Math.max(0, Math.floor(effectiveScrollTop / itemHeight) - overscan);
const visibleCount = Math.ceil(Math.max(viewportHeight, 1) / itemHeight) + overscan * 2;
const endIndex = Math.min(itemCount, startIndex + visibleCount);
return { startIndex, endIndex, effectiveScrollTop, totalHeight };
}
/** Clamp a keyboard cursor into [0, length-1], or 0 when the list is empty. */
export function clampListIndex(index: number, length: number): number {
if (length <= 0) return 0;
if (!Number.isFinite(index)) return 0;
return Math.max(0, Math.min(Math.trunc(index), length - 1));
}
/** Move a keyboard cursor by delta without ever landing on -1. */
export function stepListIndex(index: number, length: number, delta: number): number {
if (length <= 0) return 0;
return clampListIndex(index + delta, length);
}
/**
* Map selectable item indices to visual row indices for lists that interleave
* non-selectable chrome (headers, hints, empty rows).
*/
export function buildItemIndexToVisualIndexMap(
visualRows: ReadonlyArray<{ kind: string }>,
itemKind = 'item',
): Map<number, number> {
const map = new Map<number, number>();
let itemIndex = 0;
visualRows.forEach((row, visualIndex) => {
if (row.kind !== itemKind) return;
map.set(itemIndex, visualIndex);
itemIndex += 1;
});
return map;
}