perf: window the notes grid, workspace file tree and knowledge documents table
This commit is contained in:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ import type { MagicCanvasContentHandle } from './MagicCanvasContent'
|
|||||||
import { MagicCanvasThumbnail } from './MagicCanvasThumbnail'
|
import { MagicCanvasThumbnail } from './MagicCanvasThumbnail'
|
||||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||||
|
import { useGridListWindow } from './use-grid-columns'
|
||||||
import type { AppNotificationInput } from './notifications'
|
import type { AppNotificationInput } from './notifications'
|
||||||
import {
|
import {
|
||||||
EmptyState,
|
EmptyState,
|
||||||
@@ -460,6 +461,10 @@ export function MagicNotesWorkspace({
|
|||||||
const [libraryView, setLibraryView] = useState<LibraryView>('notes')
|
const [libraryView, setLibraryView] = useState<LibraryView>('notes')
|
||||||
const [detailView, setDetailView] = useState<'notes'>()
|
const [detailView, setDetailView] = useState<'notes'>()
|
||||||
const overviewFocusRef = useRef('')
|
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 [todoFilter, setTodoFilter] = useState<TodoFilter>('active')
|
||||||
const [loadedCommentMode, setCommentMode] =
|
const [loadedCommentMode, setCommentMode] =
|
||||||
useState<MagicNoteCommentMode>('immediate')
|
useState<MagicNoteCommentMode>('immediate')
|
||||||
@@ -1202,12 +1207,16 @@ export function MagicNotesWorkspace({
|
|||||||
}
|
}
|
||||||
if (target.kind === 'create-note') {
|
if (target.kind === 'create-note') {
|
||||||
overviewFocusRef.current = 'magic-note-new'
|
overviewFocusRef.current = 'magic-note-new'
|
||||||
|
setOverviewReturnNoteId('')
|
||||||
void createNote(target.title, true)
|
void createNote(target.title, true)
|
||||||
.then(() => focusSwitchTarget(target))
|
.then(() => focusSwitchTarget(target))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setDeletingNote(false)
|
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')
|
setDetailView('notes')
|
||||||
focusSwitchTarget(target)
|
focusSwitchTarget(target)
|
||||||
void loadDetail(target.noteId, target.entryId)
|
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.
|
// Overview shows every note full width; the split list appears only beside an open note.
|
||||||
const notesExpanded = isNarrowWorkspace ? !detailView : notesPaneOpen || !detailView
|
const notesExpanded = isNarrowWorkspace ? !detailView : notesPaneOpen || !detailView
|
||||||
const notesSplit = libraryView === 'notes' && Boolean(detailView) && !isNarrowWorkspace && notesPaneOpen
|
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 todoPaneWidthLimits = { minimum: 240, maximum: Math.max(240, todoLayoutWidth - 301) }
|
||||||
const displayedTodoWidth = clampMagicNotesPaneWidth(todoPaneWidth, todoPaneWidthLimits)
|
const displayedTodoWidth = clampMagicNotesPaneWidth(todoPaneWidth, todoPaneWidthLimits)
|
||||||
const indexExpanded = isNarrowLayout ? narrowIndexOpen : indexPaneOpen
|
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 (
|
return (
|
||||||
<div className="magic-notes-page">
|
<div className="magic-notes-page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -2377,7 +2459,15 @@ export function MagicNotesWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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' ? (
|
{loadStatus === 'loading' ? (
|
||||||
<p className="magic-notes-muted">
|
<p className="magic-notes-muted">
|
||||||
{t('status.loadingNotes')}
|
{t('status.loadingNotes')}
|
||||||
@@ -2400,60 +2490,13 @@ export function MagicNotesWorkspace({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
visibleNotes.map((note) => (
|
noteWindow.windowed ? noteWindow.segments.map((segment) => segment.kind === 'spacer' ? (
|
||||||
<div className="magic-note-row" key={note.id}>
|
<div aria-hidden="true" className="magic-notes-list__spacer" key={`spacer:${segment.key}`} style={{ height: `${segment.height}px` }} />
|
||||||
<button
|
) : (
|
||||||
id={`magic-note-select-${note.id}`}
|
<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` }}>
|
||||||
className="magic-note-list-item"
|
{visibleNotes.slice(segment.start, segment.end).map((note, offset) => renderNoteCard(note, segment.start + offset))}
|
||||||
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>
|
</div>
|
||||||
))
|
)) : visibleNotes.map((note, index) => renderNoteCard(note, index))
|
||||||
)}
|
)}
|
||||||
{actionNote && createPortal(
|
{actionNote && createPortal(
|
||||||
<div
|
<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))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -30,6 +30,7 @@ import { createPortal } from 'react-dom'
|
|||||||
import { SegmentedControl } from './WorkspacePrimitives'
|
import { SegmentedControl } from './WorkspacePrimitives'
|
||||||
import { WorkspaceActionDialog } from './WorkspaceActionDialog'
|
import { WorkspaceActionDialog } from './WorkspaceActionDialog'
|
||||||
import { WorkspaceGitTools } from './WorkspaceGitTools'
|
import { WorkspaceGitTools } from './WorkspaceGitTools'
|
||||||
|
import { useListWindow } from './use-list-window'
|
||||||
import type {
|
import type {
|
||||||
WorkspaceChangedFile,
|
WorkspaceChangedFile,
|
||||||
WorkspaceChanges,
|
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)}</>
|
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({
|
export function WorkspaceFilesPanel({
|
||||||
projectId,
|
projectId,
|
||||||
rootPath,
|
rootPath,
|
||||||
@@ -150,6 +187,7 @@ export function WorkspaceFilesPanel({
|
|||||||
const activeView = showGit ? view : 'files'
|
const activeView = showGit ? view : 'files'
|
||||||
const [selected, setSelected] = useState<{ projectId?: string; path: string }>()
|
const [selected, setSelected] = useState<{ projectId?: string; path: string }>()
|
||||||
const panelRef = useRef<HTMLDivElement>(null)
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const treeRef = useRef<HTMLDivElement>(null)
|
||||||
const refreshRef = useRef<HTMLButtonElement>(null)
|
const refreshRef = useRef<HTMLButtonElement>(null)
|
||||||
const backRef = useRef<HTMLButtonElement>(null)
|
const backRef = useRef<HTMLButtonElement>(null)
|
||||||
const returnContext = useRef<{ scrollTop: number; scrollLeft: number; trigger: HTMLButtonElement } | undefined>(undefined)
|
const returnContext = useRef<{ scrollTop: number; scrollLeft: number; trigger: HTMLButtonElement } | undefined>(undefined)
|
||||||
@@ -287,8 +325,9 @@ export function WorkspaceFilesPanel({
|
|||||||
[changedFiles]
|
[changedFiles]
|
||||||
)
|
)
|
||||||
const emptyPaths = useMemo(() => new Set<string>(), [])
|
const emptyPaths = useMemo(() => new Set<string>(), [])
|
||||||
const listings =
|
const emptyRecord = useMemo(() => ({}), [])
|
||||||
listingState.projectId === projectId ? listingState.value : {}
|
const listings: Record<string, WorkspaceDirectoryListing> =
|
||||||
|
listingState.projectId === projectId ? listingState.value : emptyRecord
|
||||||
const expandedPaths =
|
const expandedPaths =
|
||||||
expandedState.projectId === projectId
|
expandedState.projectId === projectId
|
||||||
? expandedState.value
|
? expandedState.value
|
||||||
@@ -297,7 +336,7 @@ export function WorkspaceFilesPanel({
|
|||||||
loadingState.projectId === projectId
|
loadingState.projectId === projectId
|
||||||
? loadingState.value
|
? loadingState.value
|
||||||
: emptyPaths
|
: 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] ? (
|
const renderDirectoryError = (path: string): React.JSX.Element | null => errors[path] ? (
|
||||||
<p className="workspace-files__error" role="alert">
|
<p className="workspace-files__error" role="alert">
|
||||||
{errors[path]}
|
{errors[path]}
|
||||||
@@ -368,55 +407,50 @@ export function WorkspaceFilesPanel({
|
|||||||
}}><MoreHorizontal size={14} aria-hidden="true" /></button>
|
}}><MoreHorizontal size={14} aria-hidden="true" /></button>
|
||||||
)
|
)
|
||||||
|
|
||||||
const renderEntry = (
|
const renderTreeRow = (
|
||||||
entry: WorkspaceDirectoryEntry
|
row: FileTreeRow,
|
||||||
|
rowProps: Record<string, unknown>
|
||||||
): React.JSX.Element => {
|
): React.JSX.Element => {
|
||||||
const expanded = expandedPaths.has(entry.path)
|
// Nesting is drawn by indentation; the rows themselves are flat, so they
|
||||||
const listing = listings[entry.path]
|
// keep their identity whether or not the tree is windowed.
|
||||||
const changed = changedByPath.get(entry.path)
|
const indent = row.depth ? { marginLeft: `calc(${row.depth} * var(--space-4))` } : undefined
|
||||||
if (entry.type === 'directory') {
|
if (row.kind === 'status') {
|
||||||
return (
|
return (
|
||||||
<div key={entry.path}>
|
<div key={row.key} style={indent} {...rowProps}>
|
||||||
<div className="workspace-files__entry">
|
{row.status === 'error' ? renderDirectoryError(row.path) : (
|
||||||
<button
|
<p className="workspace-files__status">
|
||||||
aria-expanded={expanded}
|
{t(row.status === 'loading' ? 'files.reading' : 'files.directoryTruncated')}
|
||||||
className="workspace-files__row"
|
</p>
|
||||||
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>
|
</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 (
|
return (
|
||||||
<div className="workspace-files__entry" key={entry.path}>
|
<div className="workspace-files__entry" key={row.key} style={indent} {...rowProps}>
|
||||||
<button
|
<button
|
||||||
className="workspace-files__row"
|
className="workspace-files__row"
|
||||||
aria-current={selected?.projectId === projectId && selected?.path === entry.path ? 'true' : undefined}
|
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) {
|
if (!projectId) {
|
||||||
return (
|
return (
|
||||||
<p className="assistant-sidebar__empty">
|
<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 rootName = rootPath?.replaceAll('\\', '/').split('/').filter(Boolean).at(-1) || t('files.currentWorkspace')
|
||||||
const renderPatch = (patch: string): React.JSX.Element => (
|
const renderPatch = (patch: string): React.JSX.Element => (
|
||||||
<pre className="assistant-sidebar__diff workspace-files__diff">
|
<pre className="assistant-sidebar__diff workspace-files__diff">
|
||||||
@@ -562,8 +621,16 @@ export function WorkspaceFilesPanel({
|
|||||||
</p>
|
</p>
|
||||||
) : errors[browsedPath] && !root ? null : root?.entries.length ? (
|
) : errors[browsedPath] && !root ? null : root?.entries.length ? (
|
||||||
<>
|
<>
|
||||||
<div className="workspace-files__tree">
|
<div ref={treeRef} className="workspace-files__tree" onBlur={treeWindow.onBlur} onFocus={treeWindow.onFocus}>
|
||||||
{root.entries.map((entry) => renderEntry(entry))}
|
{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>
|
</div>
|
||||||
{root.truncated && (
|
{root.truncated && (
|
||||||
<p className="workspace-files__status">
|
<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'
|
} from 'lucide-react'
|
||||||
import { RemoveSourceDialog } from './RemoveSourceDialog'
|
import { RemoveSourceDialog } from './RemoveSourceDialog'
|
||||||
import { KnowledgeActionsMenu, type KnowledgeAction } from './KnowledgeActionsMenu'
|
import { KnowledgeActionsMenu, type KnowledgeAction } from './KnowledgeActionsMenu'
|
||||||
|
import { useListWindow } from '../use-list-window'
|
||||||
|
import { ListWindowTableSpacer } from '../ListWindowSpacer'
|
||||||
|
|
||||||
const sourceStatusLabelKeys = {
|
const sourceStatusLabelKeys = {
|
||||||
queued: 'sourceStatuses.queued',
|
queued: 'sourceStatuses.queued',
|
||||||
@@ -138,6 +140,8 @@ export function DocumentsView({
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [viewedDocumentId, setViewedDocumentId] = useState<string>()
|
const [viewedDocumentId, setViewedDocumentId] = useState<string>()
|
||||||
const previewBackRef = useRef<HTMLButtonElement>(null)
|
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 previewOriginRef = useRef<{ trigger: HTMLElement; scroll?: HTMLElement; top: number } | undefined>(undefined)
|
||||||
const [urlOpen, setUrlOpen] = useState(false)
|
const [urlOpen, setUrlOpen] = useState(false)
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
@@ -218,6 +222,22 @@ export function DocumentsView({
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}, [documents, sources, locale, query])
|
}, [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 viewedDocument = useMemo(() => documents.find((document) => document.id === viewedDocumentId), [documents, viewedDocumentId])
|
||||||
const viewedSource = sources.find((source) => source.id === viewedDocument?.sourceId)
|
const viewedSource = sources.find((source) => source.id === viewedDocument?.sourceId)
|
||||||
const viewedTaskSourceId = viewedSource && viewedSource.kind !== 'directory' &&
|
const viewedTaskSourceId = viewedSource && viewedSource.kind !== 'directory' &&
|
||||||
@@ -500,9 +520,9 @@ export function DocumentsView({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="knowledge-documents__table-scroll">
|
<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>
|
<thead>
|
||||||
<tr>
|
<tr aria-rowindex={documentWindow.windowed ? 1 : undefined}>
|
||||||
<th>
|
<th>
|
||||||
{t('documents.table.columns.document')}
|
{t('documents.table.columns.document')}
|
||||||
</th>
|
</th>
|
||||||
@@ -520,10 +540,18 @@ export function DocumentsView({
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody ref={tableBodyRef} onBlur={documentWindow.onBlur} onFocus={documentWindow.onFocus}>
|
||||||
{rows.map(({ key, source, document, grouped }) => {
|
{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 (
|
if (!document && source) return (
|
||||||
<tr key={key} className="knowledge-source-group">
|
<tr key={key} className="knowledge-source-group" {...rowProps}>
|
||||||
<td colSpan={5}>
|
<td colSpan={5}>
|
||||||
<div className="knowledge-source-row">
|
<div className="knowledge-source-row">
|
||||||
<div className="knowledge-source-row__main">
|
<div className="knowledge-source-row__main">
|
||||||
@@ -563,7 +591,7 @@ export function DocumentsView({
|
|||||||
(task.status === 'queued' || task.status === 'running')
|
(task.status === 'queued' || task.status === 'running')
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<tr key={key} className={grouped ? 'knowledge-document-row--grouped' : undefined}>
|
<tr key={key} className={grouped ? 'knowledge-document-row--grouped' : undefined} {...rowProps}>
|
||||||
<td>
|
<td>
|
||||||
<strong>{document.name}</strong>
|
<strong>{document.name}</strong>
|
||||||
{(document.path || (source?.kind === 'url' && source.location)) && (
|
{(document.path || (source?.kind === 'url' && source.location)) && (
|
||||||
@@ -640,6 +668,7 @@ export function DocumentsView({
|
|||||||
{document.resultId && <button type="button" className="secondary-button" onClick={(event) => {
|
{document.resultId && <button type="button" className="secondary-button" onClick={(event) => {
|
||||||
const scroll = event.currentTarget.closest<HTMLElement>('.workspace-panel-scroll') ?? undefined
|
const scroll = event.currentTarget.closest<HTMLElement>('.workspace-panel-scroll') ?? undefined
|
||||||
previewOriginRef.current = { trigger: event.currentTarget, scroll, top: scroll?.scrollTop ?? 0 }
|
previewOriginRef.current = { trigger: event.currentTarget, scroll, top: scroll?.scrollTop ?? 0 }
|
||||||
|
setPreviewReturnKey(key)
|
||||||
setViewedDocumentId(document.id)
|
setViewedDocumentId(document.id)
|
||||||
requestAnimationFrame(() => { previewBackRef.current?.focus(); if (scroll) scroll.scrollTop = 0 })
|
requestAnimationFrame(() => { previewBackRef.current?.focus(); if (scroll) scroll.scrollTop = 0 })
|
||||||
}}>{t('documents.viewResult')}</button>}
|
}}>{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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,3 +30,10 @@
|
|||||||
.assistant-sidebar__body.assistant-sidebar__body--notes { overflow: hidden; padding: 0; display: flex; flex-direction: column; }
|
.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; }
|
.assistant-sidebar__body--notes > .magic-note-panel { flex: 1; }
|
||||||
.magic-note-panel .magic-note-panel__footer textarea { min-height: 80px; max-height: 180px; }
|
.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; }
|
||||||
|
|||||||
@@ -15302,6 +15302,15 @@ textarea.field-control {
|
|||||||
overflow-wrap: anywhere;
|
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 {
|
.knowledge-documents__table-scroll .knowledge-document-metric {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -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).
|
* Windowing for the sidebar conversation list (PERF-14), on the shared
|
||||||
*
|
* list-window hook. The list scrolls itself, is top-anchored, and returns to
|
||||||
* Long lists mount only the rows near the viewport; spacers stand in for the
|
* the top when the project or search changes. Rows that hold user state
|
||||||
* rest, sized from measured row heights (or the mean measured height for rows
|
* (focus, open menu, rename, the active conversation) stay mounted at their
|
||||||
* never rendered). The list is top-anchored like any normal list. Rows that
|
* real position even when scrolled away.
|
||||||
* 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). */
|
/** 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. */
|
/** 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). */
|
/** Upper bound for rows mounted from the range (kept rows come on top). */
|
||||||
export const maxRenderedConversationRows = 120
|
export const maxRenderedConversationRows = defaultMaxRenderedRows
|
||||||
const fallbackViewportHeight = 800
|
|
||||||
|
|
||||||
export function conversationListOverscan(viewportHeight: number): number {
|
export const conversationListOverscan = listWindowOverscan
|
||||||
return Math.max(320, Math.round(viewportHeight))
|
export const conversationWindowRange = listWindowRange
|
||||||
}
|
export type ConversationListSegment = ListWindowSegment
|
||||||
|
|
||||||
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 function useConversationListWindow({
|
export function useConversationListWindow({
|
||||||
ids,
|
ids,
|
||||||
@@ -109,202 +43,13 @@ export function useConversationListWindow({
|
|||||||
keepIds: readonly (string | undefined)[]
|
keepIds: readonly (string | undefined)[]
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
}) {
|
}) {
|
||||||
const windowed = enabled && ids.length >= conversationListWindowThreshold
|
return useListWindow({
|
||||||
const [heights] = useState(() => new ConversationRowHeights())
|
ids,
|
||||||
const [measureVersion, setMeasureVersion] = useState(0)
|
scope,
|
||||||
// scrollTop at the last re-window; the range is derived from it.
|
scrollRef,
|
||||||
const [anchorTop, setAnchorTop] = useState(0)
|
keepIds,
|
||||||
const [focusedId, setFocusedId] = useState<string | undefined>(undefined)
|
enabled,
|
||||||
const [seenScope, setSeenScope] = useState(scope)
|
rowAttribute: 'data-conversation-window-row',
|
||||||
if (seenScope !== scope) {
|
resetScrollOnScopeChange: true,
|
||||||
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
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -81,7 +81,10 @@ app.whenReady().then(async () => {
|
|||||||
const name = `${git ? 'git' : 'non-git'}-${theme}-${width}x${height}`
|
const name = `${git ? 'git' : 'non-git'}-${theme}-${width}x${height}`
|
||||||
win.setContentSize(width, height)
|
win.setContentSize(width, height)
|
||||||
await win.loadURL(`${process.env.GB_LAYOUT_URL}?git=${git}&theme=${theme}&sidebar=${sidebar}`)
|
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 js('document.fonts.ready')
|
||||||
await settle()
|
await settle()
|
||||||
assert.equal(await js('document.querySelectorAll(".workspace-files__view-switch button").length'), git ? 2 : 1)
|
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 click('.workspace-files__entry .workspace-files__more')
|
||||||
await wait('!!document.querySelector("[role=menuitem]")')
|
await wait('!!document.querySelector("[role=menuitem]")')
|
||||||
await click('[role=menuitem]:first-child')
|
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()
|
const before = await geometry()
|
||||||
assert.equal(await js('document.querySelector(".workspace-files__breadcrumbs [aria-current=location]").textContent'), 'documents')
|
assert.equal(await js('document.querySelector(".workspace-files__breadcrumbs [aria-current=location]").textContent'), 'documents')
|
||||||
await js(`${viewport}.scrollTop=${viewport}.scrollHeight`)
|
const last = '.workspace-files__entry:last-child .workspace-files__row'
|
||||||
await settle()
|
// 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()
|
const after = await geometry()
|
||||||
assert(after.scrollTop > 0)
|
assert(after.scrollTop > 0)
|
||||||
docked(before, after)
|
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(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')
|
assert.equal(await js(`document.querySelector(${JSON.stringify(last)}).title`), 'documents/file-120-workspace-notes.md')
|
||||||
await screenshot(name)
|
await screenshot(name)
|
||||||
|
|||||||
Reference in New Issue
Block a user