diff --git a/apps/web/src/components/DesignFilesPanel.tsx b/apps/web/src/components/DesignFilesPanel.tsx index 2e6cdc828..7b0d00e1b 100644 --- a/apps/web/src/components/DesignFilesPanel.tsx +++ b/apps/web/src/components/DesignFilesPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, useTransition } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useT } from '../i18n'; import type { Dict } from '../i18n/types'; import { projectFileUrl } from '../providers/registry'; @@ -23,26 +23,9 @@ interface Props { onNewSketch: () => void; } -type Section = 'pages' | 'scripts' | 'images' | 'sketches' | 'other'; +type SortKey = 'name' | 'kind' | 'mtime'; +type SortDir = 'asc' | 'desc'; -const SECTION_LABEL_KEY: Record = { - pages: 'designFiles.sectionPages', - scripts: 'designFiles.sectionScripts', - images: 'designFiles.sectionImages', - sketches: 'designFiles.sectionSketches', - other: 'designFiles.sectionOther', -}; - -const SECTION_ORDER: Section[] = ['pages', 'sketches', 'scripts', 'images', 'other']; -const INITIAL_SECTION_FILE_LIMIT = 30; -const SECTION_FILE_LIMIT_INCREMENT = 200; - -/** - * Full-panel browser for a project's `.od/projects//` folder. Mirrors - * Claude Design's "Design Files" surface: grouped sections, hover-revealed - * row menu, drop-files footer, and (when a row is selected) a right-side - * preview pane. Triggered as a sticky first tab in FileWorkspace. - */ export function DesignFilesPanel({ projectId, files, @@ -66,30 +49,42 @@ export function DesignFilesPanel({ const MENU_ESTIMATED_HEIGHT = 115; const MENU_SAFE_PADDING = 8; const [preview, setPreview] = useState(null); - const [sectionLimits, setSectionLimits] = useState>>({}); - const [isSectionExpansionPending, startSectionExpansion] = useTransition(); const [selected, setSelected] = useState>(new Set()); + const [sortKey, setSortKey] = useState('mtime'); + const [sortDir, setSortDir] = useState('desc'); + const lastKeyPress = useRef>(new Map()); const [deleting, setDeleting] = useState(false); - const grouped = useMemo(() => { - const groups: Record = { - pages: [], - sketches: [], - scripts: [], - images: [], - other: [], - }; - const sorted = [...files].sort((a, b) => b.mtime - a.mtime); - for (const f of sorted) { - groups[sectionFor(f)].push(f); - } - return groups; - }, [files]); + const sortedFiles = useMemo(() => { + return [...files].sort((a, b) => { + let cmp: number; + if (sortKey === 'name') cmp = a.name.localeCompare(b.name); + else if (sortKey === 'kind') cmp = kindSortPriority(a.kind) - kindSortPriority(b.kind); + else cmp = a.mtime - b.mtime; + return sortDir === 'asc' ? cmp : -cmp; + }); + }, [files, sortKey, sortDir]); + + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(30); + + const effectivePageSize = pageSize === 'all' ? Math.max(1, sortedFiles.length) : pageSize; + const totalPages = Math.max(1, Math.ceil(sortedFiles.length / effectivePageSize)); + const safePage = Math.min(page, totalPages - 1); + const pageFiles = sortedFiles.slice(safePage * effectivePageSize, (safePage + 1) * effectivePageSize); + const rangeStart = safePage * effectivePageSize + 1; + const rangeEnd = Math.min((safePage + 1) * effectivePageSize, sortedFiles.length); + const allPageSelected = pageFiles.every((f) => selected.has(f.name)); + const somePageSelected = !allPageSelected && pageFiles.some((f) => selected.has(f.name)); + + useEffect(() => { + setPage(0); + }, [pageSize]); + + useEffect(() => { + if (Number.isFinite(totalPages)) setPage((p) => Math.min(p, totalPages - 1)); + }, [totalPages]); - // Prune selections that no longer exist in the current file list - // (e.g. after a refresh or delete within the same project). - // Cross-project leaks are handled by the parent remounting this - // component via key={projectId}. useEffect(() => { setSelected((prev) => { if (prev.size === 0) return prev; @@ -111,7 +106,6 @@ export function DesignFilesPanel({ [preview, files], ); - // Close the row menu on outside click / escape. useEffect(() => { if (!menuPos) return; const close = () => setMenuPos(null); @@ -135,6 +129,17 @@ export function DesignFilesPanel({ } } + function toggleSort(key: SortKey) { + return () => { + if (sortKey === key) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortKey(key); + setSortDir('asc'); + } + }; + } + function toggleSelect(name: string) { setSelected((prev) => { const next = new Set(prev); @@ -147,20 +152,49 @@ export function DesignFilesPanel({ }); } - function selectAllInSection(sectionFiles: ProjectFile[]) { + function toggleSelectPage() { setSelected((prev) => { const next = new Set(prev); - for (const f of sectionFiles) next.add(f.name); + if (allPageSelected) { + for (const f of pageFiles) next.delete(f.name); + } else { + for (const f of pageFiles) next.add(f.name); + } return next; }); } - function clearSection(sectionFiles: ProjectFile[]) { - setSelected((prev) => { - const next = new Set(prev); - for (const f of sectionFiles) next.delete(f.name); - return next; - }); + function selectAllFiles() { + setSelected(new Set(sortedFiles.map((f) => f.name))); + } + + function clearSelection() { + setSelected(new Set()); + } + + function openMenuFor(name: string, el: HTMLElement) { + const rect = el.closest('.df-row-menu')?.getBoundingClientRect(); + if (!rect) return; + + const viewportHeight = window.innerHeight; + const spaceBelow = viewportHeight - rect.bottom; + const spaceAbove = rect.top; + + let top: number; + if (spaceBelow >= MENU_ESTIMATED_HEIGHT + MENU_SAFE_PADDING) { + top = rect.bottom + 4; + } else if (spaceAbove >= MENU_ESTIMATED_HEIGHT + MENU_SAFE_PADDING) { + top = rect.top - MENU_ESTIMATED_HEIGHT - 4; + } else { + top = Math.max( + MENU_SAFE_PADDING, + viewportHeight - MENU_ESTIMATED_HEIGHT - MENU_SAFE_PADDING, + ); + } + + const left = Math.max(MENU_SAFE_PADDING, rect.right - 160); + + setMenuPos({ name, top, left }); } async function handleBatchDelete() { @@ -228,15 +262,6 @@ export function DesignFilesPanel({
- - {sectionFiles.some((f) => selected.has(f.name)) ? ( - - ) : null} -
- {visibleFiles.map((f) => { - const active = preview === f.name; - const isHovered = hover === f.name; - return ( + {selected.size < sortedFiles.length ? ( + + ) : null} + {selected.size > 0 ? ( + + ) : null} +
+
- ); - })} - {hiddenCount > 0 ? ( + +
+
+ + + + + + + + + + + {pageFiles.map((f) => { + const active = preview === f.name; + const isHovered = hover === f.name; + return ( + setHover(f.name)} + onMouseLeave={() => setHover((c) => (c === f.name ? null : c))} + > + + + + + + + + ); + })} + +
+ { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleSelectPage(); + } + }} + ref={(el) => { + if (el) (el as HTMLElement).ariaChecked = allPageSelected ? 'true' : somePageSelected ? 'mixed' : 'false'; + }} + > + {allPageSelected ? '\u2611' : somePageSelected ? '\u25A3' : '\u2610'} + + + + + + + + + +
+ { + e.stopPropagation(); + toggleSelect(f.name); + }} + role="checkbox" + aria-checked={selected.has(f.name)} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + toggleSelect(f.name); + } + }} + > + {selected.has(f.name) ? '\u2611' : '\u2610'} + + + + {kindGlyph(f.kind)} + + + + + {kindLabel(f.kind, t)} + {relativeTime(f.mtime, t)} + { + e.stopPropagation(); + openMenuFor(f.name, e.target as HTMLElement); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + openMenuFor(f.name, e.currentTarget as HTMLElement); + } + }} + > + ⋯ + +
+
- ) : null} -
- ); - })} + + + + {t('designFiles.pageInfo', { start: rangeStart, end: rangeEnd, total: sortedFiles.length })} + + + + ) : null} )}
{ + const kind = kinds[i % kinds.length]; + return { + name: `file-${i + 1}.${extForKind(kind!)}`, + kind: kind!, + size: 1024 * (i + 1), + mtime: Date.now() - i * 60_000, + mime: 'text/plain', + }; + }); +} + +function renderPanel(files: ProjectFile[]) { + return render( + , + ); +} + +function getPageInfo(container: HTMLElement): string { + const el = container.querySelector('.df-page-info'); + return el?.textContent?.trim() ?? ''; +} + +/** page-btn order: top-Prev=0, top-Next=1, bottom-Prev=2, bottom-Next=3 */ +function getPageBtns(container: HTMLElement) { + return Array.from(container.querySelectorAll('.df-page-btn')); +} + +function getSelects(container: HTMLElement) { + return Array.from(container.querySelectorAll('select')); +} + +describe('DesignFilesPanel large-list regression', () => { + it('renders only the default page size (30) rows with 500 files', () => { + const files = generateFiles(500); + const { container } = renderPanel(files); + expect(container.querySelectorAll('.df-file-row').length).toBe(30); + }); + + it('shows all 500 rows when page size is set to All', () => { + const files = generateFiles(500); + const { container } = renderPanel(files); + + const selects = getSelects(container); + fireEvent.change(selects[0]!, { target: { value: 'all' } }); + + expect(container.querySelectorAll('.df-file-row').length).toBe(500); + }); + + it('shows 60 rows when page size is changed to 60', () => { + const files = generateFiles(500); + const { container } = renderPanel(files); + + const selects = getSelects(container); + fireEvent.change(selects[0]!, { target: { value: '60' } }); + + expect(container.querySelectorAll('.df-file-row').length).toBe(60); + }); + + it('navigates pages with Next button and updates row content', () => { + const files = generateFiles(500); + const { container } = renderPanel(files); + + expect(container.querySelectorAll('.df-file-row').length).toBe(30); + expect(container.querySelector('.df-file-row')!.textContent).toContain('file-1'); + + const btns = getPageBtns(container); + fireEvent.click(btns[1]!); + + expect(container.querySelectorAll('.df-file-row').length).toBe(30); + expect(container.querySelector('.df-file-row')!.textContent).toContain('file-31'); + }); + + it('shows disabled Previous on first page and Next on last page', () => { + const files = generateFiles(45); + const { container } = renderPanel(files); + + const btns = getPageBtns(container); + expect(btns[0]!.disabled).toBe(true); + expect(btns[1]!.disabled).toBe(false); + + fireEvent.click(btns[1]!); + const btns2 = getPageBtns(container); + expect(btns2[0]!.disabled).toBe(false); + + fireEvent.click(getPageBtns(container)[1]!); + fireEvent.click(getPageBtns(container)[1]!); + expect(getPageBtns(container)[1]!.disabled).toBe(true); + }); + + it('jumps to a specific page via page dropdown at bottom', () => { + const files = generateFiles(200); + const { container } = renderPanel(files); + + const selects = getSelects(container); + fireEvent.change(selects[1]!, { target: { value: '3' } }); + + expect(container.querySelector('.df-file-row')!.textContent).toContain('file-91'); + }); + + it('updates page info text when navigating', () => { + const files = generateFiles(500); + const { container } = renderPanel(files); + + expect(getPageInfo(container)).toContain('1–30 of 500'); + + const btns = getPageBtns(container); + fireEvent.click(btns[1]!); + + expect(getPageInfo(container)).toContain('31–60 of 500'); + }); + + it('renders 500 files within a reasonable time', () => { + const files = generateFiles(500); + const start = performance.now(); + renderPanel(files); + const elapsed = performance.now() - start; + expect(elapsed).toBeLessThan(2000); + }); +}); diff --git a/e2e/ui/app.test.ts b/e2e/ui/app.test.ts index 3934cce70..033ce8cac 100644 --- a/e2e/ui/app.test.ts +++ b/e2e/ui/app.test.ts @@ -1157,12 +1157,13 @@ async function runDesignFilesUploadFlow( hasText: 'moodboard.png', }); await expect(fileRow).toBeVisible(); - await fileRow.click(); + const nameBtn = fileRow.getByRole('button').first(); + await nameBtn.click(); const preview = page.getByTestId('design-file-preview'); await expect(preview).toBeVisible(); await expect(preview.getByText(/moodboard\.png/i)).toBeVisible(); - await fileRow.dblclick(); + await nameBtn.dblclick(); await expect(page.getByRole('tab', { name: /moodboard\.png/i })).toBeVisible(); }