= errorState.projectId === projectId ? errorState.value : emptyRecord
const renderDirectoryError = (path: string): React.JSX.Element | null => errors[path] ? (
{errors[path]}
@@ -368,55 +407,50 @@ export function WorkspaceFilesPanel({
}}>
)
- const renderEntry = (
- entry: WorkspaceDirectoryEntry
+ const renderTreeRow = (
+ row: FileTreeRow,
+ rowProps: Record
): 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 (
-
-
- toggleDirectory(entry.path)}
- type="button"
- >
- {expanded ? (
-
- ) : (
-
- )}
- {expanded ? : }
- {entry.name}
-
- {entryMenu(entry)}
-
- {expanded && (
-
- {listing?.entries.map((child) =>
- renderEntry(child)
- )}
- {loadingPaths.has(entry.path) && (
-
- {t('files.reading')}
-
- )}
- {renderDirectoryError(entry.path)}
- {listing?.truncated && (
-
- {t('files.directoryTruncated')}
-
- )}
-
+
+ {row.status === 'error' ? renderDirectoryError(row.path) : (
+
+ {t(row.status === 'loading' ? 'files.reading' : 'files.directoryTruncated')}
+
)}
)
}
+ const { entry } = row
+ const expanded = expandedPaths.has(entry.path)
+ const changed = changedByPath.get(entry.path)
+ if (entry.type === 'directory') {
+ return (
+
+ toggleDirectory(entry.path)}
+ type="button"
+ >
+ {expanded ? (
+
+ ) : (
+
+ )}
+ {expanded ? : }
+ {entry.name}
+
+ {entryMenu(entry)}
+
+ )
+ }
return (
-
+
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('.workspace-files__files-view') ?? tree.closest('.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 (
@@ -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 => (
@@ -562,8 +621,16 @@ export function WorkspaceFilesPanel({
) : errors[browsedPath] && !root ? null : root?.entries.length ? (
<>
-
- {root.entries.map((entry) => renderEntry(entry))}
+
+ {treeWindow.segments.map((segment) => {
+ if (segment.kind === 'spacer') {
+ return
+ }
+ const row = treeRows[segment.index]!
+ return renderTreeRow(row, treeWindow.windowed
+ ? { 'data-workspace-file-row': row.key, ref: treeWindow.rowRef(row.key) }
+ : {})
+ })}
{root.truncated && (
diff --git a/src/renderer/src/WorkspaceFilesPanel.window.test.tsx b/src/renderer/src/WorkspaceFilesPanel.window.test.tsx
new file mode 100644
index 00000000..8dd08061
--- /dev/null
+++ b/src/renderer/src/WorkspaceFilesPanel.window.test.tsx
@@ -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('.workspace-files__files-view')!
+const tree = (): HTMLElement => document.querySelector('.workspace-files__tree')!
+const rows = (): string[] => [...tree().querySelectorAll('.workspace-files__row')].map((row) => row.textContent ?? '')
+const fileRow = (directory: string, index: number): HTMLElement | null =>
+ tree().querySelector(`.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(
)
+ // 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)
+})
+
diff --git a/src/renderer/src/knowledge-workspace/DocumentsView.tsx b/src/renderer/src/knowledge-workspace/DocumentsView.tsx
index abc17587..47b8de3d 100644
--- a/src/renderer/src/knowledge-workspace/DocumentsView.tsx
+++ b/src/renderer/src/knowledge-workspace/DocumentsView.tsx
@@ -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(null)
const [viewedDocumentId, setViewedDocumentId] = useState()
const previewBackRef = useRef(null)
+ const tableBodyRef = useRef(null)
+ const [previewReturnKey, setPreviewReturnKey] = useState()
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('.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({
) : (
-
+
-
+
{t('documents.table.columns.document')}
@@ -520,10 +540,18 @@ export function DocumentsView({
-
- {rows.map(({ key, source, document, grouped }) => {
+
+ {documentWindow.segments.map((segment) => {
+ if (segment.kind === 'spacer') return (
+
+ )
+ 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 (
-
+
@@ -563,7 +591,7 @@ export function DocumentsView({
(task.status === 'queued' || task.status === 'running')
)
return (
-
+
{document.name}
{(document.path || (source?.kind === 'url' && source.location)) && (
@@ -640,6 +668,7 @@ export function DocumentsView({
{document.resultId && {
const scroll = event.currentTarget.closest('.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')} }
diff --git a/src/renderer/src/knowledge-workspace/DocumentsView.window.test.tsx b/src/renderer/src/knowledge-workspace/DocumentsView.window.test.tsx
new file mode 100644
index 00000000..375a532e
--- /dev/null
+++ b/src/renderer/src/knowledge-workspace/DocumentsView.window.test.tsx
@@ -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 }) => Preview {resultId}
+}))
+
+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('.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(`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(
+
+
+
+ )
+ 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')
+ })
+})
diff --git a/src/renderer/src/list-window-test-layout.ts b/src/renderer/src/list-window-test-layout.ts
new file mode 100644
index 00000000..369a48a3
--- /dev/null
+++ b/src/renderer/src/list-window-test-layout.ts
@@ -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()
+
+ 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()
+ 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)
+ }
+ },
+ }
+}
diff --git a/src/renderer/src/magic-notes-panel.css b/src/renderer/src/magic-notes-panel.css
index 880718b5..02f7aed4 100644
--- a/src/renderer/src/magic-notes-panel.css
+++ b/src/renderer/src/magic-notes-panel.css
@@ -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; }
diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css
index a556a50d..64e2b83b 100644
--- a/src/renderer/src/styles.css
+++ b/src/renderer/src/styles.css
@@ -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;
diff --git a/src/renderer/src/use-conversation-list-window.ts b/src/renderer/src/use-conversation-list-window.ts
index 2654ec7f..adfc7983 100644
--- a/src/renderer/src/use-conversation-list-window.ts
+++ b/src/renderer/src/use-conversation-list-window.ts
@@ -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()
- 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(undefined)
- const [seenScope, setSeenScope] = useState(scope)
- if (seenScope !== scope) {
- setSeenScope(scope)
- setAnchorTop(0)
- }
-
- const layoutRef = useRef(undefined)
- const rowElements = useRef(new Map())
- const rowRefCallbacks = useRef(new Map void>())
- const observerRef = useRef(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(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('[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): void => {
- const row = event.target instanceof Element
- ? event.target.closest('[data-conversation-window-row]')
- : null
- setFocusedId(row?.dataset.conversationWindowRow)
- }, [])
- const onBlur = useCallback((event: React.FocusEvent): 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
}
diff --git a/src/renderer/src/use-grid-columns.ts b/src/renderer/src/use-grid-columns.ts
new file mode 100644
index 00000000..8650e52b
--- /dev/null
+++ b/src/renderer/src/use-grid-columns.ts
@@ -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,
+ enabled: boolean,
+ layoutKey = '',
+): GridTrackMetrics {
+ const [metrics, setMetrics] = useState({ 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 & {
+ itemIds: readonly string[]
+ gridRef: React.RefObject
+ 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()
+ 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 }
+}
diff --git a/src/renderer/src/use-list-window.ts b/src/renderer/src/use-list-window.ts
new file mode 100644
index 00000000..f94dbedf
--- /dev/null
+++ b/src/renderer/src/use-list-window.ts
@@ -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()
+ 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
+ /** The element containing the rows when an ancestor scrolls. */
+ listRef?: React.RefObject
+ /** 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(undefined)
+ const [seenScope, setSeenScope] = useState(scope)
+ if (seenScope !== scope) {
+ setSeenScope(scope)
+ setAnchorTop(0)
+ }
+
+ const layoutRef = useRef(undefined)
+ const rowElements = useRef(new Map())
+ const rowRefCallbacks = useRef(new Map void>())
+ const observerRef = useRef(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(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(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(undefined)
+ const onFocus = useCallback((event: React.FocusEvent): 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(rowSelector) : null
+ const id = row?.getAttribute(rowAttribute) ?? undefined
+ lastFocusedRef.current = id
+ setFocusedId(id)
+ }, [rowAttribute, rowSelector])
+ const onBlur = useCallback((event: React.FocusEvent): 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
+}
diff --git a/tests/support/workspace-files-layout-driver.mjs b/tests/support/workspace-files-layout-driver.mjs
index a7d0543c..7bdaf49b 100644
--- a/tests/support/workspace-files-layout-driver.mjs
+++ b/tests/support/workspace-files-layout-driver.mjs
@@ -81,7 +81,10 @@ app.whenReady().then(async () => {
const name = `${git ? 'git' : 'non-git'}-${theme}-${width}x${height}`
win.setContentSize(width, height)
await win.loadURL(`${process.env.GB_LAYOUT_URL}?git=${git}&theme=${theme}&sidebar=${sidebar}`)
- await wait(`document.querySelectorAll('.workspace-files__row').length===121 && !!${viewport}`)
+ // The tree is windowed (P5): only rows near the viewport are mounted.
+ await wait(`document.querySelectorAll('.workspace-files__row').length>0 && !!${viewport}`)
+ await settle()
+ assert(await js(`document.querySelectorAll('.workspace-files__row').length<121`), 'Long files tree must be windowed')
await js('document.fonts.ready')
await settle()
assert.equal(await js('document.querySelectorAll(".workspace-files__view-switch button").length'), git ? 2 : 1)
@@ -103,15 +106,20 @@ app.whenReady().then(async () => {
await click('.workspace-files__entry .workspace-files__more')
await wait('!!document.querySelector("[role=menuitem]")')
await click('[role=menuitem]:first-child')
- await wait('document.querySelectorAll(".workspace-files__row").length===120 && !!document.querySelector(".workspace-files__breadcrumbs")')
+ await wait('!!document.querySelector(".workspace-files__breadcrumbs [aria-current=location]") && document.querySelectorAll(".workspace-files__row").length>0')
const before = await geometry()
assert.equal(await js('document.querySelector(".workspace-files__breadcrumbs [aria-current=location]").textContent'), 'documents')
- await js(`${viewport}.scrollTop=${viewport}.scrollHeight`)
- await settle()
+ const last = '.workspace-files__entry:last-child .workspace-files__row'
+ // Scrolling to the end re-windows; the measured row heights may grow the
+ // list, so keep scrolling until the last file is mounted at the bottom.
+ for (let i = 0; i < 10; i++) {
+ await js(`${viewport}.scrollTop=${viewport}.scrollHeight`)
+ await settle()
+ if (await js(`document.querySelector(${JSON.stringify(last)})?.title==='documents/file-120-workspace-notes.md' && Math.abs(${viewport}.scrollHeight-${viewport}.clientHeight-${viewport}.scrollTop)<=1`)) break
+ }
const after = await geometry()
assert(after.scrollTop > 0)
docked(before, after)
- const last = '.workspace-files__entry:last-child .workspace-files__row'
assert(await js(`(() => {const r=document.querySelector(${JSON.stringify(last)}).getBoundingClientRect(),v=${viewport}.getBoundingClientRect();return r.top>=v.top && r.bottom<=v.bottom+1})()`), 'Last file must be fully visible')
assert.equal(await js(`document.querySelector(${JSON.stringify(last)}).title`), 'documents/file-120-workspace-notes.md')
await screenshot(name)