perf: window the notes grid, workspace file tree and knowledge documents table

This commit is contained in:
mesalogo
2026-10-03 13:54:24 +08:00
parent f4458afe4b
commit b2eb86a0b4
14 changed files with 1400 additions and 397 deletions
+16
View File
@@ -0,0 +1,16 @@
/**
* Stand-ins for rows a windowed list did not mount (see use-list-window.ts).
* The height is computed geometry, so it is the one inline style these lists
* carry; everything else stays in classes.
*/
export function ListWindowTableSpacer({ height, columns, className }: {
height: number
columns: number
className?: string
}): React.JSX.Element {
return (
<tr aria-hidden="true" className={className}>
<td colSpan={columns} style={{ height: `${height}px` }} />
</tr>
)
}
+98 -55
View File
@@ -71,6 +71,7 @@ import type { MagicCanvasContentHandle } from './MagicCanvasContent'
import { MagicCanvasThumbnail } from './MagicCanvasThumbnail'
import { MarkdownRenderer } from './MarkdownRenderer'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { useGridListWindow } from './use-grid-columns'
import type { AppNotificationInput } from './notifications'
import {
EmptyState,
@@ -460,6 +461,10 @@ export function MagicNotesWorkspace({
const [libraryView, setLibraryView] = useState<LibraryView>('notes')
const [detailView, setDetailView] = useState<'notes'>()
const overviewFocusRef = useRef('')
// The note whose card takes focus back on returning to the overview; the
// windowed note list keeps that card mounted.
const [overviewReturnNoteId, setOverviewReturnNoteId] = useState('')
const noteGridRef = useRef<HTMLDivElement>(null)
const [todoFilter, setTodoFilter] = useState<TodoFilter>('active')
const [loadedCommentMode, setCommentMode] =
useState<MagicNoteCommentMode>('immediate')
@@ -1202,12 +1207,16 @@ export function MagicNotesWorkspace({
}
if (target.kind === 'create-note') {
overviewFocusRef.current = 'magic-note-new'
setOverviewReturnNoteId('')
void createNote(target.title, true)
.then(() => focusSwitchTarget(target))
return
}
setDeletingNote(false)
if (libraryView === 'notes') overviewFocusRef.current = `magic-note-select-${target.noteId}`
if (libraryView === 'notes') {
overviewFocusRef.current = `magic-note-select-${target.noteId}`
setOverviewReturnNoteId(target.noteId)
}
setDetailView('notes')
focusSwitchTarget(target)
void loadDetail(target.noteId, target.entryId)
@@ -1753,6 +1762,24 @@ export function MagicNotesWorkspace({
// Overview shows every note full width; the split list appears only beside an open note.
const notesExpanded = isNarrowWorkspace ? !detailView : notesPaneOpen || !detailView
const notesSplit = libraryView === 'notes' && Boolean(detailView) && !isNarrowWorkspace && notesPaneOpen
const visibleNoteIdsKey = visibleNotes.map((note) => note.id).join('\n')
const visibleNoteIds = useMemo(
() => (visibleNoteIdsKey ? visibleNoteIdsKey.split('\n') : []),
[visibleNoteIdsKey]
)
// P5: thousands of notes mount only the card rows near the viewport.
const noteWindow = useGridListWindow({
itemIds: visibleNoteIds,
gridRef: noteGridRef,
scope: `${search}\n${activeTagFilter.join('\n')}`,
// Selection, the open menu and the overview return target stay mounted.
keepIds: [selectedNoteId, noteActionsId, overviewReturnNoteId],
enabled: libraryView === 'notes' && loadStatus !== 'loading',
active: libraryView === 'notes' && notesExpanded,
layoutKey: detailView ? 'list' : 'cards',
estimatedRowHeight: detailView ? 56 : 176,
rowAttribute: 'data-magic-note-window-row'
})
const todoPaneWidthLimits = { minimum: 240, maximum: Math.max(240, todoLayoutWidth - 301) }
const displayedTodoWidth = clampMagicNotesPaneWidth(todoPaneWidth, todoPaneWidthLimits)
const indexExpanded = isNarrowLayout ? narrowIndexOpen : indexPaneOpen
@@ -2182,6 +2209,61 @@ export function MagicNotesWorkspace({
}
}
const renderNoteCard = (note: MagicNoteSummary, index: number): React.JSX.Element => (
<div className="magic-note-row" key={note.id} {...(noteWindow.windowed ? { role: 'listitem', 'aria-posinset': index + 1, 'aria-setsize': visibleNotes.length } : {})}>
<button
id={`magic-note-select-${note.id}`}
className="magic-note-list-item"
aria-current={selectedNoteId === note.id ? 'true' : undefined}
type="button"
onClick={() =>
selectedNoteId !== note.id && requestDraftSwitch({
kind: 'note',
noteId: note.id
})
}
>
<span className="magic-note-list-item__title">
{note.pinned && (
<Pin aria-label={t('status.pinned')} size={12} />
)}
<span className="magic-note-list-item__title-text">{note.title}</span>
<time dateTime={note.updatedAt} title={t('notes.updatedAt', { date: dateFormatter.format(new Date(note.updatedAt)) })}>
{relativeDate(note.updatedAt)}
</time>
</span>
<span className="magic-note-list-item__preview">
{note.preview || t('notes.noPreview')}
</span>
{!detailView && <MagicNoteTagChips tags={note.tags ?? []} />}
<span className={detailView ? 'sr-only' : 'magic-note-list-item__meta magic-note-list-item__meta--overview'}>
{t(
note.entryCount === 1
? 'notes.entryCountOne'
: 'notes.entryCountOther',
{ count: note.entryCount }
)}
</span>
</button>
<button
aria-controls={`magic-note-actions-${note.id}`}
aria-expanded={noteActionsId === note.id}
aria-haspopup="menu"
aria-label={t('actions.more', { title: note.title })}
title={t('actions.more', { title: note.title })}
className="magic-note-more icon-button"
type="button"
onClick={(event) => {
noteActionTriggerRef.current = event.currentTarget
setDeletingNote(false)
setNoteActionsId((current) => current === note.id ? '' : note.id)
}}
>
<MoreHorizontal aria-hidden="true" size={14} />
</button>
</div>
)
return (
<div className="magic-notes-page">
<PageHeader
@@ -2377,7 +2459,15 @@ export function MagicNotesWorkspace({
</div>
</form>
)}
<div className="magic-notes-list magic-notes-card-grid">
<div
ref={noteGridRef}
className={`magic-notes-list magic-notes-card-grid${noteWindow.windowed ? ' magic-notes-card-grid--windowed' : ''}`}
role={noteWindow.windowed ? 'list' : undefined}
aria-label={noteWindow.windowed ? t('notes.listLabel') : undefined}
onBlur={noteWindow.onBlur}
onFocus={noteWindow.onFocus}
onScroll={noteWindow.onScroll}
>
{loadStatus === 'loading' ? (
<p className="magic-notes-muted">
{t('status.loadingNotes')}
@@ -2400,60 +2490,13 @@ export function MagicNotesWorkspace({
)}
</>
) : (
visibleNotes.map((note) => (
<div className="magic-note-row" key={note.id}>
<button
id={`magic-note-select-${note.id}`}
className="magic-note-list-item"
aria-current={selectedNoteId === note.id ? 'true' : undefined}
type="button"
onClick={() =>
selectedNoteId !== note.id && requestDraftSwitch({
kind: 'note',
noteId: note.id
})
}
>
<span className="magic-note-list-item__title">
{note.pinned && (
<Pin aria-label={t('status.pinned')} size={12} />
)}
<span className="magic-note-list-item__title-text">{note.title}</span>
<time dateTime={note.updatedAt} title={t('notes.updatedAt', { date: dateFormatter.format(new Date(note.updatedAt)) })}>
{relativeDate(note.updatedAt)}
</time>
</span>
<span className="magic-note-list-item__preview">
{note.preview || t('notes.noPreview')}
</span>
{!detailView && <MagicNoteTagChips tags={note.tags ?? []} />}
<span className={detailView ? 'sr-only' : 'magic-note-list-item__meta magic-note-list-item__meta--overview'}>
{t(
note.entryCount === 1
? 'notes.entryCountOne'
: 'notes.entryCountOther',
{ count: note.entryCount }
)}
</span>
</button>
<button
aria-controls={`magic-note-actions-${note.id}`}
aria-expanded={noteActionsId === note.id}
aria-haspopup="menu"
aria-label={t('actions.more', { title: note.title })}
title={t('actions.more', { title: note.title })}
className="magic-note-more icon-button"
type="button"
onClick={(event) => {
noteActionTriggerRef.current = event.currentTarget
setDeletingNote(false)
setNoteActionsId((current) => current === note.id ? '' : note.id)
}}
>
<MoreHorizontal aria-hidden="true" size={14} />
</button>
noteWindow.windowed ? noteWindow.segments.map((segment) => segment.kind === 'spacer' ? (
<div aria-hidden="true" className="magic-notes-list__spacer" key={`spacer:${segment.key}`} style={{ height: `${segment.height}px` }} />
) : (
<div className="magic-notes-window-row" data-magic-note-window-row={segment.key} key={segment.key} ref={noteWindow.rowRef(segment.key)} role="none" style={{ paddingBottom: `${noteWindow.gap}px` }}>
{visibleNotes.slice(segment.start, segment.end).map((note, offset) => renderNoteCard(note, segment.start + offset))}
</div>
))
)) : visibleNotes.map((note, index) => renderNoteCard(note, index))
)}
{actionNote && createPortal(
<div
@@ -0,0 +1,141 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import i18n from './i18n'
import type { DesktopApi } from '../../shared/contracts'
import type { MagicNoteDetail, MagicNoteSummary } from '../../shared/magic-notes-contracts'
import { installFakeListLayout, type FakeListLayout } from './list-window-test-layout'
import { MagicNotesWorkspace } from './MagicNotesWorkspace'
import { defaultMaxRenderedRows } from './use-list-window'
vi.mock('./MagicNoteEditor', () => ({ MagicNoteEditor: () => <div data-testid="magic-note-editor" /> }))
vi.mock('./MagicCanvasThumbnail', () => ({ MagicCanvasThumbnail: () => null }))
vi.mock('./MagicNoteContent', () => ({ MagicNoteContent: () => <div /> }))
vi.mock('./MagicCanvasEditor', async () => {
const model = await import('./magic-canvas/model')
return { ...model, MagicCanvasEditor: () => null }
})
const count = 2_000
const rowHeight = 120
const idOf = (index: number): string => `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`
const titleOf = (index: number): string => `Note ${String(index).padStart(4, '0')}`
const notes: MagicNoteSummary[] = Array.from({ length: count }, (_, index) => ({
id: idOf(index), title: titleOf(index), preview: `preview ${index}`, entryCount: 1, pinned: false, tags: [],
revision: 1, createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:00:00.000Z'
}))
const detailOf = (id: string): MagicNoteDetail => ({ ...notes.find((note) => note.id === id)!, entries: [] })
let layout: FakeListLayout
const grid = (): HTMLElement => document.querySelector<HTMLElement>('.magic-notes-card-grid')!
const cards = (): string[] => [...grid().querySelectorAll<HTMLElement>('.magic-note-list-item')].map((card) => card.id.replace('magic-note-select-', ''))
const card = (index: number): HTMLElement | null => document.getElementById(`magic-note-select-${idOf(index)}`)
beforeEach(async () => {
await i18n.changeLanguage('zh-CN')
localStorage.clear()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() })
layout = installFakeListLayout({
isContainer: (element) => element.classList.contains('magic-notes-card-grid'),
rowAttribute: 'data-magic-note-window-row',
rowHeight: () => rowHeight
})
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: {
magicNotes: {
list: vi.fn(async () => ({ notes, tags: [] })),
listTodos: vi.fn(async () => ({ todos: [] })),
get: vi.fn(async (id: string) => detailOf(id)),
onChanged: () => vi.fn(),
onAnalysisEvent: () => vi.fn()
},
updates: { getSettings: vi.fn(async () => undefined) },
settings: { getRuntime: vi.fn() }
} as unknown as DesktopApi })
})
afterEach(() => {
cleanup()
layout.restore()
vi.restoreAllMocks()
vi.unstubAllGlobals()
Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
})
async function setup(): Promise<void> {
render(<MagicNotesWorkspace onNotify={vi.fn()} />)
await screen.findByText(titleOf(0))
layout.measure()
}
describe('MagicNotesWorkspace note list windowing', () => {
it('keeps the DOM bounded for 2,000 notes with list semantics and the full scroll height', async () => {
await setup()
expect(grid()).toHaveClass('magic-notes-card-grid--windowed')
expect(grid()).toHaveAttribute('role', 'list')
expect(cards().length).toBeGreaterThan(0)
expect(cards().length).toBeLessThanOrEqual(30)
expect(cards()[0]).toBe(idOf(0))
expect(layout.contentHeight(grid())).toBe(count * rowHeight)
const first = card(0)!.closest('.magic-note-row')!
expect(first).toHaveAttribute('role', 'listitem')
expect(first).toHaveAttribute('aria-posinset', '1')
expect(first).toHaveAttribute('aria-setsize', String(count))
})
it('mounts the notes at the scroll position', async () => {
await setup()
layout.scroll(grid(), 1_500 * rowHeight)
expect(cards().length).toBeLessThanOrEqual(defaultMaxRenderedRows)
expect(card(1_500)).toBeInTheDocument()
expect(card(1_500)!.getBoundingClientRect().top).toBe(0)
expect(card(1_500)!.closest('.magic-note-row')).toHaveAttribute('aria-posinset', '1501')
expect(card(0)).toBeNull()
})
it('keeps the selected and menu-open notes mounted at their real position and returns focus to the selected card', async () => {
await setup()
layout.scroll(grid(), 1_500 * rowHeight)
fireEvent.click(card(1_500)!)
await screen.findByDisplayValue(titleOf(1_500))
layout.measure()
// The split list beside the open note: the selected card stays mounted.
layout.scroll(grid(), 0)
expect(card(0)).toBeInTheDocument()
expect(card(1_500)).toHaveAttribute('aria-current', 'true')
const order = cards().map((id) => Number(id.slice(-12)))
expect(order).toEqual([...order].sort((left, right) => left - right))
// It sits where it belongs, so scrolling it into view lands on it.
const offset = card(1_500)!.getBoundingClientRect().top + grid().scrollTop
expect(offset).toBe(1_500 * rowHeight)
layout.scroll(grid(), offset)
expect(card(1_500)!.getBoundingClientRect().top).toBe(0)
expect(card(1_501)).toBeInTheDocument()
layout.scroll(grid(), 0)
fireEvent.click(screen.getByRole('button', { name: `更多笔记操作 ${titleOf(3)}` }))
expect(screen.getByRole('menu')).toBeInTheDocument()
layout.scroll(grid(), 900 * rowHeight)
expect(card(3)).toBeInTheDocument()
expect(screen.getByRole('menu')).toBeInTheDocument()
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
fireEvent.click(screen.getByRole('button', { name: '返回总览' }))
await waitFor(() => expect(card(1_500)).toHaveFocus())
})
it('re-windows the search results at the clamped scroll position, as the browser leaves it', async () => {
await setup()
layout.scroll(grid(), 1_000 * rowHeight)
fireEvent.change(screen.getByPlaceholderText(/搜索/), { target: { value: 'Note 19' } })
await act(async () => { await Promise.resolve() })
layout.measure()
// 100 matches (Note 1900-1999) stay windowed; the list does not jump to
// the top (it never did), so the end of the shorter result is in view.
const shown = cards().map((id) => Number(id.slice(-12)))
expect(shown.every((index) => index >= 1_900)).toBe(true)
expect(card(1_999)).toBeInTheDocument()
expect(card(1_999)!.closest('.magic-note-row')).toHaveAttribute('aria-setsize', '100')
expect(card(1_999)!.closest('.magic-note-row')).toHaveAttribute('aria-posinset', '100')
layout.scroll(grid(), 0)
expect(cards()[0]).toBe(idOf(1_900))
})
})
+115 -48
View File
@@ -30,6 +30,7 @@ import { createPortal } from 'react-dom'
import { SegmentedControl } from './WorkspacePrimitives'
import { WorkspaceActionDialog } from './WorkspaceActionDialog'
import { WorkspaceGitTools } from './WorkspaceGitTools'
import { useListWindow } from './use-list-window'
import type {
WorkspaceChangedFile,
WorkspaceChanges,
@@ -72,6 +73,42 @@ function changedTree(entries: { path: string; row: React.JSX.Element }[], prefix
return <>{[...groups].map(([name, children]) => <details key={name} open><summary>{name}</summary><div className="workspace-files__children">{changedTree(children, `${prefix}${name}/`)}</div></details>)}{files.map((entry) => entry.row)}</>
}
export type FileTreeRow =
| { kind: 'entry'; key: string; depth: number; entry: WorkspaceDirectoryEntry }
| { kind: 'status'; key: string; depth: number; path: string; status: 'loading' | 'error' | 'truncated' }
export function fileRowKey(path: string): string {
return `entry:${path}`
}
/**
* The visible tree in display order: each expanded directory is followed by
* its children, then its reading / error / truncated notes, as before.
*/
export function flattenFileTree(
entries: readonly WorkspaceDirectoryEntry[],
listings: Readonly<Record<string, WorkspaceDirectoryListing>>,
expandedPaths: ReadonlySet<string>,
loadingPaths: ReadonlySet<string>,
errors: Readonly<Record<string, string | undefined>>
): FileTreeRow[] {
const rows: FileTreeRow[] = []
const visit = (items: readonly WorkspaceDirectoryEntry[], depth: number): void => {
for (const entry of items) {
rows.push({ kind: 'entry', key: fileRowKey(entry.path), depth, entry })
if (entry.type !== 'directory' || !expandedPaths.has(entry.path)) continue
const listing = listings[entry.path]
const childDepth = depth + 1
if (listing) visit(listing.entries, childDepth)
if (loadingPaths.has(entry.path)) rows.push({ kind: 'status', key: `loading:${entry.path}`, depth: childDepth, path: entry.path, status: 'loading' })
if (errors[entry.path]) rows.push({ kind: 'status', key: `error:${entry.path}`, depth: childDepth, path: entry.path, status: 'error' })
if (listing?.truncated) rows.push({ kind: 'status', key: `truncated:${entry.path}`, depth: childDepth, path: entry.path, status: 'truncated' })
}
}
visit(entries, 0)
return rows
}
export function WorkspaceFilesPanel({
projectId,
rootPath,
@@ -150,6 +187,7 @@ export function WorkspaceFilesPanel({
const activeView = showGit ? view : 'files'
const [selected, setSelected] = useState<{ projectId?: string; path: string }>()
const panelRef = useRef<HTMLDivElement>(null)
const treeRef = useRef<HTMLDivElement>(null)
const refreshRef = useRef<HTMLButtonElement>(null)
const backRef = useRef<HTMLButtonElement>(null)
const returnContext = useRef<{ scrollTop: number; scrollLeft: number; trigger: HTMLButtonElement } | undefined>(undefined)
@@ -287,8 +325,9 @@ export function WorkspaceFilesPanel({
[changedFiles]
)
const emptyPaths = useMemo(() => new Set<string>(), [])
const listings =
listingState.projectId === projectId ? listingState.value : {}
const emptyRecord = useMemo(() => ({}), [])
const listings: Record<string, WorkspaceDirectoryListing> =
listingState.projectId === projectId ? listingState.value : emptyRecord
const expandedPaths =
expandedState.projectId === projectId
? expandedState.value
@@ -297,7 +336,7 @@ export function WorkspaceFilesPanel({
loadingState.projectId === projectId
? loadingState.value
: emptyPaths
const errors = errorState.projectId === projectId ? errorState.value : {}
const errors: Record<string, string | undefined> = errorState.projectId === projectId ? errorState.value : emptyRecord
const renderDirectoryError = (path: string): React.JSX.Element | null => errors[path] ? (
<p className="workspace-files__error" role="alert">
{errors[path]}
@@ -368,55 +407,50 @@ export function WorkspaceFilesPanel({
}}><MoreHorizontal size={14} aria-hidden="true" /></button>
)
const renderEntry = (
entry: WorkspaceDirectoryEntry
const renderTreeRow = (
row: FileTreeRow,
rowProps: Record<string, unknown>
): React.JSX.Element => {
const expanded = expandedPaths.has(entry.path)
const listing = listings[entry.path]
const changed = changedByPath.get(entry.path)
if (entry.type === 'directory') {
// Nesting is drawn by indentation; the rows themselves are flat, so they
// keep their identity whether or not the tree is windowed.
const indent = row.depth ? { marginLeft: `calc(${row.depth} * var(--space-4))` } : undefined
if (row.kind === 'status') {
return (
<div key={entry.path}>
<div className="workspace-files__entry">
<button
aria-expanded={expanded}
className="workspace-files__row"
onClick={() => toggleDirectory(entry.path)}
type="button"
>
{expanded ? (
<ChevronDown size={13} />
) : (
<ChevronRight size={13} />
)}
{expanded ? <FolderOpen size={15} /> : <Folder size={15} />}
<span title={entry.path}>{entry.name}</span>
</button>
{entryMenu(entry)}
</div>
{expanded && (
<div className="workspace-files__children">
{listing?.entries.map((child) =>
renderEntry(child)
)}
{loadingPaths.has(entry.path) && (
<p className="workspace-files__status">
{t('files.reading')}
</p>
)}
{renderDirectoryError(entry.path)}
{listing?.truncated && (
<p className="workspace-files__status">
{t('files.directoryTruncated')}
</p>
)}
</div>
<div key={row.key} style={indent} {...rowProps}>
{row.status === 'error' ? renderDirectoryError(row.path) : (
<p className="workspace-files__status">
{t(row.status === 'loading' ? 'files.reading' : 'files.directoryTruncated')}
</p>
)}
</div>
)
}
const { entry } = row
const expanded = expandedPaths.has(entry.path)
const changed = changedByPath.get(entry.path)
if (entry.type === 'directory') {
return (
<div className="workspace-files__entry" key={row.key} style={indent} {...rowProps}>
<button
aria-expanded={expanded}
className="workspace-files__row"
onClick={() => toggleDirectory(entry.path)}
type="button"
>
{expanded ? (
<ChevronDown size={13} />
) : (
<ChevronRight size={13} />
)}
{expanded ? <FolderOpen size={15} /> : <Folder size={15} />}
<span title={entry.path}>{entry.name}</span>
</button>
{entryMenu(entry)}
</div>
)
}
return (
<div className="workspace-files__entry" key={entry.path}>
<div className="workspace-files__entry" key={row.key} style={indent} {...rowProps}>
<button
className="workspace-files__row"
aria-current={selected?.projectId === projectId && selected?.path === entry.path ? 'true' : undefined}
@@ -442,6 +476,32 @@ export function WorkspaceFilesPanel({
)
}
const root = listings[browsedPath]
const treeRows = useMemo(
() => flattenFileTree(root?.entries ?? [], listings, expandedPaths, loadingPaths, errors),
[errors, expandedPaths, listings, loadingPaths, root]
)
const treeRowKeysKey = treeRows.map((row) => row.key).join('\n')
const treeRowKeys = useMemo(() => (treeRowKeysKey ? treeRowKeysKey.split('\n') : []), [treeRowKeysKey])
const selectedPath = selected && selected.projectId === projectId ? selected.path : undefined
// P5: a large workspace (500 entries per directory, many expanded) mounts
// only the rows near the viewport. The docked files view is the scroller;
// the sidebar body only scrolls where that view is not bounded.
const treeWindow = useListWindow({
ids: treeRowKeys,
scope: `${projectId ?? ''}\n${browsedPath}`,
listRef: treeRef,
scrollParent: (tree) =>
tree.closest<HTMLElement>('.workspace-files__files-view') ?? tree.closest<HTMLElement>('.assistant-sidebar__body'),
// The open file, the menu's and the dialog's entry keep their rows (focus returns there).
keepIds: [selectedPath, menu?.entry.path, dialog?.path].map((path) => (path === undefined ? undefined : fileRowKey(path))),
// Stays on while the Git view or a diff hides the tree, so hiding it never mounts every row.
enabled: true,
active: activeView === 'files' && !showingDiff,
estimatedRowHeight: 32,
rowAttribute: 'data-workspace-file-row'
})
if (!projectId) {
return (
<p className="assistant-sidebar__empty">
@@ -450,7 +510,6 @@ export function WorkspaceFilesPanel({
)
}
const root = listings[browsedPath]
const rootName = rootPath?.replaceAll('\\', '/').split('/').filter(Boolean).at(-1) || t('files.currentWorkspace')
const renderPatch = (patch: string): React.JSX.Element => (
<pre className="assistant-sidebar__diff workspace-files__diff">
@@ -562,8 +621,16 @@ export function WorkspaceFilesPanel({
</p>
) : errors[browsedPath] && !root ? null : root?.entries.length ? (
<>
<div className="workspace-files__tree">
{root.entries.map((entry) => renderEntry(entry))}
<div ref={treeRef} className="workspace-files__tree" onBlur={treeWindow.onBlur} onFocus={treeWindow.onFocus}>
{treeWindow.segments.map((segment) => {
if (segment.kind === 'spacer') {
return <div aria-hidden="true" className="workspace-files__spacer" key={`spacer:${segment.key}`} style={{ height: `${segment.height}px` }} />
}
const row = treeRows[segment.index]!
return renderTreeRow(row, treeWindow.windowed
? { 'data-workspace-file-row': row.key, ref: treeWindow.rowRef(row.key) }
: {})
})}
</div>
{root.truncated && (
<p className="workspace-files__status">
@@ -0,0 +1,126 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { changeUiLocale } from './i18n'
import { installFakeListLayout, type FakeListLayout } from './list-window-test-layout'
import { defaultMaxRenderedRows } from './use-list-window'
import { flattenFileTree, WorkspaceFilesPanel } from './WorkspaceFilesPanel'
import type { WorkspaceDirectoryListing } from '../../shared/assistant-contracts'
// 4 directories x 500 files (the per-directory listing cap) = 2,004 rows when all are expanded.
const directories = ['a', 'b', 'c', 'd']
const filesPer = 500
const fileName = (directory: string, index: number): string => `${directory}-${String(index).padStart(3, '0')}.txt`
const listing = (path: string): WorkspaceDirectoryListing => ({
path, truncated: false,
entries: path
? Array.from({ length: filesPer }, (_, index) => ({ name: fileName(path, index), path: `${path}/${fileName(path, index)}`, type: 'file' as const }))
: directories.map((name) => ({ name, path: name, type: 'directory' as const }))
})
const rowHeight = 32
let layout: FakeListLayout
// The docked files view is the tree's scroller (see workspace-files-layout.electron.test.ts).
const body = (): HTMLElement => document.querySelector<HTMLElement>('.workspace-files__files-view')!
const tree = (): HTMLElement => document.querySelector<HTMLElement>('.workspace-files__tree')!
const rows = (): string[] => [...tree().querySelectorAll<HTMLElement>('.workspace-files__row')].map((row) => row.textContent ?? '')
const fileRow = (directory: string, index: number): HTMLElement | null =>
tree().querySelector<HTMLElement>(`.workspace-files__row[title="${directory}/${fileName(directory, index)}"]`)
beforeEach(() => {
vi.stubGlobal('goodbuddy', { workspace: { manage: vi.fn(async () => ({ kind: 'branches', current: 'main', branches: [] })) } })
layout = installFakeListLayout({
isContainer: (element) => element.classList.contains('workspace-files__files-view'),
rowAttribute: 'data-workspace-file-row',
rowHeight: () => rowHeight
})
})
afterEach(async () => {
cleanup()
layout.restore()
vi.restoreAllMocks()
vi.unstubAllGlobals()
await changeUiLocale('zh-CN')
})
async function setup(onOpenFile = vi.fn()) {
const onListDirectory = vi.fn(async (path: string) => listing(path))
const props = { projectId: 'project', changedFiles: [], onListDirectory, onLoadDiff: vi.fn(), onOpenFile }
const view = render(<div className="assistant-sidebar__body"><section><WorkspaceFilesPanel {...props} /></section></div>)
// Bottom-up: expanding a directory pushes the later ones out of the window.
for (const directory of [...directories].reverse()) {
fireEvent.click(await screen.findByRole('button', { name: directory }))
await screen.findByText(fileName(directory, 0))
layout.measure()
}
return { ...view, onListDirectory, props }
}
describe('WorkspaceFilesPanel tree windowing', () => {
it('flattens expanded directories in display order with their status rows', () => {
const listings = { '': listing(''), a: { ...listing('a'), truncated: true } }
const flat = flattenFileTree(listings[''].entries, listings, new Set(['a', 'b']), new Set(['b']), { c: 'boom' })
expect(flat.slice(0, 2).map((row) => row.key)).toEqual(['entry:a', 'entry:a/a-000.txt'])
expect(flat[1]!.depth).toBe(1)
expect(flat.slice(filesPer + 1, filesPer + 4).map((row) => row.key)).toEqual(['truncated:a', 'entry:b', 'loading:b'])
// Errors show only for expanded directories, as before.
expect(flat.some((row) => row.key === 'error:c')).toBe(false)
expect(flat).toHaveLength(1 + filesPer + 1 + 1 + 1 + 2)
})
it('keeps the DOM bounded for 2,004 expanded rows and follows the files view scroll', async () => {
await setup()
const mounted = rows()
expect(mounted.length).toBeGreaterThan(0)
expect(mounted.length).toBeLessThanOrEqual(defaultMaxRenderedRows)
expect(tree().getBoundingClientRect().height).toBe((directories.length * (filesPer + 1)) * rowHeight)
// Row 1,002 is c/c-000 (a, 500 files, b, 500 files, c).
layout.scroll(body(), 1_003 * rowHeight)
expect(fileRow('c', 1)).toBeInTheDocument()
expect(fileRow('a', 0)).toBeNull()
expect(rows().length).toBeLessThanOrEqual(defaultMaxRenderedRows)
// Indentation still shows the nesting.
expect(fileRow('c', 1)!.parentElement!.style.marginLeft).toContain('1 *')
}, 20_000)
it('keeps the selected file and the open menu mounted, and scrolls back to the selection', async () => {
const onOpenFile = vi.fn()
await setup(onOpenFile)
layout.scroll(body(), 1_600 * rowHeight)
const target = fileRow('d', 97)!
fireEvent.click(target)
expect(onOpenFile).toHaveBeenCalledWith('d/d-097.txt')
expect(target).toHaveAttribute('aria-current', 'true')
layout.scroll(body(), 0)
expect(fileRow('a', 0)).toBeInTheDocument()
expect(fileRow('d', 97)).toBe(target)
const offset = target.getBoundingClientRect().top + body().scrollTop
expect(offset).toBe((3 * (filesPer + 1) + 1 + 97) * rowHeight)
layout.scroll(body(), offset)
expect(target.getBoundingClientRect().top).toBe(0)
layout.scroll(body(), 0)
fireEvent.click(screen.getByRole('button', { name: 'a-002.txt 的更多操作' }))
expect(screen.getByRole('menu')).toBeInTheDocument()
layout.scroll(body(), 1_000 * rowHeight)
expect(fileRow('a', 2)).toBeInTheDocument()
expect(screen.getByRole('menu')).toBeInTheDocument()
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
await waitFor(() => expect(screen.getByRole('button', { name: 'a-002.txt 的更多操作' })).toHaveFocus())
}, 20_000)
it('collapses and expands directories in the windowed tree', async () => {
await setup()
fireEvent.click(screen.getByRole('button', { name: 'a' }))
expect(screen.getByRole('button', { name: 'a' })).toHaveAttribute('aria-expanded', 'false')
layout.measure()
expect(fileRow('a', 0)).toBeNull()
expect(fileRow('b', 0)).toBeInTheDocument()
expect(tree().getBoundingClientRect().height).toBe((directories.length * (filesPer + 1) - filesPer) * rowHeight)
fireEvent.click(screen.getByRole('button', { name: 'a' }))
await screen.findByText(fileName('a', 0))
await act(async () => { await Promise.resolve() })
expect(fileRow('a', 0)).toBeInTheDocument()
}, 20_000)
})
@@ -26,6 +26,8 @@ import {
} from 'lucide-react'
import { RemoveSourceDialog } from './RemoveSourceDialog'
import { KnowledgeActionsMenu, type KnowledgeAction } from './KnowledgeActionsMenu'
import { useListWindow } from '../use-list-window'
import { ListWindowTableSpacer } from '../ListWindowSpacer'
const sourceStatusLabelKeys = {
queued: 'sourceStatuses.queued',
@@ -138,6 +140,8 @@ export function DocumentsView({
const fileInputRef = useRef<HTMLInputElement>(null)
const [viewedDocumentId, setViewedDocumentId] = useState<string>()
const previewBackRef = useRef<HTMLButtonElement>(null)
const tableBodyRef = useRef<HTMLTableSectionElement>(null)
const [previewReturnKey, setPreviewReturnKey] = useState<string>()
const previewOriginRef = useRef<{ trigger: HTMLElement; scroll?: HTMLElement; top: number } | undefined>(undefined)
const [urlOpen, setUrlOpen] = useState(false)
const [url, setUrl] = useState('')
@@ -218,6 +222,22 @@ export function DocumentsView({
}
return result
}, [documents, sources, locale, query])
const rowKeysKey = rows.map((row) => row.key).join('\n')
const rowKeys = useMemo(() => (rowKeysKey ? rowKeysKey.split('\n') : []), [rowKeysKey])
// P5: large libraries mount only the table rows near the page viewport. The
// page (`.workspace-panel-scroll`) scrolls, not the table.
const documentWindow = useListWindow({
ids: rowKeys,
scope: `${library.id}\n${query}`,
listRef: tableBodyRef,
scrollParent: (body) => body.closest<HTMLElement>('.workspace-panel-scroll'),
// The previewed document's row is where focus returns after the preview.
keepIds: [previewReturnKey],
enabled: true,
active: !viewedDocumentId,
estimatedRowHeight: 96,
rowAttribute: 'data-knowledge-document-row'
})
const viewedDocument = useMemo(() => documents.find((document) => document.id === viewedDocumentId), [documents, viewedDocumentId])
const viewedSource = sources.find((source) => source.id === viewedDocument?.sourceId)
const viewedTaskSourceId = viewedSource && viewedSource.kind !== 'directory' &&
@@ -500,9 +520,9 @@ export function DocumentsView({
</div>
) : (
<div className="knowledge-documents__table-scroll">
<table aria-label={t('documents.table.title')}>
<table aria-label={t('documents.table.title')} aria-rowcount={documentWindow.windowed ? rows.length + 1 : undefined}>
<thead>
<tr>
<tr aria-rowindex={documentWindow.windowed ? 1 : undefined}>
<th>
{t('documents.table.columns.document')}
</th>
@@ -520,10 +540,18 @@ export function DocumentsView({
</th>
</tr>
</thead>
<tbody>
{rows.map(({ key, source, document, grouped }) => {
<tbody ref={tableBodyRef} onBlur={documentWindow.onBlur} onFocus={documentWindow.onFocus}>
{documentWindow.segments.map((segment) => {
if (segment.kind === 'spacer') return (
<ListWindowTableSpacer className="knowledge-documents__spacer" columns={5} height={segment.height} key={`spacer:${segment.key}`} />
)
const { key, source, document, grouped } = rows[segment.index]!
// The header row is row 1.
const rowProps = documentWindow.windowed
? { 'aria-rowindex': segment.index + 2, 'data-knowledge-document-row': key, ref: documentWindow.rowRef(key) }
: {}
if (!document && source) return (
<tr key={key} className="knowledge-source-group">
<tr key={key} className="knowledge-source-group" {...rowProps}>
<td colSpan={5}>
<div className="knowledge-source-row">
<div className="knowledge-source-row__main">
@@ -563,7 +591,7 @@ export function DocumentsView({
(task.status === 'queued' || task.status === 'running')
)
return (
<tr key={key} className={grouped ? 'knowledge-document-row--grouped' : undefined}>
<tr key={key} className={grouped ? 'knowledge-document-row--grouped' : undefined} {...rowProps}>
<td>
<strong>{document.name}</strong>
{(document.path || (source?.kind === 'url' && source.location)) && (
@@ -640,6 +668,7 @@ export function DocumentsView({
{document.resultId && <button type="button" className="secondary-button" onClick={(event) => {
const scroll = event.currentTarget.closest<HTMLElement>('.workspace-panel-scroll') ?? undefined
previewOriginRef.current = { trigger: event.currentTarget, scroll, top: scroll?.scrollTop ?? 0 }
setPreviewReturnKey(key)
setViewedDocumentId(document.id)
requestAnimationFrame(() => { previewBackRef.current?.focus(); if (scroll) scroll.scrollTop = 0 })
}}>{t('documents.viewResult')}</button>}
@@ -0,0 +1,130 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import i18n from '../i18n'
import { defaultKnowledgeOntologySettings } from '../../../shared/knowledge-ontology'
import { installFakeListLayout, type FakeListLayout } from '../list-window-test-layout'
import { defaultMaxRenderedRows } from '../use-list-window'
import { DocumentsView } from './DocumentsView'
import type { KnowledgeDocumentItem, KnowledgeLibrary } from './types'
vi.mock('../DocumentResultPreview', () => ({
DocumentResultPreview: ({ resultId }: { resultId: string }) => <div>Preview {resultId}</div>
}))
const count = 2_000
const rowHeight = 60
const library: KnowledgeLibrary = {
id: 'library-1', name: 'Docs', description: '', storageMode: 'managed', graphEnabled: false,
graphStrategy: 'hybrid', sourceCount: 0, documentCount: count, indexedDocumentCount: count,
ontologySettings: defaultKnowledgeOntologySettings, updatedAt: '2026-07-30T08:00:00.000Z'
}
const nameOf = (index: number): string => `doc-${String(index).padStart(4, '0')}.md`
const documents: KnowledgeDocumentItem[] = Array.from({ length: count }, (_, index) => ({
id: `d${index}`, libraryId: library.id, name: nameOf(index), status: 'ready', indexProgress: 100,
chunkCount: 1, size: 10, resultId: `result-${index}`
}))
let layout: FakeListLayout
let animationFrames: FrameRequestCallback[] = []
const page = (): HTMLElement => document.querySelector<HTMLElement>('.workspace-panel-scroll')!
const table = (): HTMLElement => screen.getByRole('table', { hidden: true })
const renderedNames = (): string[] =>
[...table().querySelectorAll('tbody tr strong')].map((cell) => cell.textContent ?? '')
const row = (index: number): HTMLElement | null =>
table().querySelector<HTMLElement>(`tr[data-knowledge-document-row="document:d${index}"]`)
const flushFrames = (): void => act(() => {
const frames = animationFrames
animationFrames = []
for (const frame of frames) frame(0)
})
beforeEach(async () => {
await i18n.changeLanguage('zh-CN')
animationFrames = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => animationFrames.push(callback))
layout = installFakeListLayout({
isContainer: (element) => element.classList.contains('workspace-panel-scroll'),
rowAttribute: 'data-knowledge-document-row',
rowHeight: () => rowHeight
})
})
afterEach(() => {
cleanup()
layout.restore()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function setup(): void {
render(
<div className="workspace-panel-scroll">
<DocumentsView
documents={documents} library={library} sources={[]} tasks={[]}
onImportDirectory={vi.fn()} onImportFiles={vi.fn()} onImportUrl={vi.fn()}
onOpenDocumentSource={vi.fn()} onPauseSource={vi.fn()} onRemoveSource={vi.fn()}
onRebuildDocument={vi.fn()} onRetrySource={vi.fn()} onSyncSource={vi.fn()}
onManageChunks={vi.fn()} onViewTasks={vi.fn()}
/>
</div>
)
layout.measure()
}
describe('DocumentsView windowing', () => {
it('keeps the table bounded for 2,000 documents with row indexes for assistive technology', () => {
setup()
const names = renderedNames()
expect(names.length).toBeGreaterThan(0)
expect(names.length).toBeLessThanOrEqual(30)
expect(names[0]).toBe(nameOf(0))
expect(table()).toHaveAttribute('aria-rowcount', String(count + 1))
expect(row(0)).toHaveAttribute('aria-rowindex', '2')
// Spacers keep the page as tall as the full table.
const body = table().querySelector('tbody')!
expect(body.getBoundingClientRect().height).toBe(count * rowHeight)
})
it('follows the page scroll, which belongs to an ancestor of the table', () => {
setup()
layout.scroll(page(), 1_200 * rowHeight)
expect(renderedNames().length).toBeLessThanOrEqual(defaultMaxRenderedRows)
expect(row(1_200)).toBeInTheDocument()
expect(row(1_200)!.getBoundingClientRect().top).toBe(0)
expect(row(1_200)).toHaveAttribute('aria-rowindex', '1202')
expect(row(0)).toBeNull()
})
it('returns focus and scroll to the previewed row after the page left it', async () => {
setup()
layout.scroll(page(), 1_200 * rowHeight)
const trigger = within(row(1_200)!).getByRole('button', { name: '查看解析结果' })
trigger.focus()
fireEvent.click(trigger)
flushFrames()
expect(screen.getByText('Preview result-1200')).toBeInTheDocument()
expect(page().scrollTop).toBe(0)
layout.measure()
// The hidden list keeps its window; the previewed row stays mounted.
expect(row(1_200)).toBe(trigger.closest('tr'))
fireEvent.click(screen.getByRole('button', { name: '返回文档列表' }))
flushFrames()
// Browsers report the restored scrollTop with a scroll event.
act(() => { page().dispatchEvent(new Event('scroll')) })
layout.measure()
await waitFor(() => expect(trigger).toHaveFocus())
expect(page().scrollTop).toBe(1_200 * rowHeight)
expect(row(1_200)!.getBoundingClientRect().top).toBe(0)
expect(row(1_201)).toBeInTheDocument()
})
it('renders every row without spacers for small libraries and searches', () => {
setup()
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'doc-000' } })
layout.measure()
// doc-0000 .. doc-0009: below the threshold.
expect(renderedNames()).toHaveLength(10)
expect(table().querySelector('.knowledge-documents__spacer')).toBeNull()
expect(table()).not.toHaveAttribute('aria-rowcount')
})
})
+132
View File
@@ -0,0 +1,132 @@
import { act } from '@testing-library/react'
import { vi } from 'vitest'
/**
* A tiny block-flow layout for windowed-list tests (jsdom has none).
*
* One scroll container (matched by `isContainer`) sits at viewport top 0 and
* is `viewport` px tall. Inside it every element stacks its children
* vertically: rows (elements carrying `rowAttribute`) take `rowHeight(id)`,
* spacers take their inline height (on themselves or their only cell), and
* every other element is as tall as its children.
*/
export type FakeListLayout = {
measure: () => void
scroll: (container: HTMLElement, top: number) => void
contentHeight: (container: HTMLElement) => number
restore: () => void
}
export function installFakeListLayout({
isContainer,
rowAttribute,
viewport = 400,
rowHeight = () => 40,
}: {
isContainer: (element: Element) => boolean
rowAttribute: string
viewport?: number
rowHeight?: (id: string) => number
}): FakeListLayout {
const scrollTops = new WeakMap<Element, number>()
const ownHeight = (element: HTMLElement): number | undefined => {
const id = element.getAttribute(rowAttribute)
if (id) return rowHeight(id)
const inline = Number.parseFloat(element.style?.height ?? '')
if (Number.isFinite(inline)) return inline
return undefined
}
const height = (element: HTMLElement): number => {
if (element.hidden) return 0
const own = ownHeight(element)
if (own !== undefined) return own
let sum = 0
for (const child of element.children) sum += height(child as HTMLElement)
return sum
}
const containerOf = (element: Element): HTMLElement | null => {
let current: Element | null = element
while (current && !isContainer(current)) current = current.parentElement
return current as HTMLElement | null
}
const top = (element: HTMLElement, container: HTMLElement): number => {
if (element === container) return 0
const parent = element.parentElement!
let offset = top(parent, container) - (parent === container ? container.scrollTop : 0)
for (const sibling of parent.children) {
if (sibling === element) break
offset += height(sibling as HTMLElement)
}
return offset
}
const rect = (y: number, h: number): DOMRect =>
({ top: y, bottom: y + h, height: h, left: 0, right: 0, width: 0, x: 0, y, toJSON: () => ({}) }) as DOMRect
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
const container = containerOf(this)
if (!container) return rect(0, 0)
if (this === container) return rect(0, viewport)
return rect(top(this, container), height(this))
})
const names = ['clientHeight', 'scrollHeight', 'scrollTop'] as const
const originals = new Map(names.map((name) => [name, Object.getOwnPropertyDescriptor(HTMLElement.prototype, name)]))
const contentHeight = (container: HTMLElement): number => {
let sum = 0
for (const child of container.children) sum += height(child as HTMLElement)
return sum
}
Object.defineProperties(HTMLElement.prototype, {
clientHeight: { configurable: true, get(this: HTMLElement) { return isContainer(this) ? viewport : 0 } },
scrollHeight: { configurable: true, get(this: HTMLElement) { return isContainer(this) ? contentHeight(this) : 0 } },
scrollTop: {
configurable: true,
get(this: HTMLElement) { return scrollTops.get(this) ?? 0 },
set(this: HTMLElement, value: number) {
const max = isContainer(this) ? Math.max(0, contentHeight(this) - viewport) : 0
scrollTops.set(this, Math.min(Math.max(0, value), max))
},
},
})
class FakeResizeObserver {
static instances: FakeResizeObserver[] = []
readonly observed = new Set<Element>()
constructor(private readonly callback: ResizeObserverCallback) {
FakeResizeObserver.instances.push(this)
}
observe(element: Element): void { this.observed.add(element) }
unobserve(element: Element): void { this.observed.delete(element) }
disconnect(): void { this.observed.clear() }
fire(): void {
const entries = [...this.observed].map((target) => ({ target }) as ResizeObserverEntry)
if (entries.length) this.callback(entries, this as unknown as ResizeObserver)
}
}
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
const measure = (): void => {
act(() => {
for (const observer of [...FakeResizeObserver.instances]) observer.fire()
})
}
return {
measure,
contentHeight,
scroll(container, value) {
act(() => {
container.scrollTop = value
container.dispatchEvent(new Event('scroll'))
})
measure()
},
restore() {
for (const name of names) {
const original = originals.get(name)
if (original) Object.defineProperty(HTMLElement.prototype, name, original)
else Reflect.deleteProperty(HTMLElement.prototype, name)
}
},
}
}
+7
View File
@@ -30,3 +30,10 @@
.assistant-sidebar__body.assistant-sidebar__body--notes { overflow: hidden; padding: 0; display: flex; flex-direction: column; }
.assistant-sidebar__body--notes > .magic-note-panel { flex: 1; }
.magic-note-panel .magic-note-panel__footer textarea { min-height: 80px; max-height: 180px; }
/* Windowed note list (P5): rows of cards span the grid as subgrids; spacers
stand in for unmounted rows. Row spacing moves into each row's padding so
spacer heights and measured rows add up exactly. */
.magic-notes-list.magic-notes-card-grid.magic-notes-card-grid--windowed { row-gap: 0; }
.magic-notes-card-grid--windowed > .magic-notes-window-row { display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; align-items: stretch; }
.magic-notes-card-grid--windowed > .magic-notes-list__spacer { grid-column: 1 / -1; }
+9
View File
@@ -15302,6 +15302,15 @@ textarea.field-control {
overflow-wrap: anywhere;
}
/* Windowed document table (P5): spacer rows stand in for unmounted rows. */
.knowledge-documents__table-scroll tbody tr.knowledge-documents__spacer {
border: 0;
}
.knowledge-documents__table-scroll tr.knowledge-documents__spacer > td {
padding: 0;
}
.knowledge-documents__table-scroll .knowledge-document-metric {
text-align: right;
white-space: nowrap;
+28 -283
View File
@@ -1,97 +1,31 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import {
defaultEstimatedRowHeight,
defaultListWindowThreshold,
defaultMaxRenderedRows,
listWindowOverscan,
listWindowRange,
useListWindow,
type ListWindowSegment,
} from './use-list-window'
/**
* Windowing for the sidebar conversation list (PERF-14).
*
* Long lists mount only the rows near the viewport; spacers stand in for the
* rest, sized from measured row heights (or the mean measured height for rows
* never rendered). The list is top-anchored like any normal list. Rows that
* hold user state (focus, open menu, rename, the active conversation) stay
* mounted at their real position even when scrolled away.
* Windowing for the sidebar conversation list (PERF-14), on the shared
* list-window hook. The list scrolls itself, is top-anchored, and returns to
* the top when the project or search changes. Rows that hold user state
* (focus, open menu, rename, the active conversation) stay mounted at their
* real position even when scrolled away.
*/
/** Lists shorter than this render every row (no windowing). */
export const conversationListWindowThreshold = 60
export const conversationListWindowThreshold = defaultListWindowThreshold
/** Height assumed for rows before any row has been measured. */
export const estimatedConversationRowHeight = 40
export const estimatedConversationRowHeight = defaultEstimatedRowHeight
/** Upper bound for rows mounted from the range (kept rows come on top). */
export const maxRenderedConversationRows = 120
const fallbackViewportHeight = 800
export const maxRenderedConversationRows = defaultMaxRenderedRows
export function conversationListOverscan(viewportHeight: number): number {
return Math.max(320, Math.round(viewportHeight))
}
export type ConversationListSegment =
| { kind: 'spacer'; key: string; height: number }
| { kind: 'row'; index: number }
class ConversationRowHeights {
private readonly sizes = new Map<string, number>()
private estimateValue = estimatedConversationRowHeight
private viewportValue = 0
get viewport(): number {
return this.viewportValue > 0 ? this.viewportValue : fallbackViewportHeight
}
setViewport(height: number): boolean {
if (height <= 0 || Math.abs(height - this.viewportValue) < 1) return false
this.viewportValue = height
return true
}
size(id: string): number {
return this.sizes.get(id) ?? this.estimateValue
}
/** Records a height; returns whether layout may have changed. */
set(id: string, height: number): boolean {
const previous = this.sizes.get(id)
if (previous !== undefined) {
if (Math.abs(previous - height) < 0.5) return false
this.sizes.set(id, height)
return true
}
const count = this.sizes.size
this.estimateValue = count === 0 ? height : (this.estimateValue * count + height) / (count + 1)
this.sizes.set(id, height)
return true
}
}
type Layout = {
/** offsets[i] is the top of row i; offsets[count] is the total height. */
offsets: number[]
start: number
end: number
viewport: number
}
/** Rows covering the viewport whose top is `scrollTop`, plus `overscan` on both sides. */
export function conversationWindowRange(
offsets: readonly number[],
scrollTop: number,
viewportHeight: number,
overscan: number,
maxCount = maxRenderedConversationRows,
): { start: number; end: number } {
const count = offsets.length - 1
const total = offsets[count] ?? 0
const top = Math.min(Math.max(0, scrollTop), Math.max(0, total - viewportHeight))
let start = 0
while (start < count && offsets[start + 1]! <= top - overscan) start += 1
let end = start
while (end < count && offsets[end]! < top + viewportHeight + overscan) end += 1
if (end - start > maxCount) {
// Keep the rows at the viewport top; trim the overscan evenly.
let first = start
while (first < end && offsets[first + 1]! <= top) first += 1
start = Math.max(start, Math.min(first - Math.floor(maxCount / 4), end - maxCount))
end = Math.min(end, start + maxCount)
}
return { start, end }
}
export const conversationListOverscan = listWindowOverscan
export const conversationWindowRange = listWindowRange
export type ConversationListSegment = ListWindowSegment
export function useConversationListWindow({
ids,
@@ -109,202 +43,13 @@ export function useConversationListWindow({
keepIds: readonly (string | undefined)[]
enabled: boolean
}) {
const windowed = enabled && ids.length >= conversationListWindowThreshold
const [heights] = useState(() => new ConversationRowHeights())
const [measureVersion, setMeasureVersion] = useState(0)
// scrollTop at the last re-window; the range is derived from it.
const [anchorTop, setAnchorTop] = useState(0)
const [focusedId, setFocusedId] = useState<string | undefined>(undefined)
const [seenScope, setSeenScope] = useState(scope)
if (seenScope !== scope) {
setSeenScope(scope)
setAnchorTop(0)
}
const layoutRef = useRef<Layout | undefined>(undefined)
const rowElements = useRef(new Map<string, HTMLElement>())
const rowRefCallbacks = useRef(new Map<string, (element: HTMLElement | null) => void>())
const observerRef = useRef<ResizeObserver | undefined>(undefined)
const scrollAnchorRef = useRef<{ id: string; top: number } | undefined>(undefined)
const scopeRef = useRef(scope)
const keep = new Set([...keepIds, focusedId].filter((id): id is string => Boolean(id)))
// Kept rows change the mounted set (and spacers), so the layout effect reruns.
const keepKey = [...keep].join('\n')
let segments: ConversationListSegment[]
let layout: Layout | undefined
if (!windowed) {
segments = ids.map((_, index) => ({ kind: 'row', index }))
} else {
const offsets = new Array<number>(ids.length + 1)
offsets[0] = 0
for (let index = 0; index < ids.length; index += 1) {
offsets[index + 1] = offsets[index]! + heights.size(ids[index]!)
}
const viewport = heights.viewport
const range = conversationWindowRange(offsets, anchorTop, viewport, conversationListOverscan(viewport))
layout = { offsets, start: range.start, end: range.end, viewport }
const rendered: number[] = []
for (let index = range.start; index < range.end; index += 1) rendered.push(index)
if (keep.size) {
ids.forEach((id, index) => {
if (keep.has(id) && (index < range.start || index >= range.end)) rendered.push(index)
})
rendered.sort((left, right) => left - right)
}
// The top spacer is always present so it keeps its identity while the window moves.
segments = [{ kind: 'spacer', key: 'top', height: 0 }]
let previous = 0
for (const index of rendered) {
if (index > previous) {
const height = offsets[index]! - offsets[previous]!
if (previous === 0) segments[0] = { kind: 'spacer', key: 'top', height }
else segments.push({ kind: 'spacer', key: `gap:${ids[previous]}`, height })
}
segments.push({ kind: 'row', index })
previous = index + 1
}
if (previous < ids.length) {
segments.push({ kind: 'spacer', key: 'bottom', height: offsets[ids.length]! - offsets[previous]! })
}
}
useLayoutEffect(() => {
layoutRef.current = layout
return useListWindow({
ids,
scope,
scrollRef,
keepIds,
enabled,
rowAttribute: 'data-conversation-window-row',
resetScrollOnScopeChange: true,
})
/** First mounted row reaching into the viewport, relative to the viewport top. */
const captureScrollAnchor = useCallback((): void => {
const container = scrollRef.current
if (!container || container.scrollTop <= 0) {
scrollAnchorRef.current = undefined
return
}
const viewportTop = container.getBoundingClientRect().top
for (const element of container.querySelectorAll<HTMLElement>('[data-conversation-window-row]')) {
const rect = element.getBoundingClientRect()
if (rect.bottom > viewportTop) {
scrollAnchorRef.current = { id: element.dataset.conversationWindowRow ?? '', top: rect.top - viewportTop }
return
}
}
scrollAnchorRef.current = undefined
}, [scrollRef])
// Row and viewport measurement.
useLayoutEffect(() => {
const container = scrollRef.current
if (!windowed || !container) return
if (heights.setViewport(container.clientHeight)) setMeasureVersion((version) => version + 1)
if (typeof ResizeObserver !== 'function') return
const observer = new ResizeObserver((entries) => {
let changed = false
for (const entry of entries) {
const target = entry.target as HTMLElement
if (target === container) {
if (heights.setViewport(container.clientHeight)) changed = true
continue
}
const id = target.dataset?.conversationWindowRow
if (!id) continue
const height = target.getBoundingClientRect().height
if (height > 0 && heights.set(id, height)) changed = true
}
if (changed) {
// Spacer sizes may change with the new estimate; keep the visible row in place.
captureScrollAnchor()
setMeasureVersion((version) => version + 1)
}
})
observer.observe(container)
for (const element of rowElements.current.values()) observer.observe(element)
observerRef.current = observer
return () => {
observer.disconnect()
observerRef.current = undefined
}
}, [captureScrollAnchor, heights, scrollRef, windowed])
// After each commit: restore the visible row's position after a re-layout,
// return to the top on a scope change, and re-window when the scroll
// position left the mounted rows without a scroll event (content shrank).
useLayoutEffect(() => {
const container = scrollRef.current
if (!container) return
if (scopeRef.current !== scope) {
scopeRef.current = scope
scrollAnchorRef.current = undefined
// New search results or another project start at the top; short lists
// keep the browser's own clamping, as before windowing.
if (windowed && container.scrollTop !== 0) container.scrollTop = 0
}
const anchor = scrollAnchorRef.current
scrollAnchorRef.current = undefined
if (windowed && anchor) {
const element = rowElements.current.get(anchor.id)
if (element) {
const delta = element.getBoundingClientRect().top - container.getBoundingClientRect().top - anchor.top
if (Math.abs(delta) >= 0.5) container.scrollTop += delta
}
}
const current = layoutRef.current
if (!windowed || !current) return
const scrollTop = container.scrollTop
if (!coversViewport(current, scrollTop) && Math.abs(scrollTop - anchorTop) >= 1) setAnchorTop(scrollTop)
}, [anchorTop, ids, keepKey, measureVersion, scope, scrollRef, windowed])
const onScroll = useCallback((): void => {
const container = scrollRef.current
const current = layoutRef.current
if (!container || !current) return
if (heights.setViewport(container.clientHeight)) setMeasureVersion((version) => version + 1)
const scrollTop = container.scrollTop
// Hysteresis: re-window only when the viewport (plus a margin) leaves the
// mounted rows, so a steady scroll remounts rows every screen or so.
if (!coversViewport(current, scrollTop)) setAnchorTop(scrollTop)
}, [heights, scrollRef])
const rowRef = useCallback((id: string): ((element: HTMLElement | null) => void) => {
let callback = rowRefCallbacks.current.get(id)
if (!callback) {
callback = (element) => {
const previous = rowElements.current.get(id)
if (previous && previous !== element) {
observerRef.current?.unobserve(previous)
rowElements.current.delete(id)
}
if (element && previous !== element) {
rowElements.current.set(id, element)
observerRef.current?.observe(element)
}
if (!element) rowRefCallbacks.current.delete(id)
}
rowRefCallbacks.current.set(id, callback)
}
return callback
}, [])
const onFocus = useCallback((event: React.FocusEvent<HTMLElement>): void => {
const row = event.target instanceof Element
? event.target.closest<HTMLElement>('[data-conversation-window-row]')
: null
setFocusedId(row?.dataset.conversationWindowRow)
}, [])
const onBlur = useCallback((event: React.FocusEvent<HTMLElement>): void => {
const next = event.relatedTarget
// Focus leaving the window (null) keeps the row; moving elsewhere releases it.
if (next instanceof Node && !event.currentTarget.contains(next)) setFocusedId(undefined)
}, [])
return { windowed, segments, rowRef, onScroll, onFocus, onBlur }
}
function coversViewport(layout: Layout, scrollTop: number): boolean {
const count = layout.offsets.length - 1
const margin = Math.round(layout.viewport / 4)
const renderedTop = layout.offsets[layout.start] ?? 0
const renderedBottom = layout.offsets[layout.end] ?? 0
const topCovered = layout.start === 0 || renderedTop <= scrollTop - margin
const bottomCovered = layout.end >= count || renderedBottom >= scrollTop + layout.viewport + margin
return topCovered && bottomCovered
}
+113
View File
@@ -0,0 +1,113 @@
import { useLayoutEffect, useMemo, useState } from 'react'
import { defaultListWindowThreshold, useListWindow, type ListWindowOptions } from './use-list-window'
/** `gap` is the grid's column gap; windowed rows reuse it as their row spacing. */
export type GridTrackMetrics = { columns: number; gap: number }
/**
* Resolved column count and row gap of a CSS grid; follows its width.
* Changing `layoutKey` re-reads them (the grid's class, and so its tracks, changed).
*/
export function useGridColumns(
gridRef: React.RefObject<HTMLElement | null>,
enabled: boolean,
layoutKey = '',
): GridTrackMetrics {
const [metrics, setMetrics] = useState<GridTrackMetrics>({ columns: 1, gap: 0 })
useLayoutEffect(() => {
const grid = gridRef.current
if (!enabled || !grid) return
const update = (): void => {
// Hidden grids report no tracks; keep the last layout until shown again.
if (grid.closest('[hidden]')) return
const next = readGridMetrics(grid)
setMetrics((current) =>
current.columns === next.columns && Math.abs(current.gap - next.gap) < 0.5 ? current : next)
}
update()
if (typeof ResizeObserver !== 'function') {
window.addEventListener('resize', update)
return () => window.removeEventListener('resize', update)
}
const observer = new ResizeObserver(update)
observer.observe(grid)
return () => observer.disconnect()
}, [enabled, gridRef, layoutKey])
return metrics
}
export function readGridMetrics(grid: HTMLElement): GridTrackMetrics {
const style = getComputedStyle(grid)
// Computed `grid-template-columns` lists one resolved length per track.
const tracks = (style.gridTemplateColumns ?? '').trim().split(/\s+/).filter((track) => /^[\d.]+px$/.test(track))
const gap = Number.parseFloat(style.columnGap)
return { columns: Math.max(1, tracks.length), gap: Number.isFinite(gap) ? gap : 0 }
}
export type GridWindowSegment =
| { kind: 'spacer'; key: string; height: number }
| { kind: 'row'; key: string; start: number; end: number }
/**
* Windowing for a CSS grid of cards that scrolls itself: items are grouped in
* rows of the grid's current column count and the rows are windowed. Kept
* items (selection, open menu, focus) keep their whole row mounted.
*
* While windowed, each row should be a full-width subgrid item
* (`grid-column: 1 / -1; grid-template-columns: subgrid`) and spacers full-width
* blocks of the given height; the grid's own `row-gap` must then be 0, with
* the returned `gap` carried inside each row as bottom padding, so the
* measured row height includes it.
*/
export function useGridListWindow({
itemIds,
gridRef,
keepIds,
layoutKey,
...options
}: Omit<ListWindowOptions, 'ids' | 'scrollRef' | 'listRef' | 'keepIds' | 'layoutKey'> & {
itemIds: readonly string[]
gridRef: React.RefObject<HTMLElement | null>
keepIds: readonly (string | undefined)[]
/** Card layout (view mode); rows are re-measured when it or the column count changes. */
layoutKey: string
}) {
// The threshold counts items, not grid rows.
const enabled = options.enabled && itemIds.length >= (options.threshold ?? defaultListWindowThreshold)
const { columns, gap } = useGridColumns(gridRef, enabled, layoutKey)
const itemKey = itemIds.join('\n')
const rowIds = useMemo(() => {
const items = itemKey ? itemKey.split('\n') : []
const rows: string[] = []
for (let index = 0; index < items.length; index += columns) rows.push(`row:${items[index]}`)
return rows
}, [columns, itemKey])
const rowIndexById = useMemo(() => {
const map = new Map<string, number>()
itemIds.forEach((id, index) => map.set(id, Math.floor(index / columns)))
return map
}, [columns, itemIds])
const keepRows = keepIds.map((id) => {
const row = id ? rowIndexById.get(id) : undefined
return row === undefined ? undefined : rowIds[row]
})
const listWindow = useListWindow({
...options,
enabled,
threshold: 1,
ids: rowIds,
scrollRef: gridRef,
keepIds: keepRows,
layoutKey: `${layoutKey}:${columns}`,
})
const segments: GridWindowSegment[] = listWindow.segments.map((segment) =>
segment.kind === 'spacer'
? segment
: {
kind: 'row',
key: rowIds[segment.index]!,
start: segment.index * columns,
end: Math.min(itemIds.length, (segment.index + 1) * columns),
})
return { ...listWindow, segments, columns, gap }
}
+437
View File
@@ -0,0 +1,437 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
/**
* Windowing for long renderer lists (PERF-14, P5).
*
* Long lists mount only the rows near the viewport; spacers stand in for the
* rest, sized from measured row heights (or the mean measured height for rows
* never rendered). The list is top-anchored like any normal list. Rows that
* hold user state (focus, open menu, rename, the selection) stay mounted at
* their real position even when scrolled away, so DOM and Tab order follow
* the list and `scrollIntoView` on such a row lands where it belongs.
*
* The scrolling element is either the list itself (`scrollRef` only) or an
* ancestor (`listRef` plus `scrollParent`), in which case the list may sit
* anywhere inside it, below other content. Without any scroll parent the
* window follows the document viewport.
*/
/** Lists shorter than this render every row (no windowing). */
export const defaultListWindowThreshold = 60
/** Height assumed for rows before any row has been measured. */
export const defaultEstimatedRowHeight = 40
/** Upper bound for rows mounted from the range (kept rows come on top). */
export const defaultMaxRenderedRows = 120
/** Row attribute carrying the row id; spacers carry `data-list-window-spacer`. */
export const defaultListWindowRowAttribute = 'data-list-window-row'
const fallbackViewportHeight = 800
export function listWindowOverscan(viewportHeight: number): number {
return Math.max(320, Math.round(viewportHeight))
}
export type ListWindowSegment =
| { kind: 'spacer'; key: string; height: number }
| { kind: 'row'; index: number }
class RowHeights {
private readonly sizes = new Map<string, number>()
private estimateValue: number
private viewportValue = 0
constructor(estimate: number) {
this.estimateValue = estimate
}
/** Forgets every measurement (the row layout changed, e.g. a new column count). */
reset(estimate: number): void {
this.sizes.clear()
this.estimateValue = estimate
}
get viewport(): number {
return this.viewportValue > 0 ? this.viewportValue : fallbackViewportHeight
}
setViewport(height: number): boolean {
if (height <= 0 || Math.abs(height - this.viewportValue) < 1) return false
this.viewportValue = height
return true
}
size(id: string): number {
return this.sizes.get(id) ?? this.estimateValue
}
/** Records a height; returns whether layout may have changed. */
set(id: string, height: number): boolean {
const previous = this.sizes.get(id)
if (previous !== undefined) {
if (Math.abs(previous - height) < 0.5) return false
this.sizes.set(id, height)
return true
}
const count = this.sizes.size
this.estimateValue = count === 0 ? height : (this.estimateValue * count + height) / (count + 1)
this.sizes.set(id, height)
return true
}
}
type Layout = {
/** offsets[i] is the top of row i; offsets[count] is the total height. */
offsets: number[]
start: number
end: number
viewport: number
}
/** Rows covering the viewport whose top is `scrollTop`, plus `overscan` on both sides. */
export function listWindowRange(
offsets: readonly number[],
scrollTop: number,
viewportHeight: number,
overscan: number,
maxCount = defaultMaxRenderedRows,
): { start: number; end: number } {
const count = offsets.length - 1
const total = offsets[count] ?? 0
const top = Math.min(Math.max(0, scrollTop), Math.max(0, total - viewportHeight))
let start = 0
while (start < count && offsets[start + 1]! <= top - overscan) start += 1
let end = start
while (end < count && offsets[end]! < top + viewportHeight + overscan) end += 1
if (end - start > maxCount) {
// Keep the rows at the viewport top; trim the overscan evenly.
let first = start
while (first < end && offsets[first + 1]! <= top) first += 1
start = Math.max(start, Math.min(first - Math.floor(maxCount / 4), end - maxCount))
end = Math.min(end, start + maxCount)
}
return { start, end }
}
export type ListWindowOptions = {
/** Row ids in display order. */
ids: readonly string[]
/** Changing the scope (project, search, folder) re-windows from the top. */
scope: string
/** The list element when it scrolls itself. */
scrollRef?: React.RefObject<HTMLElement | null>
/** The element containing the rows when an ancestor scrolls. */
listRef?: React.RefObject<HTMLElement | null>
/** Finds the scrolling ancestor of `listRef`; null follows the document viewport. */
scrollParent?: (list: HTMLElement) => HTMLElement | null
/** Ids that must stay mounted (selected, menu open, renaming...). */
keepIds: readonly (string | undefined)[]
enabled: boolean
rowAttribute?: string
threshold?: number
estimatedRowHeight?: number
maxRenderedRows?: number
/** Scroll back to the top on a scope change (sidebar search results). */
resetScrollOnScopeChange?: boolean
/** Changing it drops measured heights (rows were re-laid out). */
layoutKey?: string
/**
* False while the list is hidden (`hidden`, a collapsed view): its geometry
* is meaningless then. Turning it back on re-windows at the current position.
*/
active?: boolean
}
type Geometry = {
list: HTMLElement
/** Scrolling element; undefined follows the document viewport. */
container: HTMLElement | undefined
}
export function useListWindow({
ids,
scope,
scrollRef,
listRef,
scrollParent,
keepIds,
enabled,
rowAttribute = defaultListWindowRowAttribute,
threshold = defaultListWindowThreshold,
estimatedRowHeight = defaultEstimatedRowHeight,
maxRenderedRows = defaultMaxRenderedRows,
resetScrollOnScopeChange = false,
active = true,
layoutKey = '',
}: ListWindowOptions) {
const windowed = enabled && ids.length >= threshold
const [heights] = useState(() => new RowHeights(estimatedRowHeight))
const [measureVersion, setMeasureVersion] = useState(0)
const [seenLayoutKey, setSeenLayoutKey] = useState(layoutKey)
if (seenLayoutKey !== layoutKey) {
setSeenLayoutKey(layoutKey)
heights.reset(estimatedRowHeight)
}
// List-relative scroll position at the last re-window; the range is derived from it.
const [anchorTop, setAnchorTop] = useState(0)
const [focusedId, setFocusedId] = useState<string | undefined>(undefined)
const [seenScope, setSeenScope] = useState(scope)
if (seenScope !== scope) {
setSeenScope(scope)
setAnchorTop(0)
}
const layoutRef = useRef<Layout | undefined>(undefined)
const rowElements = useRef(new Map<string, HTMLElement>())
const rowRefCallbacks = useRef(new Map<string, (element: HTMLElement | null) => void>())
const observerRef = useRef<ResizeObserver | undefined>(undefined)
const scrollAnchorRef = useRef<{ id: string; top: number } | undefined>(undefined)
const scopeRef = useRef(scope)
const scrollParentRef = useRef(scrollParent)
useLayoutEffect(() => {
scrollParentRef.current = scrollParent
})
const keep = new Set([...keepIds, focusedId].filter((id): id is string => Boolean(id)))
// Kept rows change the mounted set (and spacers), so the layout effect reruns.
const keepKey = [...keep].join('\n')
let segments: ListWindowSegment[]
let layout: Layout | undefined
if (!windowed) {
segments = ids.map((_, index) => ({ kind: 'row', index }))
} else {
const offsets = new Array<number>(ids.length + 1)
offsets[0] = 0
for (let index = 0; index < ids.length; index += 1) {
offsets[index + 1] = offsets[index]! + heights.size(ids[index]!)
}
const viewport = heights.viewport
const range = listWindowRange(offsets, anchorTop, viewport, listWindowOverscan(viewport), maxRenderedRows)
layout = { offsets, start: range.start, end: range.end, viewport }
const rendered: number[] = []
for (let index = range.start; index < range.end; index += 1) rendered.push(index)
if (keep.size) {
ids.forEach((id, index) => {
if (keep.has(id) && (index < range.start || index >= range.end)) rendered.push(index)
})
rendered.sort((left, right) => left - right)
}
// The top spacer is always present so it keeps its identity while the window moves.
segments = [{ kind: 'spacer', key: 'top', height: 0 }]
let previous = 0
for (const index of rendered) {
if (index > previous) {
const height = offsets[index]! - offsets[previous]!
if (previous === 0) segments[0] = { kind: 'spacer', key: 'top', height }
else segments.push({ kind: 'spacer', key: `gap:${ids[previous]}`, height })
}
segments.push({ kind: 'row', index })
previous = index + 1
}
if (previous < ids.length) {
segments.push({ kind: 'spacer', key: 'bottom', height: offsets[ids.length]! - offsets[previous]! })
}
}
useLayoutEffect(() => {
layoutRef.current = layout
})
const geometry = useCallback((): Geometry | undefined => {
const list = listRef?.current ?? scrollRef?.current
if (!list) return undefined
const container = scrollRef?.current ?? scrollParentRef.current?.(list) ?? undefined
return { list, container }
}, [listRef, scrollRef])
const rowSelector = `[${rowAttribute}]`
/** Viewport top in client coordinates. */
const viewportTop = (container: HTMLElement | undefined): number =>
container ? container.getBoundingClientRect().top : 0
/** Scroll position relative to the list top, and the viewport height. */
const position = useCallback(({ list, container }: Geometry): { top: number; viewport: number } => {
if (container === list) return { top: list.scrollTop, viewport: list.clientHeight }
const top = viewportTop(container) - list.getBoundingClientRect().top
return { top, viewport: container ? container.clientHeight : window.innerHeight }
}, [])
/** First mounted row reaching into the viewport, relative to the viewport top. */
const captureScrollAnchor = useCallback((): void => {
const current = geometry()
if (!current || position(current).top <= 0) {
scrollAnchorRef.current = undefined
return
}
const top = viewportTop(current.container)
for (const element of current.list.querySelectorAll<HTMLElement>(rowSelector)) {
const rect = element.getBoundingClientRect()
if (rect.bottom > top) {
scrollAnchorRef.current = { id: element.getAttribute(rowAttribute) ?? '', top: rect.top - top }
return
}
}
scrollAnchorRef.current = undefined
}, [geometry, position, rowAttribute, rowSelector])
const activeRef = useRef(active)
useLayoutEffect(() => {
activeRef.current = active
})
/** Re-windows when the viewport (plus a margin) left the mounted rows. */
const follow = useCallback((): void => {
const current = geometry()
const layoutNow = layoutRef.current
if (!current || !layoutNow || !activeRef.current || isHidden(current.list)) return
const { top, viewport } = position(current)
if (heights.setViewport(viewport)) setMeasureVersion((version) => version + 1)
// Hysteresis: re-window only when the viewport (plus a margin) leaves the
// mounted rows, so a steady scroll remounts rows every screen or so.
if (!coversViewport(layoutNow, top)) setAnchorTop(top)
}, [geometry, heights, position])
// Row and viewport measurement.
useLayoutEffect(() => {
const current = geometry()
if (!windowed || !current) return
const { container, list } = current
const onWindowResize = (): void => follow()
if (!container) window.addEventListener('resize', onWindowResize)
if (typeof ResizeObserver !== 'function') {
return () => window.removeEventListener('resize', onWindowResize)
}
// The observer reports every observed element once on `observe`, which
// delivers the first viewport height without a synchronous re-render here.
const observer = new ResizeObserver((entries) => {
let changed = false
for (const entry of entries) {
const target = entry.target as HTMLElement
if (target === (container ?? list)) {
if (heights.setViewport(container ? container.clientHeight : window.innerHeight)) changed = true
if (target !== list) continue
}
const id = target.getAttribute?.(rowAttribute)
if (!id) continue
const height = target.getBoundingClientRect().height
if (height > 0 && heights.set(id, height)) changed = true
}
if (changed) {
// Spacer sizes may change with the new estimate; keep the visible row in place.
captureScrollAnchor()
setMeasureVersion((version) => version + 1)
}
})
observer.observe(container ?? list)
for (const element of rowElements.current.values()) observer.observe(element)
observerRef.current = observer
return () => {
window.removeEventListener('resize', onWindowResize)
observer.disconnect()
observerRef.current = undefined
}
}, [captureScrollAnchor, follow, geometry, heights, rowAttribute, windowed])
// An ancestor (or the document) scrolls: listen to it here.
useLayoutEffect(() => {
const current = geometry()
if (!windowed || !current || current.container === current.list) return
const target: HTMLElement | Window = current.container ?? window
const onScroll = (): void => follow()
target.addEventListener('scroll', onScroll, { passive: true })
return () => target.removeEventListener('scroll', onScroll)
}, [follow, geometry, windowed])
// After each commit: restore the visible row's position after a re-layout,
// return to the top on a scope change, and re-window when the scroll
// position left the mounted rows without a scroll event (content shrank).
useLayoutEffect(() => {
const current = geometry()
if (!current) return
const { container } = current
if (scopeRef.current !== scope) {
scopeRef.current = scope
scrollAnchorRef.current = undefined
// New search results start at the top; short lists keep the browser's
// own clamping, as before windowing.
if (resetScrollOnScopeChange && windowed && container && container.scrollTop !== 0) container.scrollTop = 0
}
const anchor = scrollAnchorRef.current
scrollAnchorRef.current = undefined
if (windowed && anchor) {
const element = rowElements.current.get(anchor.id)
if (element) {
const delta = element.getBoundingClientRect().top - viewportTop(container) - anchor.top
if (Math.abs(delta) >= 0.5) {
if (container) container.scrollTop += delta
else window.scrollBy(0, delta)
}
}
}
const layoutNow = layoutRef.current
if (!windowed || !layoutNow || !active || isHidden(current.list)) return
const { top } = position(current)
if (!coversViewport(layoutNow, top) && Math.abs(top - anchorTop) >= 1) setAnchorTop(top)
}, [active, anchorTop, geometry, ids, keepKey, measureVersion, position, resetScrollOnScopeChange, scope, windowed])
/** For lists that scroll themselves: wire to the list's onScroll. */
const onScroll = useCallback((): void => follow(), [follow])
const rowRef = useCallback((id: string): ((element: HTMLElement | null) => void) => {
let callback = rowRefCallbacks.current.get(id)
if (!callback) {
callback = (element) => {
const previous = rowElements.current.get(id)
if (previous && previous !== element) {
observerRef.current?.unobserve(previous)
rowElements.current.delete(id)
}
if (element && previous !== element) {
rowElements.current.set(id, element)
observerRef.current?.observe(element)
}
if (!element) rowRefCallbacks.current.delete(id)
}
rowRefCallbacks.current.set(id, callback)
}
return callback
}, [])
// The row that last held focus inside the list; a portaled menu opened from
// it (React focus events bubble through portals) keeps that row mounted.
const lastFocusedRef = useRef<string | undefined>(undefined)
const onFocus = useCallback((event: React.FocusEvent<HTMLElement>): void => {
const target = event.target
if (target instanceof Node && !event.currentTarget.contains(target)) {
// Focus inside a portal rendered by a row: keep its row.
setFocusedId(lastFocusedRef.current)
return
}
const row = target instanceof Element ? target.closest<HTMLElement>(rowSelector) : null
const id = row?.getAttribute(rowAttribute) ?? undefined
lastFocusedRef.current = id
setFocusedId(id)
}, [rowAttribute, rowSelector])
const onBlur = useCallback((event: React.FocusEvent<HTMLElement>): void => {
const next = event.relatedTarget
// Focus leaving the window (null) keeps the row; moving elsewhere releases
// it (a portal of this list gets it back from its own focus event).
if (next instanceof Node && !event.currentTarget.contains(next)) setFocusedId(undefined)
}, [])
return { windowed, segments, rowRef, onScroll, onFocus, onBlur }
}
/** A list inside a `hidden` subtree has no geometry; leave its window alone. */
function isHidden(list: HTMLElement): boolean {
return list.closest('[hidden]') !== null
}
function coversViewport(layout: Layout, scrollTop: number): boolean {
const count = layout.offsets.length - 1
const margin = Math.round(layout.viewport / 4)
const renderedTop = layout.offsets[layout.start] ?? 0
const renderedBottom = layout.offsets[layout.end] ?? 0
const topCovered = layout.start === 0 || renderedTop <= scrollTop - margin
const bottomCovered = layout.end >= count || renderedBottom >= scrollTop + layout.viewport + margin
return topCovered && bottomCovered
}