[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
145
components/ui/FixedSizeVirtualList.tsx
Normal file
145
components/ui/FixedSizeVirtualList.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { getFixedSizeVirtualWindow } from './virtualListMath';
|
||||
|
||||
const DEFAULT_OVERSCAN = 6;
|
||||
|
||||
export type FixedSizeVirtualListHandle = {
|
||||
scrollToIndex: (index: number, align?: 'auto' | 'center') => void;
|
||||
};
|
||||
|
||||
interface FixedSizeVirtualListProps<T> {
|
||||
items: T[];
|
||||
itemHeight: number;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
overscan?: number;
|
||||
getItemKey: (item: T, index: number) => string;
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
function FixedSizeVirtualListInner<T>(
|
||||
{
|
||||
items,
|
||||
itemHeight,
|
||||
className,
|
||||
contentClassName,
|
||||
overscan = DEFAULT_OVERSCAN,
|
||||
getItemKey,
|
||||
renderItem,
|
||||
}: FixedSizeVirtualListProps<T>,
|
||||
ref: React.ForwardedRef<FixedSizeVirtualListHandle>,
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const updateViewportHeight = () => {
|
||||
setViewportHeight(container.clientHeight);
|
||||
};
|
||||
|
||||
updateViewportHeight();
|
||||
const observer = new ResizeObserver(updateViewportHeight);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
startIndex,
|
||||
endIndex,
|
||||
effectiveScrollTop,
|
||||
totalHeight,
|
||||
} = getFixedSizeVirtualWindow({
|
||||
itemCount: items.length,
|
||||
itemHeight,
|
||||
scrollTop,
|
||||
viewportHeight,
|
||||
overscan,
|
||||
});
|
||||
|
||||
// Sync the DOM scroll position when content shrinks (filter / data change).
|
||||
// Layout already uses effectiveScrollTop so the list never blanks for a frame.
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (container.scrollTop !== effectiveScrollTop) {
|
||||
container.scrollTop = effectiveScrollTop;
|
||||
}
|
||||
if (scrollTop !== effectiveScrollTop) {
|
||||
setScrollTop(effectiveScrollTop);
|
||||
}
|
||||
}, [effectiveScrollTop, scrollTop]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToIndex: (index: number, align = 'auto') => {
|
||||
const container = containerRef.current;
|
||||
if (!container || index < 0 || index >= items.length) return;
|
||||
|
||||
const itemTop = index * itemHeight;
|
||||
const itemBottom = itemTop + itemHeight;
|
||||
const viewTop = container.scrollTop;
|
||||
const viewBottom = viewTop + container.clientHeight;
|
||||
|
||||
if (align === 'center') {
|
||||
container.scrollTop = Math.max(
|
||||
0,
|
||||
itemTop - (container.clientHeight - itemHeight) / 2,
|
||||
);
|
||||
} else if (itemTop < viewTop) {
|
||||
container.scrollTop = itemTop;
|
||||
} else if (itemBottom > viewBottom) {
|
||||
container.scrollTop = itemBottom - container.clientHeight;
|
||||
}
|
||||
setScrollTop(container.scrollTop);
|
||||
},
|
||||
}), [itemHeight, items.length]);
|
||||
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
setScrollTop(event.currentTarget.scrollTop);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('h-full overflow-y-auto overflow-x-hidden', className)}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div
|
||||
className={cn('relative w-full', contentClassName)}
|
||||
style={{ height: totalHeight || undefined, minHeight: items.length === 0 ? 0 : totalHeight }}
|
||||
>
|
||||
{items.slice(startIndex, endIndex).map((item, offset) => {
|
||||
const index = startIndex + offset;
|
||||
return (
|
||||
<div
|
||||
key={getItemKey(item, index)}
|
||||
className="absolute left-0 right-0"
|
||||
style={{
|
||||
top: index * itemHeight,
|
||||
height: itemHeight,
|
||||
}}
|
||||
>
|
||||
{renderItem(item, index)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const FixedSizeVirtualList = forwardRef(FixedSizeVirtualListInner) as <T>(
|
||||
props: FixedSizeVirtualListProps<T> & { ref?: React.ForwardedRef<FixedSizeVirtualListHandle> },
|
||||
) => React.ReactElement | null;
|
||||
Reference in New Issue
Block a user