Improve design files grouping (#1082)

Add a modified-date grouping mode to make busy design workspaces easier to scan as generated files accumulate. The new view keeps existing batch actions and pagination available, adds localized labels, and covers date boundaries with component tests.
This commit is contained in:
soulme 2026-05-10 11:55:34 +08:00 committed by GitHub
parent bb578b3dca
commit cbb3c0e33a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 774 additions and 156 deletions

View file

@ -24,9 +24,32 @@ interface Props {
onNewSketch: () => void;
}
type DesignFilesGroupMode = 'kind' | 'modified';
type ModifiedSection = 'today' | 'yesterday' | 'previous7Days' | 'previous30Days' | 'older';
type SortKey = 'name' | 'kind' | 'mtime';
type SortDir = 'asc' | 'desc';
const MODIFIED_SECTION_ORDER: ModifiedSection[] = [
'today',
'yesterday',
'previous7Days',
'previous30Days',
'older',
];
const MODIFIED_SECTION_LABEL_KEY: Record<ModifiedSection, keyof Dict> = {
today: 'designFiles.modifiedToday',
yesterday: 'designFiles.modifiedYesterday',
previous7Days: 'designFiles.modifiedPrevious7Days',
previous30Days: 'designFiles.modifiedPrevious30Days',
older: 'designFiles.modifiedOlder',
};
/**
* Full-panel browser for a project's `.od/projects/<id>/` 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,
@ -56,7 +79,12 @@ export function DesignFilesPanel({
const [sortDir, setSortDir] = useState<SortDir>('desc');
const lastKeyPress = useRef<Map<string, number>>(new Map());
const [deleting, setDeleting] = useState(false);
const [groupMode, setGroupMode] = useState<DesignFilesGroupMode>('kind');
const [collapsedModifiedSections, setCollapsedModifiedSections] = useState<
Set<ModifiedSection>
>(new Set());
const [renaming, setRenaming] = useState<{ name: string; draft: string; saving: boolean } | null>(null);
const [dayBoundary, setDayBoundary] = useState(() => Date.now());
const sortedFiles = useMemo(() => {
return [...files].sort((a, b) => {
@ -74,7 +102,31 @@ export function DesignFilesPanel({
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 pageFiles = useMemo(
() =>
sortedFiles.slice(
safePage * effectivePageSize,
(safePage + 1) * effectivePageSize,
),
[effectivePageSize, safePage, sortedFiles],
);
const modifiedGroups = useMemo(() => {
const groups: Record<ModifiedSection, ProjectFile[]> = {
today: [],
yesterday: [],
previous7Days: [],
previous30Days: [],
older: [],
};
const thresholds = modifiedSectionThresholds(dayBoundary);
for (const f of pageFiles) {
groups[modifiedSectionFor(f.mtime, thresholds)].push(f);
}
return groups;
}, [dayBoundary, pageFiles]);
const visibleModifiedSections = MODIFIED_SECTION_ORDER.filter(
(section) => modifiedGroups[section].length > 0,
);
const rangeStart = safePage * effectivePageSize + 1;
const rangeEnd = Math.min((safePage + 1) * effectivePageSize, sortedFiles.length);
const allPageSelected = pageFiles.every((f) => selected.has(f.name));
@ -88,6 +140,21 @@ export function DesignFilesPanel({
if (Number.isFinite(totalPages)) setPage((p) => Math.min(p, totalPages - 1));
}, [totalPages]);
useEffect(() => {
const now = Date.now();
const startOfTomorrow = new Date(now);
startOfTomorrow.setHours(24, 0, 0, 0);
const timer = window.setTimeout(
() => setDayBoundary(Date.now()),
Math.max(1, startOfTomorrow.getTime() - now),
);
return () => window.clearTimeout(timer);
}, [dayBoundary]);
// 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;
@ -247,6 +314,188 @@ export function DesignFilesPanel({
}
}
function toggleModifiedSection(section: ModifiedSection) {
setCollapsedModifiedSections((prev) => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
}
function renderFileRow(f: ProjectFile) {
const active = preview === f.name;
const isHovered = hover === f.name;
const renameState = renaming?.name === f.name ? renaming : null;
return (
<tr
key={f.name}
data-testid={`design-file-row-${f.name}`}
className={`df-file-row ${active ? 'active' : ''} ${selected.has(f.name) ? 'selected' : ''}`}
onMouseEnter={() => setHover(f.name)}
onMouseLeave={() => setHover((c) => (c === f.name ? null : c))}
>
<td className="df-cell-check">
<span
className="df-row-check"
onClick={(e) => {
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'}
</span>
</td>
<td
className="df-cell-icon df-cell-openable"
onClick={() => setPreview(f.name)}
onDoubleClick={() => onOpenFile(f.name)}
>
<span className="df-row-icon" data-kind={f.kind} aria-hidden>
{kindGlyph(f.kind)}
</span>
</td>
<td
className="df-cell-name df-cell-openable"
onClick={() => {
if (!renameState) setPreview(f.name);
}}
onDoubleClick={() => {
if (!renameState) onOpenFile(f.name);
}}
>
{renameState ? (
<input
autoFocus
className="df-rename-input"
value={renameState.draft}
disabled={renameState.saving}
onChange={(e) => setRenaming({ ...renameState, draft: e.target.value })}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
onBlur={(e) => {
if (e.currentTarget.dataset.skipRenameCommit === '1') return;
void commitRename(f.name, renameState.draft);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.currentTarget.dataset.skipRenameCommit = '1';
void commitRename(f.name, renameState.draft);
} else if (e.key === 'Escape') {
e.preventDefault();
e.currentTarget.dataset.skipRenameCommit = '1';
setRenaming(null);
}
}}
/>
) : (
<button
type="button"
className="df-row-name-btn"
onClick={() => setPreview(f.name)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const now = Date.now();
const last = lastKeyPress.current.get(f.name) ?? 0;
if (now - last < 300) {
lastKeyPress.current.delete(f.name);
onOpenFile(f.name);
} else {
lastKeyPress.current.set(f.name, now);
setPreview(f.name);
}
}
}}
>
<span className="df-row-name-wrap">
<span className="df-row-name">{f.name}</span>
<span className="df-row-sub">{humanBytes(f.size)}</span>
</span>
</button>
)}
</td>
<td
className="df-cell-kind df-cell-openable"
onClick={() => setPreview(f.name)}
onDoubleClick={() => onOpenFile(f.name)}
>
<span className="df-kind-label">{kindLabel(f.kind, t)}</span>
</td>
<td
className="df-cell-time df-cell-openable"
onClick={() => setPreview(f.name)}
onDoubleClick={() => onOpenFile(f.name)}
>
{relativeTime(f.mtime, t)}
</td>
<td className="df-cell-menu">
<span
data-testid={`design-file-menu-${f.name}`}
className="df-row-menu"
style={isHovered || active ? { opacity: 1 } : undefined}
role="button"
tabIndex={0}
aria-label={t('designFiles.rowMenu')}
onClick={(e) => {
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);
}
}}
>
</span>
</td>
</tr>
);
}
function renderModifiedSections() {
return visibleModifiedSections.flatMap((section) => {
const sectionFiles = modifiedGroups[section];
const collapsed = collapsedModifiedSections.has(section);
const label = t(MODIFIED_SECTION_LABEL_KEY[section]);
return [
<tr className="df-section-row" key={`${section}-label`}>
<td colSpan={6}>
<button
type="button"
className="df-section-toggle"
aria-expanded={!collapsed}
aria-label={`${collapsed ? t('designFiles.expandGroup') : t('designFiles.collapseGroup')} ${label}`}
onClick={() => toggleModifiedSection(section)}
>
<Icon name={collapsed ? 'chevron-right' : 'chevron-down'} size={13} />
<span>{label}</span>
<span className="df-section-count">{sectionFiles.length}</span>
</button>
</td>
</tr>,
...(collapsed ? [] : sectionFiles.map(renderFileRow)),
];
});
}
async function handleBatchDownload() {
const fileList = [...selected];
if (fileList.length === 0) return;
@ -355,6 +604,31 @@ export function DesignFilesPanel({
<div className="df-empty">{t('designFiles.empty')}</div>
) : (
<>
{files.length > 0 ? (
<div
className="df-group-toggle"
role="group"
aria-label={t('designFiles.groupBy')}
>
<span>{t('designFiles.groupBy')}</span>
<button
type="button"
className={groupMode === 'kind' ? 'active' : ''}
aria-pressed={groupMode === 'kind'}
onClick={() => setGroupMode('kind')}
>
{t('designFiles.groupByKind')}
</button>
<button
type="button"
className={groupMode === 'modified' ? 'active' : ''}
aria-pressed={groupMode === 'modified'}
onClick={() => setGroupMode('modified')}
>
{t('designFiles.groupByModified')}
</button>
</div>
) : null}
{liveArtifacts.length > 0 ? (
<div className="df-section" key="live-artifacts">
<div className="df-section-label">{t('designFiles.sectionLiveArtifacts')}</div>
@ -499,151 +773,9 @@ export function DesignFilesPanel({
</tr>
</thead>
<tbody>
{pageFiles.map((f) => {
const active = preview === f.name;
const isHovered = hover === f.name;
const renameState = renaming?.name === f.name ? renaming : null;
return (
<tr
key={f.name}
data-testid={`design-file-row-${f.name}`}
className={`df-file-row ${active ? 'active' : ''} ${selected.has(f.name) ? 'selected' : ''}`}
onMouseEnter={() => setHover(f.name)}
onMouseLeave={() => setHover((c) => (c === f.name ? null : c))}
>
<td className="df-cell-check">
<span
className="df-row-check"
onClick={(e) => {
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'}
</span>
</td>
<td
className="df-cell-icon df-cell-openable"
onClick={() => setPreview(f.name)}
onDoubleClick={() => onOpenFile(f.name)}
>
<span className="df-row-icon" data-kind={f.kind} aria-hidden>
{kindGlyph(f.kind)}
</span>
</td>
<td
className="df-cell-name df-cell-openable"
onClick={() => {
if (!renameState) setPreview(f.name);
}}
onDoubleClick={() => {
if (!renameState) onOpenFile(f.name);
}}
>
{renameState ? (
<input
autoFocus
className="df-rename-input"
value={renameState.draft}
disabled={renameState.saving}
onChange={(e) =>
setRenaming({ ...renameState, draft: e.target.value })
}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
onBlur={(e) => {
if (e.currentTarget.dataset.skipRenameCommit === '1') return;
void commitRename(f.name, renameState.draft);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.currentTarget.dataset.skipRenameCommit = '1';
void commitRename(f.name, renameState.draft);
} else if (e.key === 'Escape') {
e.preventDefault();
e.currentTarget.dataset.skipRenameCommit = '1';
setRenaming(null);
}
}}
/>
) : (
<button
type="button"
className="df-row-name-btn"
onClick={() => setPreview(f.name)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const now = Date.now();
const last = lastKeyPress.current.get(f.name) ?? 0;
if (now - last < 300) {
lastKeyPress.current.delete(f.name);
onOpenFile(f.name);
} else {
lastKeyPress.current.set(f.name, now);
setPreview(f.name);
}
}
}}
>
<span className="df-row-name-wrap">
<span className="df-row-name">{f.name}</span>
<span className="df-row-sub">{humanBytes(f.size)}</span>
</span>
</button>
)}
</td>
<td
className="df-cell-kind df-cell-openable"
onClick={() => setPreview(f.name)}
onDoubleClick={() => onOpenFile(f.name)}
>
<span className="df-kind-label">{kindLabel(f.kind, t)}</span>
</td>
<td
className="df-cell-time df-cell-openable"
onClick={() => setPreview(f.name)}
onDoubleClick={() => onOpenFile(f.name)}
>
{relativeTime(f.mtime, t)}
</td>
<td className="df-cell-menu">
<span
data-testid={`design-file-menu-${f.name}`}
className="df-row-menu"
style={isHovered || active ? { opacity: 1 } : undefined}
role="button"
tabIndex={0}
aria-label={t('designFiles.rowMenu')}
onClick={(e) => {
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);
}
}}
>
</span>
</td>
</tr>
);
})}
{groupMode === 'modified'
? renderModifiedSections()
: pageFiles.map(renderFileRow)}
</tbody>
</table>
<div className="df-pagination df-pagination-center">
@ -878,6 +1010,39 @@ function kindSortPriority(kind: ProjectFileKind): number {
return 11;
}
interface ModifiedSectionThresholds {
todayStart: number;
yesterdayStart: number;
previous7DaysStart: number;
previous30DaysStart: number;
}
function modifiedSectionThresholds(now: number): ModifiedSectionThresholds {
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
return {
todayStart: startOfToday.getTime(),
yesterdayStart: dateDaysBefore(startOfToday, 1).getTime(),
previous7DaysStart: dateDaysBefore(startOfToday, 7).getTime(),
previous30DaysStart: dateDaysBefore(startOfToday, 30).getTime(),
};
}
function modifiedSectionFor(ts: number, thresholds: ModifiedSectionThresholds): ModifiedSection {
const { todayStart, yesterdayStart, previous7DaysStart, previous30DaysStart } = thresholds;
if (ts >= todayStart) return 'today';
if (ts >= yesterdayStart) return 'yesterday';
if (ts >= previous7DaysStart) return 'previous7Days';
if (ts >= previous30DaysStart) return 'previous30Days';
return 'older';
}
function dateDaysBefore(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() - days);
return result;
}
function kindGlyph(kind: ProjectFileKind): string {
if (kind === 'html') return '\u27E8\u27E9';
if (kind === 'image') return '\u25A3';

View file

@ -656,11 +656,21 @@ export const ar: Dict = {
'designFiles.previewClose': 'إغلاق المعاينة',
'designFiles.modified': 'تم التعديل {time} · {size}',
'designFiles.weeksAgo': 'منذ {n} أسبوع',
'designFiles.groupBy': 'التجميع حسب',
'designFiles.groupByKind': 'النوع',
'designFiles.groupByModified': 'تاريخ التعديل',
'designFiles.expandGroup': 'توسيع',
'designFiles.collapseGroup': 'طي',
'designFiles.sectionPages': 'صفحات',
'designFiles.sectionScripts': 'سكربتات',
'designFiles.sectionImages': 'صور',
'designFiles.sectionSketches': 'رسومات',
'designFiles.sectionOther': 'أخرى',
'designFiles.modifiedToday': 'اليوم',
'designFiles.modifiedYesterday': 'أمس',
'designFiles.modifiedPrevious7Days': 'آخر 7 أيام',
'designFiles.modifiedPrevious30Days': 'آخر 30 يومًا',
'designFiles.modifiedOlder': 'أقدم',
'designFiles.showMore': 'عرض +{n} أخرى',
'designFiles.kindHtml': 'صفحة HTML',
'designFiles.kindImage': 'صورة',

View file

@ -544,11 +544,21 @@ export const de: Dict = {
'designFiles.previewClose': 'Vorschau schließen',
'designFiles.modified': 'Geändert {time} · {size}',
'designFiles.weeksAgo': 'vor {n} W.',
'designFiles.groupBy': 'Gruppieren nach',
'designFiles.groupByKind': 'Typ',
'designFiles.groupByModified': 'Geändert',
'designFiles.expandGroup': 'Erweitern',
'designFiles.collapseGroup': 'Einklappen',
'designFiles.sectionPages': 'Seiten',
'designFiles.sectionScripts': 'Skripte',
'designFiles.sectionImages': 'Bilder',
'designFiles.sectionSketches': 'Sketches',
'designFiles.sectionOther': 'Andere',
'designFiles.modifiedToday': 'Heute',
'designFiles.modifiedYesterday': 'Gestern',
'designFiles.modifiedPrevious7Days': 'Letzte 7 Tage',
'designFiles.modifiedPrevious30Days': 'Letzte 30 Tage',
'designFiles.modifiedOlder': 'Älter',
'designFiles.showMore': '+{n} weitere anzeigen',
'designFiles.kindHtml': 'HTML-Seite',
'designFiles.kindImage': 'Bild',

View file

@ -723,12 +723,22 @@ export const en: Dict = {
'designFiles.previewClose': 'Close preview',
'designFiles.modified': 'Modified {time} · {size}',
'designFiles.weeksAgo': '{n}w ago',
'designFiles.groupBy': 'Group by',
'designFiles.groupByKind': 'Kind',
'designFiles.groupByModified': 'Modified',
'designFiles.expandGroup': 'Expand',
'designFiles.collapseGroup': 'Collapse',
'designFiles.sectionPages': 'Pages',
'designFiles.sectionScripts': 'Scripts',
'designFiles.sectionImages': 'Images',
'designFiles.sectionSketches': 'Sketches',
'designFiles.sectionLiveArtifacts': 'Live artifacts',
'designFiles.sectionOther': 'Other',
'designFiles.modifiedToday': 'Today',
'designFiles.modifiedYesterday': 'Yesterday',
'designFiles.modifiedPrevious7Days': 'Previous 7 days',
'designFiles.modifiedPrevious30Days': 'Previous 30 days',
'designFiles.modifiedOlder': 'Older',
'designFiles.showMore': 'Show +{n} more',
'designFiles.kindHtml': 'HTML page',
'designFiles.kindImage': 'Image',

View file

@ -545,11 +545,21 @@ export const esES: Dict = {
'designFiles.previewClose': 'Cerrar vista previa',
'designFiles.modified': 'Modificado {time} · {size}',
'designFiles.weeksAgo': 'hace {n} sem',
'designFiles.groupBy': 'Agrupar por',
'designFiles.groupByKind': 'Tipo',
'designFiles.groupByModified': 'Modificado',
'designFiles.expandGroup': 'Expandir',
'designFiles.collapseGroup': 'Contraer',
'designFiles.sectionPages': 'Páginas',
'designFiles.sectionScripts': 'Scripts',
'designFiles.sectionImages': 'Imágenes',
'designFiles.sectionSketches': 'Bocetos',
'designFiles.sectionOther': 'Otros',
'designFiles.modifiedToday': 'Hoy',
'designFiles.modifiedYesterday': 'Ayer',
'designFiles.modifiedPrevious7Days': 'Últimos 7 días',
'designFiles.modifiedPrevious30Days': 'Últimos 30 días',
'designFiles.modifiedOlder': 'Anterior',
'designFiles.showMore': 'Mostrar +{n} más',
'designFiles.kindHtml': 'Página HTML',
'designFiles.kindImage': 'Imagen',

View file

@ -669,12 +669,22 @@ export const fa: Dict = {
'designFiles.previewClose': 'بستن پیش‌نمایش',
'designFiles.modified': 'ویرایش شده {time} · {size}',
'designFiles.weeksAgo': '{n} هفته پیش',
'designFiles.groupBy': 'گروه‌بندی بر اساس',
'designFiles.groupByKind': 'نوع',
'designFiles.groupByModified': 'زمان ویرایش',
'designFiles.expandGroup': 'باز کردن',
'designFiles.collapseGroup': 'جمع کردن',
'designFiles.sectionPages': 'صفحات',
'designFiles.sectionScripts': 'اسکریپت‌ها',
'designFiles.sectionImages': 'تصاویر',
'designFiles.sectionSketches': 'طرح‌ها',
'designFiles.sectionLiveArtifacts': 'مصنوعات زنده',
'designFiles.sectionOther': 'سایر',
'designFiles.modifiedToday': 'امروز',
'designFiles.modifiedYesterday': 'دیروز',
'designFiles.modifiedPrevious7Days': '۷ روز گذشته',
'designFiles.modifiedPrevious30Days': '۳۰ روز گذشته',
'designFiles.modifiedOlder': 'قدیمی‌تر',
'designFiles.showMore': '+{n} بیشتر',
'designFiles.kindHtml': 'صفحه HTML',
'designFiles.kindImage': 'تصویر',

View file

@ -656,11 +656,21 @@ export const fr: Dict = {
'designFiles.previewClose': 'Fermer l\'aperçu',
'designFiles.modified': 'Modifié {time} · {size}',
'designFiles.weeksAgo': 'il y a {n} sem',
'designFiles.groupBy': 'Grouper par',
'designFiles.groupByKind': 'Type',
'designFiles.groupByModified': 'Modification',
'designFiles.expandGroup': 'Développer',
'designFiles.collapseGroup': 'Réduire',
'designFiles.sectionPages': 'Pages',
'designFiles.sectionScripts': 'Scripts',
'designFiles.sectionImages': 'Images',
'designFiles.sectionSketches': 'Croquis',
'designFiles.sectionOther': 'Autre',
'designFiles.modifiedToday': 'Aujourdhui',
'designFiles.modifiedYesterday': 'Hier',
'designFiles.modifiedPrevious7Days': '7 derniers jours',
'designFiles.modifiedPrevious30Days': '30 derniers jours',
'designFiles.modifiedOlder': 'Plus ancien',
'designFiles.showMore': 'Afficher +{n} de plus',
'designFiles.kindHtml': 'Page HTML',
'designFiles.kindImage': 'Image',

View file

@ -656,11 +656,21 @@ export const hu: Dict = {
'designFiles.previewClose': 'Előnézet bezárása',
'designFiles.modified': 'Módosítva: {time} · {size}',
'designFiles.weeksAgo': '{n} hete',
'designFiles.groupBy': 'Csoportosítás',
'designFiles.groupByKind': 'Típus',
'designFiles.groupByModified': 'Módosítás',
'designFiles.expandGroup': 'Kibontás',
'designFiles.collapseGroup': 'Összecsukás',
'designFiles.sectionPages': 'Oldalak',
'designFiles.sectionScripts': 'Szkriptek',
'designFiles.sectionImages': 'Képek',
'designFiles.sectionSketches': 'Vázlatok',
'designFiles.sectionOther': 'Egyéb',
'designFiles.modifiedToday': 'Ma',
'designFiles.modifiedYesterday': 'Tegnap',
'designFiles.modifiedPrevious7Days': 'Előző 7 nap',
'designFiles.modifiedPrevious30Days': 'Előző 30 nap',
'designFiles.modifiedOlder': 'Régebbi',
'designFiles.showMore': '+{n} további megjelenítése',
'designFiles.kindHtml': 'HTML oldal',
'designFiles.kindImage': 'Kép',

View file

@ -756,12 +756,22 @@ export const id: Dict = {
'designFiles.previewClose': 'Tutup pratinjau',
'designFiles.modified': 'Diubah {time} - {size}',
'designFiles.weeksAgo': '{n} minggu lalu',
'designFiles.groupBy': 'Kelompokkan menurut',
'designFiles.groupByKind': 'Jenis',
'designFiles.groupByModified': 'Diubah',
'designFiles.expandGroup': 'Perluas',
'designFiles.collapseGroup': 'Ciutkan',
'designFiles.sectionPages': 'Halaman',
'designFiles.sectionScripts': 'Skrip',
'designFiles.sectionImages': 'Gambar',
'designFiles.sectionSketches': 'Sketsa',
'designFiles.sectionLiveArtifacts': 'Live artifact',
'designFiles.sectionOther': 'Lainnya',
'designFiles.modifiedToday': 'Hari ini',
'designFiles.modifiedYesterday': 'Kemarin',
'designFiles.modifiedPrevious7Days': '7 hari sebelumnya',
'designFiles.modifiedPrevious30Days': '30 hari sebelumnya',
'designFiles.modifiedOlder': 'Lebih lama',
'designFiles.showMore': 'Tampilkan +{n} lagi',
'designFiles.kindHtml': 'Halaman HTML',
'designFiles.kindImage': 'Gambar',

View file

@ -543,11 +543,21 @@ export const ja: Dict = {
'designFiles.previewClose': 'プレビューを閉じる',
'designFiles.modified': '{time} に変更 · {size}',
'designFiles.weeksAgo': '{n}週間前',
'designFiles.groupBy': 'グループ化',
'designFiles.groupByKind': '種類',
'designFiles.groupByModified': '更新日時',
'designFiles.expandGroup': '展開',
'designFiles.collapseGroup': '折りたたむ',
'designFiles.sectionPages': 'ページ',
'designFiles.sectionScripts': 'スクリプト',
'designFiles.sectionImages': '画像',
'designFiles.sectionSketches': 'スケッチ',
'designFiles.sectionOther': 'その他',
'designFiles.modifiedToday': '今日',
'designFiles.modifiedYesterday': '昨日',
'designFiles.modifiedPrevious7Days': '過去7日間',
'designFiles.modifiedPrevious30Days': '過去30日間',
'designFiles.modifiedOlder': 'それ以前',
'designFiles.showMore': 'さらに {n} 件表示',
'designFiles.kindHtml': 'HTML ページ',
'designFiles.kindImage': '画像',

View file

@ -656,11 +656,21 @@ export const ko: Dict = {
'designFiles.previewClose': '미리보기 닫기',
'designFiles.modified': '{time} 수정됨 · {size}',
'designFiles.weeksAgo': '{n}주 전',
'designFiles.groupBy': '그룹 기준',
'designFiles.groupByKind': '종류',
'designFiles.groupByModified': '수정일',
'designFiles.expandGroup': '펼치기',
'designFiles.collapseGroup': '접기',
'designFiles.sectionPages': '페이지',
'designFiles.sectionScripts': '스크립트',
'designFiles.sectionImages': '이미지',
'designFiles.sectionSketches': '스케치',
'designFiles.sectionOther': '기타',
'designFiles.modifiedToday': '오늘',
'designFiles.modifiedYesterday': '어제',
'designFiles.modifiedPrevious7Days': '지난 7일',
'designFiles.modifiedPrevious30Days': '지난 30일',
'designFiles.modifiedOlder': '이전',
'designFiles.showMore': '+{n}개 더 보기',
'designFiles.kindHtml': 'HTML 페이지',
'designFiles.kindImage': '이미지',

View file

@ -656,11 +656,21 @@ export const pl: Dict = {
'designFiles.previewClose': 'Zamknij podgląd',
'designFiles.modified': 'Zmodyfikowano {time} · {size}',
'designFiles.weeksAgo': '{n} tyg. temu',
'designFiles.groupBy': 'Grupuj według',
'designFiles.groupByKind': 'Typ',
'designFiles.groupByModified': 'Modyfikacja',
'designFiles.expandGroup': 'Rozwiń',
'designFiles.collapseGroup': 'Zwiń',
'designFiles.sectionPages': 'Strony',
'designFiles.sectionScripts': 'Skrypty',
'designFiles.sectionImages': 'Obrazy',
'designFiles.sectionSketches': 'Szkice',
'designFiles.sectionOther': 'Inne',
'designFiles.modifiedToday': 'Dzisiaj',
'designFiles.modifiedYesterday': 'Wczoraj',
'designFiles.modifiedPrevious7Days': 'Ostatnie 7 dni',
'designFiles.modifiedPrevious30Days': 'Ostatnie 30 dni',
'designFiles.modifiedOlder': 'Starsze',
'designFiles.showMore': 'Pokaż +{n} więcej',
'designFiles.kindHtml': 'Strona HTML',
'designFiles.kindImage': 'Obraz',

View file

@ -668,12 +668,22 @@ export const ptBR: Dict = {
'designFiles.previewClose': 'Fechar prévia',
'designFiles.modified': 'Modificado {time} · {size}',
'designFiles.weeksAgo': 'há {n} sem',
'designFiles.groupBy': 'Agrupar por',
'designFiles.groupByKind': 'Tipo',
'designFiles.groupByModified': 'Modificado',
'designFiles.expandGroup': 'Expandir',
'designFiles.collapseGroup': 'Recolher',
'designFiles.sectionPages': 'Páginas',
'designFiles.sectionScripts': 'Scripts',
'designFiles.sectionImages': 'Imagens',
'designFiles.sectionSketches': 'Esboços',
'designFiles.sectionLiveArtifacts': 'Artefatos live',
'designFiles.sectionOther': 'Outros',
'designFiles.modifiedToday': 'Hoje',
'designFiles.modifiedYesterday': 'Ontem',
'designFiles.modifiedPrevious7Days': 'Últimos 7 dias',
'designFiles.modifiedPrevious30Days': 'Últimos 30 dias',
'designFiles.modifiedOlder': 'Mais antigos',
'designFiles.showMore': 'Mostrar +{n} mais',
'designFiles.kindHtml': 'Página HTML',
'designFiles.kindImage': 'Imagem',

View file

@ -668,12 +668,22 @@ export const ru: Dict = {
'designFiles.previewClose': 'Закрыть предпросмотр',
'designFiles.modified': 'Изменено {time} · {size}',
'designFiles.weeksAgo': '{n} нед. назад',
'designFiles.groupBy': 'Группировать по',
'designFiles.groupByKind': 'Тип',
'designFiles.groupByModified': 'Изменено',
'designFiles.expandGroup': 'Развернуть',
'designFiles.collapseGroup': 'Свернуть',
'designFiles.sectionPages': 'Страницы',
'designFiles.sectionScripts': 'Скрипты',
'designFiles.sectionImages': 'Изображения',
'designFiles.sectionSketches': 'Эскизы',
'designFiles.sectionLiveArtifacts': 'Live-артефакты',
'designFiles.sectionOther': 'Другое',
'designFiles.modifiedToday': 'Сегодня',
'designFiles.modifiedYesterday': 'Вчера',
'designFiles.modifiedPrevious7Days': 'Последние 7 дней',
'designFiles.modifiedPrevious30Days': 'Последние 30 дней',
'designFiles.modifiedOlder': 'Старше',
'designFiles.showMore': 'Показать ещё +{n}',
'designFiles.kindHtml': 'HTML страница',
'designFiles.kindImage': 'Изображение',

View file

@ -647,11 +647,21 @@ export const tr: Dict = {
'designFiles.previewClose': 'Önizlemeyi kapat',
'designFiles.modified': 'Düzenlendi: {time} · {size}',
'designFiles.weeksAgo': '{n} hafta önce',
'designFiles.groupBy': 'Grupla',
'designFiles.groupByKind': 'Tür',
'designFiles.groupByModified': 'Değiştirilme',
'designFiles.expandGroup': 'Genişlet',
'designFiles.collapseGroup': 'Daralt',
'designFiles.sectionPages': 'Sayfalar',
'designFiles.sectionScripts': 'Betikler',
'designFiles.sectionImages': 'Görseller',
'designFiles.sectionSketches': 'Taslaklar',
'designFiles.sectionOther': 'Diğer',
'designFiles.modifiedToday': 'Bugün',
'designFiles.modifiedYesterday': 'Dün',
'designFiles.modifiedPrevious7Days': 'Son 7 gün',
'designFiles.modifiedPrevious30Days': 'Son 30 gün',
'designFiles.modifiedOlder': 'Daha eski',
'designFiles.showMore': '+{n} tane daha göster',
'designFiles.kindHtml': 'HTML sayfası',
'designFiles.kindImage': 'Görsel',

View file

@ -669,12 +669,22 @@ export const uk: Dict = {
'designFiles.previewClose': 'Закрити попередній перегляд',
'designFiles.modified': 'Змінено {time} · {size}',
'designFiles.weeksAgo': '{n}тижнів назад',
'designFiles.groupBy': 'Групувати за',
'designFiles.groupByKind': 'Тип',
'designFiles.groupByModified': 'Змінено',
'designFiles.expandGroup': 'Розгорнути',
'designFiles.collapseGroup': 'Згорнути',
'designFiles.sectionPages': 'Сторінки',
'designFiles.sectionLiveArtifacts': 'Live-артефакти',
'designFiles.sectionScripts': 'Скрипти',
'designFiles.sectionImages': 'Зображення',
'designFiles.sectionSketches': 'Ескізи',
'designFiles.sectionOther': 'Інше',
'designFiles.modifiedToday': 'Сьогодні',
'designFiles.modifiedYesterday': 'Учора',
'designFiles.modifiedPrevious7Days': 'Останні 7 днів',
'designFiles.modifiedPrevious30Days': 'Останні 30 днів',
'designFiles.modifiedOlder': 'Старіші',
'designFiles.showMore': 'Показати ще +{n}',
'designFiles.kindHtml': 'Веб-сторінка',
'designFiles.kindLiveArtifact': 'Live-артефакт',

View file

@ -712,12 +712,22 @@ export const zhCN: Dict = {
'designFiles.previewClose': '关闭预览',
'designFiles.modified': '修改于 {time} · {size}',
'designFiles.weeksAgo': '{n} 周前',
'designFiles.groupBy': '分组方式',
'designFiles.groupByKind': '类型',
'designFiles.groupByModified': '修改时间',
'designFiles.expandGroup': '展开',
'designFiles.collapseGroup': '折叠',
'designFiles.sectionPages': '页面',
'designFiles.sectionScripts': '脚本',
'designFiles.sectionImages': '图片',
'designFiles.sectionSketches': '草图',
'designFiles.sectionLiveArtifacts': '实时制品',
'designFiles.sectionOther': '其它',
'designFiles.modifiedToday': '今天',
'designFiles.modifiedYesterday': '昨天',
'designFiles.modifiedPrevious7Days': '最近 7 天',
'designFiles.modifiedPrevious30Days': '最近 30 天',
'designFiles.modifiedOlder': '更早',
'designFiles.showMore': '再显示 {n} 个',
'designFiles.kindHtml': 'HTML 页面',
'designFiles.kindImage': '图片',

View file

@ -705,12 +705,22 @@ export const zhTW: Dict = {
'designFiles.previewClose': '關閉預覽',
'designFiles.modified': '修改於 {time} · {size}',
'designFiles.weeksAgo': '{n} 週前',
'designFiles.groupBy': '分組方式',
'designFiles.groupByKind': '類型',
'designFiles.groupByModified': '修改時間',
'designFiles.expandGroup': '展開',
'designFiles.collapseGroup': '折疊',
'designFiles.sectionPages': '頁面',
'designFiles.sectionScripts': '腳本',
'designFiles.sectionImages': '圖片',
'designFiles.sectionSketches': '草圖',
'designFiles.sectionLiveArtifacts': '即時成品',
'designFiles.sectionOther': '其它',
'designFiles.modifiedToday': '今天',
'designFiles.modifiedYesterday': '昨天',
'designFiles.modifiedPrevious7Days': '最近 7 天',
'designFiles.modifiedPrevious30Days': '最近 30 天',
'designFiles.modifiedOlder': '更早',
'designFiles.showMore': '再顯示 {n} 個',
'designFiles.kindHtml': 'HTML 頁面',
'designFiles.kindImage': '圖片',

View file

@ -874,12 +874,22 @@ export interface Dict {
'designFiles.previewClose': string;
'designFiles.modified': string;
'designFiles.weeksAgo': string;
'designFiles.groupBy': string;
'designFiles.groupByKind': string;
'designFiles.groupByModified': string;
'designFiles.expandGroup': string;
'designFiles.collapseGroup': string;
'designFiles.sectionPages': string;
'designFiles.sectionScripts': string;
'designFiles.sectionImages': string;
'designFiles.sectionSketches': string;
'designFiles.sectionLiveArtifacts': string;
'designFiles.sectionOther': string;
'designFiles.modifiedToday': string;
'designFiles.modifiedYesterday': string;
'designFiles.modifiedPrevious7Days': string;
'designFiles.modifiedPrevious30Days': string;
'designFiles.modifiedOlder': string;
'designFiles.showMore': string;
'designFiles.kindHtml': string;
'designFiles.kindImage': string;

View file

@ -7232,6 +7232,39 @@ button.connector-action.is-loading {
overflow-y: auto;
padding: 12px 0 0;
}
.df-group-toggle {
margin: 0 20px 8px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel);
color: var(--text-muted);
font-size: 11.5px;
}
.df-group-toggle > span {
padding: 0 6px 0 4px;
color: var(--text-faint);
}
.df-group-toggle button {
min-height: 24px;
padding: 3px 9px;
border: 1px solid transparent;
background: transparent;
color: var(--text-muted);
font-size: 11.5px;
}
.df-group-toggle button:hover {
background: var(--bg-subtle);
color: var(--text);
}
.df-group-toggle button.active {
background: var(--bg-subtle);
border-color: var(--border);
color: var(--text);
}
.df-section { display: flex; flex-direction: column; gap: 0; }
.df-section + .df-section { margin-top: 6px; }
.df-section-label {
@ -7242,6 +7275,34 @@ button.connector-action.is-loading {
font-weight: 600;
padding: 12px 20px 6px;
}
.df-section-toggle {
width: auto;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
border: none;
background: transparent;
text-align: left;
font: inherit;
cursor: pointer;
}
.df-section-toggle:hover {
background: var(--bg-subtle);
color: var(--text);
}
.df-section-toggle span:first-of-type {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.df-section-row td {
padding: 10px 20px 4px;
border-top: 1px solid var(--border);
}
.df-section-row:first-child td {
border-top: none;
}
.df-section-count {
margin-left: 8px;
color: var(--text-faint);

View file

@ -1,7 +1,8 @@
// @vitest-environment jsdom
import { fireEvent, render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DesignFilesPanel } from '../../src/components/DesignFilesPanel';
import type { ProjectFile, ProjectFileKind } from '../../src/types';
@ -15,19 +16,29 @@ function extForKind(kind: ProjectFileKind): string {
return 'bin';
}
function file(overrides: Partial<ProjectFile> & Pick<ProjectFile, 'name'>): ProjectFile {
return {
path: overrides.name,
type: 'file',
size: 1024,
mtime: Date.now(),
kind: 'html',
mime: 'text/html',
...overrides,
};
}
function generateFiles(count: number): ProjectFile[] {
const kinds: ProjectFileKind[] = [
'html', 'image', 'sketch', 'text', 'code', 'pdf',
];
const kinds: ProjectFileKind[] = ['html', 'image', 'sketch', 'text', 'code', 'pdf'];
return Array.from({ length: count }, (_, i) => {
const kind = kinds[i % kinds.length];
return {
name: `file-${i + 1}.${extForKind(kind!)}`,
kind: kind!,
const kind = kinds[i % kinds.length]!;
return file({
name: `file-${i + 1}.${extForKind(kind)}`,
kind,
size: 1024 * (i + 1),
mtime: Date.now() - i * 60_000,
mime: 'text/plain',
};
});
});
}
@ -68,6 +79,207 @@ function getSelects(container: HTMLElement) {
return Array.from(container.querySelectorAll<HTMLSelectElement>('select'));
}
describe('DesignFilesPanel grouping', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it('does not show grouping controls when only live artifacts are available', () => {
render(
<DesignFilesPanel
projectId="project-1"
files={[]}
liveArtifacts={[
{
kind: 'live-artifact',
artifactId: 'artifact-1',
tabId: 'live:artifact-1',
projectId: 'project-1',
title: 'Live Preview',
slug: 'live-preview',
status: 'active',
refreshStatus: 'idle',
pinned: false,
preview: { type: 'html', entry: 'index.html' },
hasDocument: true,
updatedAt: '2026-05-09T12:00:00.000Z',
},
]}
onRefreshFiles={vi.fn()}
onOpenFile={vi.fn()}
onOpenLiveArtifact={vi.fn()}
onRenameFile={vi.fn()}
onDeleteFile={vi.fn()}
onDeleteFiles={vi.fn()}
onUpload={vi.fn()}
onUploadFiles={vi.fn()}
onPaste={vi.fn()}
onNewSketch={vi.fn()}
/>,
);
expect(screen.queryByRole('group', { name: 'Group by' })).toBeNull();
expect(screen.getByTestId('design-file-row-live:artifact-1')).toBeTruthy();
});
it('keeps the ungrouped table view as the default view', () => {
renderPanel([
file({ name: 'page.html', kind: 'html', mime: 'text/html' }),
file({ name: 'chart.png', kind: 'image', mime: 'image/png' }),
]);
const groupControls = screen.getByRole('group', { name: 'Group by' });
const kindGroupButton = within(groupControls).getByRole('button', { name: 'Kind' });
expect(kindGroupButton.getAttribute('aria-pressed')).toBe('true');
expect(screen.getByText('Name')).toBeTruthy();
expect(document.querySelector('.df-th-kind')?.textContent).toContain('Kind');
expect(screen.queryByText('Today')).toBeNull();
});
it('can group files by modified date and collapse a date group', () => {
const now = new Date(2026, 4, 9, 12).getTime();
vi.useFakeTimers();
vi.setSystemTime(now);
renderPanel([
file({ name: 'today.html', mtime: new Date(2026, 4, 9, 11).getTime() }),
file({ name: 'yesterday.html', mtime: new Date(2026, 4, 8, 12).getTime() }),
]);
expect(screen.queryByText('Today')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
expect(screen.getByText('Today')).toBeTruthy();
expect(screen.getByText('Yesterday')).toBeTruthy();
expect(screen.getByTestId('design-file-row-today.html')).toBeTruthy();
expect(screen.getByTestId('design-file-row-yesterday.html')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /Collapse Today/i }));
expect(screen.queryByTestId('design-file-row-today.html')).toBeNull();
expect(screen.getByTestId('design-file-row-yesterday.html')).toBeTruthy();
});
it('keeps files from seven calendar days ago in the previous 7 days group', () => {
const now = new Date(2026, 4, 9, 12).getTime();
vi.useFakeTimers();
vi.setSystemTime(now);
renderPanel([file({ name: 'week-old.html', mtime: new Date(2026, 4, 2, 12).getTime() })]);
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
expect(screen.getByText('Previous 7 days')).toBeTruthy();
expect(screen.queryByText('Previous 30 days')).toBeNull();
expect(screen.getByTestId('design-file-row-week-old.html')).toBeTruthy();
});
it('keeps files at the seven calendar day boundary in the previous 7 days group', () => {
const now = new Date(2026, 4, 9, 12).getTime();
vi.useFakeTimers();
vi.setSystemTime(now);
renderPanel([
file({ name: 'week-boundary.html', mtime: new Date(2026, 4, 2, 0, 0, 0, 0).getTime() }),
]);
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
expect(screen.getByText('Previous 7 days')).toBeTruthy();
expect(screen.queryByText('Previous 30 days')).toBeNull();
expect(screen.getByTestId('design-file-row-week-boundary.html')).toBeTruthy();
});
it('keeps files from thirty calendar days ago in the previous 30 days group', () => {
const now = new Date(2026, 4, 9, 12).getTime();
vi.useFakeTimers();
vi.setSystemTime(now);
renderPanel([
file({ name: 'month-old.html', mtime: new Date(2026, 3, 9, 12).getTime() }),
]);
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
expect(screen.getByText('Previous 30 days')).toBeTruthy();
expect(screen.queryByText('Older')).toBeNull();
expect(screen.getByTestId('design-file-row-month-old.html')).toBeTruthy();
});
it('keeps files at the thirty calendar day boundary in the previous 30 days group', () => {
const now = new Date(2026, 4, 9, 12).getTime();
vi.useFakeTimers();
vi.setSystemTime(now);
renderPanel([
file({
name: 'month-boundary.html',
mtime: new Date(2026, 3, 9, 0, 0, 0, 0).getTime(),
}),
]);
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
expect(screen.getByText('Previous 30 days')).toBeTruthy();
expect(screen.queryByText('Older')).toBeNull();
expect(screen.getByTestId('design-file-row-month-boundary.html')).toBeTruthy();
});
it('groups files older than thirty calendar days into older', () => {
const now = new Date(2026, 4, 9, 12).getTime();
vi.useFakeTimers();
vi.setSystemTime(now);
renderPanel([file({ name: 'archive.html', mtime: new Date(2026, 3, 8, 12).getTime() })]);
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
expect(screen.getByText('Older')).toBeTruthy();
expect(screen.queryByText('Previous 30 days')).toBeNull();
expect(screen.getByTestId('design-file-row-archive.html')).toBeTruthy();
});
it('groups only the current page so large file lists stay paginated', () => {
const now = new Date(2026, 4, 9, 12).getTime();
vi.useFakeTimers();
vi.setSystemTime(now);
renderPanel(
Array.from({ length: 31 }, (_, i) =>
file({ name: `today-${String(i + 1).padStart(2, '0')}.html`, mtime: now - i }),
),
);
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
expect(screen.getByTestId('design-file-row-today-01.html')).toBeTruthy();
expect(screen.queryByTestId('design-file-row-today-31.html')).toBeNull();
expect(getPageInfo(document.body)).toContain('130 of 31');
});
it('updates modified date groups when the local day changes', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 4, 9, 23, 59, 50));
renderPanel([file({ name: 'late-edit.html', mtime: new Date(2026, 4, 9, 23, 59).getTime() })]);
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
expect(screen.getByText('Today')).toBeTruthy();
expect(screen.queryByText('Yesterday')).toBeNull();
act(() => {
vi.advanceTimersByTime(10_001);
});
expect(screen.getByText('Yesterday')).toBeTruthy();
expect(screen.queryByText('Today')).toBeNull();
expect(screen.getByTestId('design-file-row-late-edit.html')).toBeTruthy();
});
});
describe('DesignFilesPanel large-list regression', () => {
it('renders only the default page size (30) rows with 500 files', () => {
const files = generateFiles(500);