fix/Bug#772-DesignFilesSortButton-DesignFilesTableNowSortableColumns (#804)

* Disclaimer: Changes made using OpenCode with Big Pickle, an AI code assistant.

1. Removed the ↑ button from DesignFilesPanel.tsx (was a "back" button that
    closed the preview pane — confusingly placed in the header as if it were for
    sorting).

2. Converted the file list to a single <table> with sortable columns:
    - Replaced the section-based grouping (Pages, Sketches, Scripts, Images,
    Other) with a flat, sortable table
    - Added column headers: Name, Kind, Modified — all clickable to toggle
    sort direction
    - Default sort is by modified time descending (same as previous behavior)
    - Sort indicator arrows (↑/↓) show the active sort column and direction
    - Live artifacts remain as a separate section above the table

3. Added i18n keys designFiles.colName, designFiles.colKind, designFiles.
    colModified to all 17 locale files and the type definitions.

4. Updated CSS with table layout styles (.df-table, .df-file-row, column
    width classes, sortable header styles).

    Files modified:
    - apps/web/src/components/DesignFilesPanel.tsx
    - apps/web/src/index.css
    - apps/web/src/i18n/types.ts
    - apps/web/src/i18n/locales/en.ts
    - apps/web/src/i18n/locales/*.ts (all 16 other locale files)

* Updated to preserve keyboard access to sorting

* Fixed keyboard to focus/activate/launch file from Design Files list. Single space bar will show preview, double spare bar will open the file as a tab

* Top pagination bar (above the table):
     - "Show" dropdown with options 15, 30 (default), 45, 60, All
     - Page range indicator (1–20 of 45)
     - Previous / Next buttons

     Bottom pagination bar (below the table):
     - Previous / Next buttons
     - "Go to page" dropdown listing all page numbers
     - Same page range indicator

     Implementation details:
     - All controls use native <select> and <button> elements — fully keyboard
     accessible (Tab, arrow keys, Enter/Space)
     - Page resets to 0 when page size changes
     - safePage clamps to valid bounds when file count changes (e.g. after delete)
     - "All" sets page size to total file count (effectively one page)
     - Prev/Next buttons show disabled state at boundaries with reduced opacity

* All 46 test files, all 385 tests pass. Here's what the regression test covers:
     ┌────────────────────┬──────────────────────────────────────────────────────┐
     │Test                │What it verifies                                      │
     ├────────────────────┼──────────────────────────────────────────────────────┤
     │default page size   │500 files → only 30 .df-file-row elements in DOM      │
     ├────────────────────┼──────────────────────────────────────────────────────┤
     │page size All       │changing per-page to "All" shows all 500 rows         │
     ├────────────────────┼──────────────────────────────────────────────────────┤
     │page size 60        │changing to 60 shows 60 rows                          │
     ├────────────────────┼──────────────────────────────────────────────────────┤
     │Next navigation     │clicking Next advances page and shows file-31 (sorted │
     │                    │by mtime desc)                                        │
     ├────────────────────┼──────────────────────────────────────────────────────┤
     │Prev/Next disabled  │Prev disabled on page 0, Next disabled on last page   │
     │states              │                                                      │
     ├────────────────────┼──────────────────────────────────────────────────────┤
     │jump to page        │bottom dropdown jumps to page 3 (shows file-91)       │
     ├────────────────────┼──────────────────────────────────────────────────────┤
     │page info text      │1–30 of 500 → after Next → 31–60 of 500               │
     ├────────────────────┼──────────────────────────────────────────────────────┤
     │render time         │renders 500 files in under 2s                         │
     └────────────────────┴──────────────────────────────────────────────────────┘

* Fixed i18n for DesignFiles, and Fixed DesignFilesPanel Test

* Fixed - P3 — .df-thead rule defined but never applied

* Fixed keyboard use for file navigation, focus and button usage

* Fix i18n for x of y in design files pagination

* Fixed SafePage clamping

* Fixed dupe file total count

* Fixed x of y i18n

* Fixed DeleteSelected i18n and missing from Test

* fix effective pagesize issue, and change duplicate file kind to a filesize

* Readded page/everything selection and i18n

* Fixed i18n issues

* Resolved indonesian i18n issue with cloudflare keys

* Fixed unrelated cloudflare i18n issues as requested in Pull Request by reviewer

* Fix e2e test: click filename button instead of row for preview

The DesignFilesPanel was refactored from <button> rows to a <tr> with
a nested <button> for the filename. The e2e test was still clicking
the <tr> which has no onClick handler, so the preview never appeared.

* Remove duplicate formatSize helper, reuse humanBytes instead
This commit is contained in:
Paul Stean 2026-05-09 11:01:57 +10:00 committed by GitHub
parent 644a7daf2d
commit 0b039777b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 929 additions and 295 deletions

View file

@ -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<Section, keyof Dict> = {
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/<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,
@ -66,30 +49,42 @@ export function DesignFilesPanel({
const MENU_ESTIMATED_HEIGHT = 115;
const MENU_SAFE_PADDING = 8;
const [preview, setPreview] = useState<string | null>(null);
const [sectionLimits, setSectionLimits] = useState<Partial<Record<Section, number>>>({});
const [isSectionExpansionPending, startSectionExpansion] = useTransition();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [sortKey, setSortKey] = useState<SortKey>('mtime');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const lastKeyPress = useRef<Map<string, number>>(new Map());
const [deleting, setDeleting] = useState(false);
const grouped = useMemo(() => {
const groups: Record<Section, ProjectFile[]> = {
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<number | 'all'>(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({
<div className={`df-panel ${preview ? '' : 'no-preview'}`}>
<div className="df-main">
<div className="df-head">
<button
type="button"
className="icon-only"
onClick={() => setPreview(null)}
title={t('designFiles.up')}
aria-label={t('designFiles.back')}
>
</button>
<button
type="button"
className="icon-only"
@ -329,55 +354,129 @@ export function DesignFilesPanel({
))}
</div>
) : null}
{SECTION_ORDER.filter((s) => grouped[s].length > 0).map((section) => {
const sectionFiles = grouped[section];
const visibleLimit = sectionLimits[section] ?? INITIAL_SECTION_FILE_LIMIT;
const visibleFiles = sectionFiles.slice(0, visibleLimit);
const hiddenCount = sectionFiles.length - visibleFiles.length;
return (
<div className="df-section" key={section}>
<div className="df-section-label">
{t(SECTION_LABEL_KEY[section])}
<span className="df-section-count">{sectionFiles.length}</span>
<button
type="button"
className="df-select-all"
title={t('designFiles.selectAll')}
onClick={(e) => {
e.stopPropagation();
selectAllInSection(sectionFiles);
{sortedFiles.length > 0 ? (
<>
<div className="df-pagination df-pagination-start">
<label>
{t('designFiles.perPage')}:
<select
value={pageSize === 'all' ? 'all' : pageSize}
onChange={(e) => {
const val = e.target.value;
setPageSize(val === 'all' ? 'all' : Number(val));
}}
>
{t('designFiles.selectAll')}
<option value={15}>15</option>
<option value={30}>30</option>
<option value={45}>45</option>
<option value={60}>60</option>
<option value="all">{t('designFiles.all')}</option>
</select>
</label>
<span className="df-page-info">
{t('designFiles.pageInfo', { start: rangeStart, end: rangeEnd, total: sortedFiles.length })}
</span>
<div className="df-select-bar">
<button type="button" className="df-select-all" onClick={toggleSelectPage}>
{t('designFiles.selectPage')}
</button>
{sectionFiles.some((f) => selected.has(f.name)) ? (
<button
type="button"
className="df-select-all"
title={t('designFiles.clearSelection')}
onClick={(e) => {
e.stopPropagation();
clearSection(sectionFiles);
}}
>
{selected.size < sortedFiles.length ? (
<button type="button" className="df-select-all" onClick={selectAllFiles}>
{t('designFiles.selectAll', { n: sortedFiles.length })}
</button>
) : null}
{selected.size > 0 ? (
<button type="button" className="df-select-all" onClick={clearSelection}>
{t('designFiles.clearSelection')}
</button>
) : null}
</div>
{visibleFiles.map((f) => {
<div className="df-pagination-right">
<button
type="button"
className="df-page-btn"
disabled={safePage <= 0}
onClick={() => setPage(Math.max(0, safePage - 1))}
>
{t('designFiles.prev')}
</button>
<button
type="button"
className="df-page-btn"
disabled={safePage >= totalPages - 1}
onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
>
{t('designFiles.next')}
</button>
</div>
</div>
<table className="df-table">
<thead>
<tr>
<th className="df-th-check">
<span
className="df-row-check"
onClick={toggleSelectPage}
role="checkbox"
aria-checked={allPageSelected}
tabIndex={0}
onKeyDown={(e) => {
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'}
</span>
</th>
<th className="df-th-icon" />
<th
className="df-th-name df-th-sortable"
aria-sort={sortKey === 'name' ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}
>
<button type="button" className="df-th-btn" onClick={toggleSort('name')}>
{t('designFiles.colName')}
{sortKey === 'name' ? <span className="df-sort-arrow">{sortDir === 'asc' ? ' \u2191' : ' \u2193'}</span> : null}
</button>
</th>
<th
className="df-th-kind df-th-sortable"
aria-sort={sortKey === 'kind' ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}
>
<button type="button" className="df-th-btn" onClick={toggleSort('kind')}>
{t('designFiles.colKind')}
{sortKey === 'kind' ? <span className="df-sort-arrow">{sortDir === 'asc' ? ' \u2191' : ' \u2193'}</span> : null}
</button>
</th>
<th
className="df-th-time df-th-sortable"
aria-sort={sortKey === 'mtime' ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}
>
<button type="button" className="df-th-btn" onClick={toggleSort('mtime')}>
{t('designFiles.colModified')}
{sortKey === 'mtime' ? <span className="df-sort-arrow">{sortDir === 'asc' ? ' \u2191' : ' \u2193'}</span> : null}
</button>
</th>
<th className="df-th-menu" />
</tr>
</thead>
<tbody>
{pageFiles.map((f) => {
const active = preview === f.name;
const isHovered = hover === f.name;
return (
<button
<tr
key={f.name}
type="button"
data-testid={`design-file-row-${f.name}`}
className={`df-row ${active ? 'active' : ''} ${selected.has(f.name) ? 'selected' : ''}`}
className={`df-file-row ${active ? 'active' : ''} ${selected.has(f.name) ? 'selected' : ''}`}
onMouseEnter={() => setHover(f.name)}
onMouseLeave={() => setHover((c) => (c === f.name ? null : c))}
onClick={() => setPreview(f.name)}
onDoubleClick={() => onOpenFile(f.name)}
>
<td className="df-cell-check">
<span
className="df-row-check"
onClick={(e) => {
@ -395,88 +494,109 @@ export function DesignFilesPanel({
}
}}
>
{selected.has(f.name) ? '☑' : '☐'}
{selected.has(f.name) ? '\u2611' : '\u2610'}
</span>
</td>
<td className="df-cell-icon">
<span className="df-row-icon" data-kind={f.kind} aria-hidden>
{kindGlyph(f.kind)}
</span>
</td>
<td className="df-cell-name">
<button
type="button"
className="df-row-name-btn"
onClick={() => setPreview(f.name)}
onDoubleClick={() => onOpenFile(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">{kindLabel(f.kind, t)}</span>
<span className="df-row-sub">{humanBytes(f.size)}</span>
</span>
<span className="df-row-time">{relativeTime(f.mtime, t)}</span>
</button>
</td>
<td className="df-cell-kind">
<span className="df-kind-label">{kindLabel(f.kind, t)}</span>
</td>
<td className="df-cell-time">{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();
const rect = (e.target as HTMLElement)
.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,
);
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);
}
const left = Math.max(MENU_SAFE_PADDING, rect.right - 160);
setMenuPos({
name: f.name,
top,
left,
});
}}
>
</span>
</button>
</td>
</tr>
);
})}
{hiddenCount > 0 ? (
</tbody>
</table>
<div className="df-pagination df-pagination-center">
<button
type="button"
className="df-section-more"
disabled={isSectionExpansionPending}
aria-busy={isSectionExpansionPending}
onClick={() =>
startSectionExpansion(() => {
setSectionLimits((curr) => ({
...curr,
[section]: Math.min(
sectionFiles.length,
visibleLimit + SECTION_FILE_LIMIT_INCREMENT,
),
}));
})
}
className="df-page-btn"
disabled={safePage <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<Icon name={isSectionExpansionPending ? 'spinner' : 'plus'} size={12} />
<span>
{t('designFiles.showMore', {
n: Math.min(hiddenCount, SECTION_FILE_LIMIT_INCREMENT),
})}
</span>
{t('designFiles.prev')}
</button>
) : null}
<label>
{t('designFiles.jumpToPage')}:
<select
value={safePage}
onChange={(e) => setPage(Number(e.target.value))}
>
{Array.from({ length: totalPages }, (_, i) => (
<option key={i} value={i}>
{i + 1}
</option>
))}
</select>
</label>
<button
type="button"
className="df-page-btn"
disabled={safePage >= totalPages - 1}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
{t('designFiles.next')}
</button>
<span className="df-page-info">
{t('designFiles.pageInfo', { start: rangeStart, end: rangeEnd, total: sortedFiles.length })}
</span>
</div>
);
})}
</>
) : null}
</>
)}
<div
@ -649,31 +769,32 @@ function DfPreview({
);
}
function sectionFor(file: ProjectFile): Section {
if (file.kind === 'html' || file.kind === 'text') return 'pages';
if (file.kind === 'sketch') return 'sketches';
if (file.kind === 'code') return 'scripts';
if (file.kind === 'image') return 'images';
if (
file.kind === 'pdf' ||
file.kind === 'document' ||
file.kind === 'presentation' ||
file.kind === 'spreadsheet'
) return 'pages';
return 'other';
function kindSortPriority(kind: ProjectFileKind): number {
if (kind === 'html') return 0;
if (kind === 'text') return 1;
if (kind === 'code') return 2;
if (kind === 'sketch') return 3;
if (kind === 'image') return 4;
if (kind === 'document') return 5;
if (kind === 'pdf') return 6;
if (kind === 'presentation') return 7;
if (kind === 'spreadsheet') return 8;
if (kind === 'video') return 9;
if (kind === 'audio') return 10;
return 11;
}
function kindGlyph(kind: ProjectFileKind): string {
if (kind === 'html') return '⟨⟩';
if (kind === 'image') return '';
if (kind === 'sketch') return '';
if (kind === 'text') return '';
if (kind === 'code') return '{}';
if (kind === 'html') return '\u27E8\u27E9';
if (kind === 'image') return '\u25A3';
if (kind === 'sketch') return '\u270E';
if (kind === 'text') return '\u00B6';
if (kind === 'code') return '\u007B\u007D';
if (kind === 'pdf') return 'PDF';
if (kind === 'document') return 'DOC';
if (kind === 'presentation') return 'PPT';
if (kind === 'spreadsheet') return 'XLS';
return '·';
return '\u00B7';
}
function kindLabel(kind: ProjectFileKind, t: TranslateFn): string {

View file

@ -599,6 +599,7 @@ export const ar: Dict = {
'لا يوجد شيء هنا بعد. اسحب الملفات أدناه، أو أنشئ رسماً / الصق نصاً.',
'designFiles.refresh': 'تحديث',
'designFiles.delete': 'حذف',
'designFiles.deleteSelected': 'حذف {n}',
'designFiles.searchPlaceholder': 'بحث في الملفات...',
'designFiles.up': 'للأعلى',
'designFiles.back': 'رجوع',
@ -606,10 +607,10 @@ export const ar: Dict = {
'designFiles.rowMenu': 'قائمة الصف',
'designFiles.openInTab': 'فتح في علامة تبويب',
'designFiles.download': 'تحميل',
'designFiles.downloadSelected': 'Download {n} as ZIP',
'designFiles.deleteSelected': 'حذف {n}',
'designFiles.clearSelection': 'Clear',
'designFiles.selectAll': 'Select all',
'designFiles.downloadSelected': 'تنزيل {n} كـ ZIP',
'designFiles.clearSelection': 'مسح التحديد',
'designFiles.selectPage': 'تحديد الكل في الصفحة',
'designFiles.selectAll': 'تحديد الكل',
'designFiles.dropTitle': '⤓ أسقط الملفات هنا',
'designFiles.dropDesc':
'الصور، المستندات، المراجع، أو المجلدات - سيستخدمها الوكيل كسياق.',
@ -637,6 +638,15 @@ export const ar: Dict = {
'designFiles.kindPresentation': 'عرض تقديمي',
'designFiles.kindSpreadsheet': 'جدول بيانات',
'designFiles.kindBinary': 'ثنائي',
'designFiles.colName': 'الاسم',
'designFiles.colKind': 'النوع',
'designFiles.colModified': 'آخر تعديل',
'designFiles.perPage': 'عرض',
'designFiles.all': 'الكل',
'designFiles.prev': 'السابق',
'designFiles.next': 'التالي',
'designFiles.jumpToPage': 'انتقل إلى الصفحة',
'designFiles.pageInfo': '{start}{end} من {total}',
'quickSwitcher.placeholder': 'فتح ملف…',
'quickSwitcher.empty': 'لا توجد ملفات في هذا المشروع',
'quickSwitcher.noMatches': 'لا توجد نتائج',

View file

@ -494,10 +494,11 @@ export const de: Dict = {
'designFiles.rowMenu': 'Zeilenmenü',
'designFiles.openInTab': 'In Tab öffnen',
'designFiles.download': 'Herunterladen',
'designFiles.downloadSelected': 'Download {n} as ZIP',
'designFiles.downloadSelected': '{n} als ZIP herunterladen',
'designFiles.deleteSelected': '{n} löschen',
'designFiles.clearSelection': 'Clear',
'designFiles.selectAll': 'Select all',
'designFiles.clearSelection': 'Auswahl aufheben',
'designFiles.selectPage': 'Alle auf dieser Seite auswählen',
'designFiles.selectAll': 'Alle auswählen',
'designFiles.dropTitle': '⤓ Dateien hier ablegen',
'designFiles.dropDesc':
'Bilder, Docs, Referenzen oder Ordner — der Agent nutzt sie als Kontext.',
@ -525,6 +526,15 @@ export const de: Dict = {
'designFiles.kindPresentation': 'Präsentation',
'designFiles.kindSpreadsheet': 'Tabellenblatt',
'designFiles.kindBinary': 'Binärdatei',
'designFiles.colName': 'Name',
'designFiles.colKind': 'Art',
'designFiles.colModified': 'Geändert',
'designFiles.perPage': 'Anzeigen',
'designFiles.all': 'Alle',
'designFiles.prev': 'Zurück',
'designFiles.next': 'Weiter',
'designFiles.jumpToPage': 'Gehe zu Seite',
'designFiles.pageInfo': '{start}{end} von {total}',
'quickSwitcher.placeholder': 'Datei öffnen…',
'quickSwitcher.empty': 'Keine Dateien in diesem Projekt',
'quickSwitcher.noMatches': 'Keine Treffer',

View file

@ -620,7 +620,8 @@ export const en: Dict = {
'designFiles.downloadSelected': 'Download {n} as ZIP',
'designFiles.deleteSelected': 'Delete {n}',
'designFiles.clearSelection': 'Clear',
'designFiles.selectAll': 'Select all',
'designFiles.selectPage': 'Select all on page',
'designFiles.selectAll': 'Select everything',
'designFiles.dropTitle': '⤓ Drop files here',
'designFiles.dropDesc':
'Images, docs, references, or folders — the agent will use them as context.',
@ -650,6 +651,15 @@ export const en: Dict = {
'designFiles.kindSpreadsheet': 'Spreadsheet',
'designFiles.kindLiveArtifact': 'Live artifact',
'designFiles.kindBinary': 'Binary',
'designFiles.colName': 'Name',
'designFiles.colKind': 'Kind',
'designFiles.colModified': 'Modified',
'designFiles.perPage': 'Show',
'designFiles.all': 'All',
'designFiles.prev': 'Previous',
'designFiles.next': 'Next',
'designFiles.jumpToPage': 'Go to page',
'designFiles.pageInfo': '{start}{end} of {total}',
'quickSwitcher.placeholder': 'Open file…',
'quickSwitcher.empty': 'No files in this project',
'quickSwitcher.noMatches': 'No matches',

View file

@ -495,10 +495,11 @@ export const esES: Dict = {
'designFiles.rowMenu': 'Menú de la fila',
'designFiles.openInTab': 'Abrir en pestaña',
'designFiles.download': 'Descargar',
'designFiles.downloadSelected': 'Download {n} as ZIP',
'designFiles.downloadSelected': 'Descargar {n} como ZIP',
'designFiles.deleteSelected': 'Eliminar {n}',
'designFiles.clearSelection': 'Clear',
'designFiles.selectAll': 'Select all',
'designFiles.clearSelection': 'Limpiar selección',
'designFiles.selectPage': 'Seleccionar todo en la página',
'designFiles.selectAll': 'Seleccionar todo',
'designFiles.dropTitle': '⤓ Suelta archivos aquí',
'designFiles.dropDesc':
'Imágenes, documentos, referencias o carpetas: el agente los usará como contexto.',
@ -526,6 +527,15 @@ export const esES: Dict = {
'designFiles.kindPresentation': 'Presentación',
'designFiles.kindSpreadsheet': 'Hoja de cálculo',
'designFiles.kindBinary': 'Binario',
'designFiles.colName': 'Nombre',
'designFiles.colKind': 'Tipo',
'designFiles.colModified': 'Modificado',
'designFiles.perPage': 'Mostrar',
'designFiles.all': 'Todos',
'designFiles.prev': 'Anterior',
'designFiles.next': 'Siguiente',
'designFiles.jumpToPage': 'Ir a la página',
'designFiles.pageInfo': '{start}{end} de {total}',
'quickSwitcher.placeholder': 'Abrir archivo…',
'quickSwitcher.empty': 'No hay archivos en este proyecto',
'quickSwitcher.noMatches': 'Sin resultados',

View file

@ -610,6 +610,7 @@ export const fa: Dict = {
'هنوز هیچ چیزی اینجا نیست. فایل‌ها را رها کنید، یا یک طرح ایجاد کنید / متن بچسبانید.',
'designFiles.refresh': 'بازنشانی',
'designFiles.delete': 'حذف',
'designFiles.deleteSelected': 'حذف {n}',
'designFiles.searchPlaceholder': 'جستجوی فایل‌ها…',
'designFiles.up': 'بالا',
'designFiles.back': 'بازگشت',
@ -617,10 +618,10 @@ export const fa: Dict = {
'designFiles.rowMenu': 'منوی ردیف',
'designFiles.openInTab': 'باز کردن در تب',
'designFiles.download': 'دانلود',
'designFiles.downloadSelected': 'Download {n} as ZIP',
'designFiles.deleteSelected': 'حذف {n}',
'designFiles.clearSelection': 'Clear',
'designFiles.selectAll': 'Select all',
'designFiles.downloadSelected': 'دانلود {n} به صورت ZIP',
'designFiles.clearSelection': 'پاک کردن انتخاب',
'designFiles.selectPage': 'انتخاب همه در صفحه',
'designFiles.selectAll': 'انتخاب همه',
'designFiles.dropTitle': '⤓ فایل‌ها را اینجا رها کنید',
'designFiles.dropDesc':
'تصاویر، اسناد، مراجع یا پوشه‌ها — عامل از آن‌ها به عنوان زمینه استفاده خواهد کرد.',
@ -650,6 +651,15 @@ export const fa: Dict = {
'designFiles.kindSpreadsheet': 'صفحه گسترده',
'designFiles.kindLiveArtifact': 'مصنوع زنده',
'designFiles.kindBinary': 'باینری',
'designFiles.colName': 'نام',
'designFiles.colKind': 'نوع',
'designFiles.colModified': 'تغییر یافته',
'designFiles.perPage': 'نمایش',
'designFiles.all': 'همه',
'designFiles.prev': 'قبلی',
'designFiles.next': 'بعدی',
'designFiles.jumpToPage': 'برو به صفحه',
'designFiles.pageInfo': '{start}{end} از {total}',
'quickSwitcher.placeholder': 'باز کردن فایل…',
'quickSwitcher.empty': 'هیچ فایلی در این پروژه نیست',
'quickSwitcher.noMatches': 'بدون نتیجه',

View file

@ -606,10 +606,11 @@ export const fr: Dict = {
'designFiles.rowMenu': 'Menu de ligne',
'designFiles.openInTab': 'Ouvrir dans un onglet',
'designFiles.download': 'Télécharger',
'designFiles.downloadSelected': 'Download {n} as ZIP',
'designFiles.downloadSelected': 'Télécharger {n} sélectionnés en ZIP',
'designFiles.clearSelection': 'Effacer la sélection',
'designFiles.selectPage': 'Tout sélectionner sur la page',
'designFiles.selectAll': 'Tout sélectionner',
'designFiles.deleteSelected': 'Supprimer {n}',
'designFiles.clearSelection': 'Clear',
'designFiles.selectAll': 'Select all',
'designFiles.dropTitle': '⤓ Déposez les fichiers ici',
'designFiles.dropDesc':
'Images, documents, références ou dossiers — l\'agent les utilisera comme contexte.',
@ -637,6 +638,15 @@ export const fr: Dict = {
'designFiles.kindPresentation': 'Présentation',
'designFiles.kindSpreadsheet': 'Tableur',
'designFiles.kindBinary': 'Binaire',
'designFiles.colName': 'Nom',
'designFiles.colKind': 'Type',
'designFiles.colModified': 'Modifié le',
'designFiles.perPage': 'Afficher',
'designFiles.all': 'Tout',
'designFiles.prev': 'Précédent',
'designFiles.next': 'Suivant',
'designFiles.jumpToPage': 'Aller à la page',
'designFiles.pageInfo': '{start}{end} sur {total}',
'quickSwitcher.placeholder': 'Ouvrir un fichier…',
'quickSwitcher.empty': 'Aucun fichier dans ce projet',
'quickSwitcher.noMatches': 'Aucun résultat',

View file

@ -606,10 +606,11 @@ export const hu: Dict = {
'designFiles.rowMenu': 'Sor menü',
'designFiles.openInTab': 'Megnyitás lapon',
'designFiles.download': 'Letöltés',
'designFiles.downloadSelected': 'Download {n} as ZIP',
'designFiles.downloadSelected': '{n} fájl letöltése ZIP-ként',
'designFiles.clearSelection': 'Kijelölés törlése',
'designFiles.selectPage': 'Összes kijelölése az oldalon',
'designFiles.selectAll': 'Összes kijelölése',
'designFiles.deleteSelected': '{n} törlése',
'designFiles.clearSelection': 'Clear',
'designFiles.selectAll': 'Select all',
'designFiles.dropTitle': '⤓ Húzd ide a fájlokat',
'designFiles.dropDesc':
'Képek, dokumentumok, hivatkozások vagy mappák — az ügynök kontextusként használja őket.',
@ -637,6 +638,15 @@ export const hu: Dict = {
'designFiles.kindPresentation': 'Prezentáció',
'designFiles.kindSpreadsheet': 'Táblázat',
'designFiles.kindBinary': 'Bináris',
'designFiles.colName': 'Név',
'designFiles.colKind': 'Típus',
'designFiles.colModified': 'Módosítva',
'designFiles.perPage': 'Mutat',
'designFiles.all': 'Összes',
'designFiles.prev': 'Előző',
'designFiles.next': 'Következő',
'designFiles.jumpToPage': 'Ugrás oldalra',
'designFiles.pageInfo': '{start}{end} / {total}',
'quickSwitcher.placeholder': 'Fájl megnyitása…',
'quickSwitcher.empty': 'Nincsenek fájlok ebben a projektben',
'quickSwitcher.noMatches': 'Nincs találat',

View file

@ -708,6 +708,7 @@ export const id: Dict = {
'designFiles.downloadSelected': 'Unduh {n} sebagai ZIP',
'designFiles.deleteSelected': 'Hapus {n}',
'designFiles.clearSelection': 'Bersihkan',
'designFiles.selectPage': 'Pilih semua di halaman',
'designFiles.selectAll': 'Pilih semua',
'designFiles.dropTitle': 'Lepaskan file di sini',
'designFiles.dropDesc': 'Upload file desain, gambar, dokumen, atau aset lain ke proyek ini.',
@ -737,6 +738,15 @@ export const id: Dict = {
'designFiles.kindSpreadsheet': 'Spreadsheet',
'designFiles.kindLiveArtifact': 'Live artifact',
'designFiles.kindBinary': 'Biner',
'designFiles.colName': 'Nama',
'designFiles.colKind': 'Jenis',
'designFiles.colModified': 'Diubah',
'designFiles.perPage': 'Tampilkan',
'designFiles.all': 'Semua',
'designFiles.prev': 'Sebelumnya',
'designFiles.next': 'Berikutnya',
'designFiles.jumpToPage': 'Lompat ke halaman',
'designFiles.pageInfo': '{start}{end} dari {total}',
'quickSwitcher.placeholder': 'Buka file...',
'quickSwitcher.empty': 'Tidak ada file di proyek ini',
@ -913,15 +923,15 @@ export const id: Dict = {
'fileViewer.deployToProvider': 'Deploy ke {provider}',
'fileViewer.redeployToProvider': 'Deploy ulang ke {provider}',
'fileViewer.deployingToProvider': 'Deploying ke {provider}...',
'fileViewer.cloudflareApiToken': 'Cloudflare API token',
'fileViewer.cloudflareApiTokenGetLink': 'Dapatkan Cloudflare API token',
'fileViewer.cloudflareApiTokenPlaceholder': 'Tempelkan Cloudflare API token Anda',
'fileViewer.cloudflareApiTokenReuseHint': 'Cloudflare API token yang tersimpan akan digunakan. Masukkan token baru untuk menggantinya.',
'fileViewer.cloudflareApiTokenRequired': 'Masukkan dan simpan Cloudflare API token terlebih dahulu.',
'fileViewer.cloudflareApiTokenScopeHint': 'Pages Edit is required for deploys. Zone Read is required to list domains. DNS Edit is only needed when binding a custom domain.',
'fileViewer.cloudflareAccountId': 'Account ID',
'fileViewer.cloudflareAccountIdHint': 'Wajib. Temukan account ID di dashboard Cloudflare.',
'fileViewer.cloudflareAccountIdRequired': 'Masukkan dan simpan Cloudflare Account ID terlebih dahulu.',
'fileViewer.cloudflareApiToken': 'Token API Cloudflare',
'fileViewer.cloudflareApiTokenGetLink': 'Dapatkan token API Cloudflare',
'fileViewer.cloudflareApiTokenPlaceholder': 'Tempelkan token API Cloudflare Anda',
'fileViewer.cloudflareApiTokenReuseHint': 'Token API Cloudflare yang tersimpan akan digunakan. Masukkan token baru untuk menggantinya.',
'fileViewer.cloudflareApiTokenRequired': 'Masukkan dan simpan token API Cloudflare terlebih dahulu.',
'fileViewer.cloudflareApiTokenScopeHint': 'Token memerlukan Akun: Cloudflare Pages: Edit ditambah akses baca akun.',
'fileViewer.cloudflareAccountId': 'ID Akun',
'fileViewer.cloudflareAccountIdHint': 'Wajib. Temukan ID akun di dasbor Cloudflare.',
'fileViewer.cloudflareAccountIdRequired': 'Masukkan dan simpan ID Akun Cloudflare terlebih dahulu.',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',

View file

@ -493,10 +493,11 @@ export const ja: Dict = {
'designFiles.rowMenu': '行メニュー',
'designFiles.openInTab': 'タブで開く',
'designFiles.download': 'ダウンロード',
'designFiles.downloadSelected': 'Download {n} as ZIP',
'designFiles.downloadSelected': '{n}件をZIPとしてダウンロード',
'designFiles.deleteSelected': '{n} 件を削除',
'designFiles.clearSelection': 'Clear',
'designFiles.selectAll': 'Select all',
'designFiles.clearSelection': '選択をクリア',
'designFiles.selectPage': 'このページをすべて選択',
'designFiles.selectAll': 'すべて選択',
'designFiles.dropTitle': '⤓ ファイルをここにドロップ',
'designFiles.dropDesc':
'画像、ドキュメント、参考資料、フォルダー — エージェントがコンテキストとして使用します。',
@ -524,6 +525,15 @@ export const ja: Dict = {
'designFiles.kindPresentation': 'プレゼンテーション',
'designFiles.kindSpreadsheet': 'スプレッドシート',
'designFiles.kindBinary': 'バイナリ',
'designFiles.colName': '名前',
'designFiles.colKind': '種類',
'designFiles.colModified': '更新日',
'designFiles.perPage': '表示',
'designFiles.all': 'すべて',
'designFiles.prev': '前へ',
'designFiles.next': '次へ',
'designFiles.jumpToPage': 'ページにジャンプ',
'designFiles.pageInfo': '{start}{end} / {total}',
'quickSwitcher.placeholder': 'ファイルを開く…',
'quickSwitcher.empty': 'このプロジェクトにファイルがありません',
'quickSwitcher.noMatches': '一致なし',

View file

@ -606,10 +606,11 @@ export const ko: Dict = {
'designFiles.rowMenu': '항목 메뉴',
'designFiles.openInTab': '탭에서 열기',
'designFiles.download': '다운로드',
'designFiles.downloadSelected': 'Download {n} as ZIP',
'designFiles.downloadSelected': '{n}개를 ZIP으로 다운로드',
'designFiles.clearSelection': '선택 해제',
'designFiles.selectPage': '페이지에서 모두 선택',
'designFiles.selectAll': '모두 선택',
'designFiles.deleteSelected': '{n}개 삭제',
'designFiles.clearSelection': 'Clear',
'designFiles.selectAll': 'Select all',
'designFiles.dropTitle': '⤓ 여기에 파일을 놓으세요',
'designFiles.dropDesc':
'이미지, 문서, 참조 자료, 폴더 등을 놓아주세요. 에이전트가 문맥으로 활용합니다.',
@ -637,6 +638,15 @@ export const ko: Dict = {
'designFiles.kindPresentation': '프레젠테이션',
'designFiles.kindSpreadsheet': '스프레드시트',
'designFiles.kindBinary': '바이너리 파일',
'designFiles.colName': '이름',
'designFiles.colKind': '종류',
'designFiles.colModified': '수정일',
'designFiles.perPage': '표시',
'designFiles.all': '모두',
'designFiles.prev': '이전',
'designFiles.next': '다음',
'designFiles.jumpToPage': '페이지로 이동',
'designFiles.pageInfo': '{start}{end} / {total}',
'quickSwitcher.placeholder': '파일 열기…',
'quickSwitcher.empty': '이 프로젝트에 파일이 없습니다',
'quickSwitcher.noMatches': '일치 항목 없음',

View file

@ -606,10 +606,11 @@ export const pl: Dict = {
'designFiles.rowMenu': 'Menu wiersza',
'designFiles.openInTab': 'Otwórz w karcie',
'designFiles.download': 'Pobierz',
'designFiles.downloadSelected': 'Download {n} as ZIP',
'designFiles.downloadSelected': 'Pobierz {n} jako ZIP',
'designFiles.clearSelection': 'Wyczyść zaznaczenie',
'designFiles.selectPage': 'Zaznacz wszystko na stronie',
'designFiles.selectAll': 'Zaznacz wszystko',
'designFiles.deleteSelected': 'Usuń {n}',
'designFiles.clearSelection': 'Clear',
'designFiles.selectAll': 'Select all',
'designFiles.dropTitle': '⤓ Upuść pliki tutaj',
'designFiles.dropDesc':
'Obrazy, dokumenty, referencje lub foldery — agent użyje ich jako kontekstu.',
@ -637,6 +638,15 @@ export const pl: Dict = {
'designFiles.kindPresentation': 'Prezentacja',
'designFiles.kindSpreadsheet': 'Arkusz kalkulacyjny',
'designFiles.kindBinary': 'Plik binarny',
'designFiles.colName': 'Nazwa',
'designFiles.colKind': 'Rodzaj',
'designFiles.colModified': 'Zmodyfikowano',
'designFiles.perPage': 'Pokaż',
'designFiles.all': 'Wszystkie',
'designFiles.prev': 'Poprzednia',
'designFiles.next': 'Następna',
'designFiles.jumpToPage': 'Przejdź do strony',
'designFiles.pageInfo': '{start}{end} z {total}',
'quickSwitcher.placeholder': 'Otwórz plik…',
'quickSwitcher.empty': 'Brak plików w tym projekcie',
'quickSwitcher.noMatches': 'Brak wyników',

View file

@ -616,10 +616,11 @@ export const ptBR: Dict = {
'designFiles.rowMenu': 'Menu da linha',
'designFiles.openInTab': 'Abrir em aba',
'designFiles.download': 'Baixar',
'designFiles.downloadSelected': 'Download {n} as ZIP',
'designFiles.downloadSelected': 'Baixar {n} selecionados como ZIP',
'designFiles.clearSelection': 'Limpar seleção',
'designFiles.selectPage': 'Selecionar tudo na página',
'designFiles.selectAll': 'Selecionar tudo',
'designFiles.deleteSelected': 'Excluir {n}',
'designFiles.clearSelection': 'Clear',
'designFiles.selectAll': 'Select all',
'designFiles.dropTitle': '⤓ Solte arquivos aqui',
'designFiles.dropDesc':
'Imagens, docs, referências ou pastas — o agente usará tudo como contexto.',
@ -649,6 +650,15 @@ export const ptBR: Dict = {
'designFiles.kindSpreadsheet': 'Planilha',
'designFiles.kindLiveArtifact': 'Artefato live',
'designFiles.kindBinary': 'Binário',
'designFiles.colName': 'Nome',
'designFiles.colKind': 'Tipo',
'designFiles.colModified': 'Modificado',
'designFiles.perPage': 'Mostrar',
'designFiles.all': 'Todos',
'designFiles.prev': 'Anterior',
'designFiles.next': 'Próximo',
'designFiles.jumpToPage': 'Ir para a página',
'designFiles.pageInfo': '{start}{end} de {total}',
'quickSwitcher.placeholder': 'Abrir arquivo…',
'quickSwitcher.empty': 'Nenhum arquivo neste projeto',
'quickSwitcher.noMatches': 'Sem resultados',

View file

@ -616,10 +616,11 @@ export const ru: Dict = {
'designFiles.rowMenu': 'Меню строки',
'designFiles.openInTab': 'Открыть во вкладке',
'designFiles.download': 'Скачать',
'designFiles.downloadSelected': 'Download {n} as ZIP',
'designFiles.downloadSelected': 'Скачать {n} как ZIP',
'designFiles.clearSelection': 'Очистить выделение',
'designFiles.selectPage': 'Выбрать всё на странице',
'designFiles.selectAll': 'Выбрать всё',
'designFiles.deleteSelected': 'Удалить {n}',
'designFiles.clearSelection': 'Clear',
'designFiles.selectAll': 'Select all',
'designFiles.dropTitle': '⤓ Перетащите файлы сюда',
'designFiles.dropDesc':
'Изображения, документы, референсы или папки — агент будет использовать их как контекст.',
@ -649,6 +650,15 @@ export const ru: Dict = {
'designFiles.kindSpreadsheet': 'Таблица',
'designFiles.kindLiveArtifact': 'Live-артефакт',
'designFiles.kindBinary': 'Бинарный',
'designFiles.colName': 'Имя',
'designFiles.colKind': 'Тип',
'designFiles.colModified': 'Изменён',
'designFiles.perPage': 'Показать',
'designFiles.all': 'Все',
'designFiles.prev': 'Назад',
'designFiles.next': 'Вперёд',
'designFiles.jumpToPage': 'Перейти на страницу',
'designFiles.pageInfo': '{start}{end} из {total}',
'quickSwitcher.placeholder': 'Открыть файл…',
'quickSwitcher.empty': 'В проекте нет файлов',
'quickSwitcher.noMatches': 'Нет совпадений',

View file

@ -597,10 +597,11 @@ export const tr: Dict = {
'designFiles.rowMenu': 'Sıra menüsü',
'designFiles.openInTab': 'Sekmede aç',
'designFiles.download': 'İndir',
'designFiles.downloadSelected': 'Download {n} as ZIP',
'designFiles.downloadSelected': '{n} dosyayı ZIP olarak indir',
'designFiles.clearSelection': 'Seçimi temizle',
'designFiles.selectPage': 'Sayfadaki tümünü seç',
'designFiles.selectAll': 'Tümünü seç',
'designFiles.deleteSelected': '{n} sil',
'designFiles.clearSelection': 'Clear',
'designFiles.selectAll': 'Select all',
'designFiles.dropTitle': '⤓ Dosyaları buraya sürükleyin',
'designFiles.dropDesc':
'Görseller, dokümanlar, referanslar, veya klasörler — ajan onları bağlam olarak kullanacak.',
@ -628,6 +629,15 @@ export const tr: Dict = {
'designFiles.kindPresentation': 'Sunum',
'designFiles.kindSpreadsheet': 'Elektronik tablo',
'designFiles.kindBinary': 'Binary',
'designFiles.colName': 'Ad',
'designFiles.colKind': 'Tür',
'designFiles.colModified': 'Değiştirilme',
'designFiles.perPage': 'Göster',
'designFiles.all': 'Tümü',
'designFiles.prev': 'Önceki',
'designFiles.next': 'Sonraki',
'designFiles.jumpToPage': 'Sayfaya git',
'designFiles.pageInfo': '{start}{end} / {total}',
'quickSwitcher.placeholder': 'Dosya aç…',
'quickSwitcher.empty': 'Bu projede dosya yok',
'quickSwitcher.noMatches': 'Eşleşme yok',

View file

@ -617,10 +617,11 @@ export const uk: Dict = {
'designFiles.rowMenu': 'Меню рядка',
'designFiles.openInTab': 'Відкрити на вкладці',
'designFiles.download': 'Завантажити',
'designFiles.downloadSelected': 'Download {n} as ZIP',
'designFiles.downloadSelected': 'Завантажити {n} як ZIP',
'designFiles.clearSelection': 'Очистити виділення',
'designFiles.selectPage': 'Вибрати все на сторінці',
'designFiles.selectAll': 'Вибрати все',
'designFiles.deleteSelected': 'Видалити {n}',
'designFiles.clearSelection': 'Clear',
'designFiles.selectAll': 'Select all',
'designFiles.dropTitle': '⤓ Перенесіть файли сюди',
'designFiles.dropDesc':
'Зображення, документи, посилання або папки — агент використовуватиме їх як контекст.',
@ -650,6 +651,15 @@ export const uk: Dict = {
'designFiles.kindPresentation': 'Презентація',
'designFiles.kindSpreadsheet': 'Електронна таблиця',
'designFiles.kindBinary': 'Двійковий',
'designFiles.colName': 'Назва',
'designFiles.colKind': 'Тип',
'designFiles.colModified': 'Змінено',
'designFiles.perPage': 'Показати',
'designFiles.all': 'Усі',
'designFiles.prev': 'Назад',
'designFiles.next': 'Далі',
'designFiles.jumpToPage': 'Перейти на сторінку',
'designFiles.pageInfo': '{start}{end} із {total}',
'liveArtifact.refresh.button': 'Оновити',
'liveArtifact.refresh.buttonTitle': 'Оновити live-артефакт',
'liveArtifact.refresh.loadingTitle': 'Оновлення live-артефакту…',

View file

@ -610,6 +610,7 @@ export const zhCN: Dict = {
'designFiles.downloadSelected': '下载选中的 {n} 个文件为 ZIP',
'designFiles.deleteSelected': '删除 {n} 个',
'designFiles.clearSelection': '取消选择',
'designFiles.selectPage': '全选此页',
'designFiles.selectAll': '全选',
'designFiles.dropTitle': '⤓ 把文件拖到这里',
'designFiles.dropDesc': '图片、文档、参考资料或文件夹 — 智能体都会用作上下文。',
@ -639,6 +640,15 @@ export const zhCN: Dict = {
'designFiles.kindSpreadsheet': '电子表格',
'designFiles.kindLiveArtifact': '实时制品',
'designFiles.kindBinary': '二进制',
'designFiles.colName': '名称',
'designFiles.colKind': '类型',
'designFiles.colModified': '修改时间',
'designFiles.perPage': '显示',
'designFiles.all': '全部',
'designFiles.prev': '上一页',
'designFiles.next': '下一页',
'designFiles.jumpToPage': '跳转到页面',
'designFiles.pageInfo': '{start}{end} / {total}',
'quickSwitcher.placeholder': '打开文件…',
'quickSwitcher.empty': '此项目中没有文件',
'quickSwitcher.noMatches': '无匹配项',
@ -800,19 +810,19 @@ export const zhCN: Dict = {
'fileViewer.vercelToken': 'Vercel token',
'fileViewer.vercelTokenGetLink': '获取 Vercel token',
'fileViewer.vercelTokenPlaceholder': '粘贴你的 Vercel token',
'fileViewer.vercelTokenReuseHint': '将使用已保存的 token。输入新 token 可替换。',
'fileViewer.vercelTokenRequired': '请先输入并保存 Vercel token。',
'fileViewer.cloudflareApiToken': 'Cloudflare API token',
'fileViewer.cloudflareApiTokenGetLink': '获取 Cloudflare API token',
'fileViewer.cloudflareApiTokenPlaceholder': '粘贴你的 Cloudflare API token',
'fileViewer.cloudflareApiTokenReuseHint': '将使用已保存的 Cloudflare API token。输入新 token 可替换。',
'fileViewer.cloudflareApiTokenRequired': '请先输入并保存 Cloudflare API token。',
'fileViewer.cloudflareApiTokenScopeHint': 'Pages Edit 是部署必需权限;列出域名需要 Zone Read只有绑定自定义域名时才需要 DNS Edit。',
'fileViewer.vercelTeamId': 'Team ID',
'fileViewer.vercelTeamSlug': 'Team slug',
'fileViewer.cloudflareAccountId': 'Account ID',
'fileViewer.cloudflareAccountIdHint': '必填。可在 Cloudflare 控制台中找到账 ID。',
'fileViewer.cloudflareAccountIdRequired': '请先输入并保存 Cloudflare Account ID。',
'fileViewer.vercelTokenReuseHint': '将使用已保存的令牌。输入新令牌可替换。',
'fileViewer.vercelTokenRequired': '请先输入并保存 Vercel 令牌。',
'fileViewer.cloudflareApiToken': 'Cloudflare API 令牌',
'fileViewer.cloudflareApiTokenGetLink': '获取 Cloudflare API 令牌',
'fileViewer.cloudflareApiTokenPlaceholder': '粘贴你的 Cloudflare API 令牌',
'fileViewer.cloudflareApiTokenReuseHint': '将使用已保存的 Cloudflare API 令牌。输入新令牌可替换。',
'fileViewer.cloudflareApiTokenRequired': '请先输入并保存 Cloudflare API 令牌。',
'fileViewer.cloudflareApiTokenScopeHint': '令牌需要 Account: Cloudflare Pages: Edit 权限,以及账号读取权限。',
'fileViewer.vercelTeamId': '团队 ID',
'fileViewer.vercelTeamSlug': '团队标识',
'fileViewer.cloudflareAccountId': '账户 ID',
'fileViewer.cloudflareAccountIdHint': '必填。可在 Cloudflare 控制台中找到账 ID。',
'fileViewer.cloudflareAccountIdRequired': '请先输入并保存 Cloudflare 账户 ID。',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',

View file

@ -610,6 +610,7 @@ export const zhTW: Dict = {
'designFiles.downloadSelected': '下載選中的 {n} 個檔案為 ZIP',
'designFiles.deleteSelected': '刪除 {n} 個',
'designFiles.clearSelection': '取消選擇',
'designFiles.selectPage': '全選此頁',
'designFiles.selectAll': '全選',
'designFiles.dropTitle': '⤓ 把檔案拖到這裡',
'designFiles.dropDesc': '圖片、文件、參考資料或資料夾 — 智慧體都會用作上下文。',
@ -639,6 +640,15 @@ export const zhTW: Dict = {
'designFiles.kindSpreadsheet': '試算表',
'designFiles.kindLiveArtifact': '即時成品',
'designFiles.kindBinary': '二進位',
'designFiles.colName': '名稱',
'designFiles.colKind': '類型',
'designFiles.colModified': '修改時間',
'designFiles.perPage': '顯示',
'designFiles.all': '全部',
'designFiles.prev': '上一頁',
'designFiles.next': '下一頁',
'designFiles.jumpToPage': '跳轉到頁面',
'designFiles.pageInfo': '{start}{end} / {total}',
'quickSwitcher.placeholder': '開啟檔案…',
'quickSwitcher.empty': '此專案中沒有檔案',
'quickSwitcher.noMatches': '無符合項目',
@ -800,19 +810,19 @@ export const zhTW: Dict = {
'fileViewer.vercelToken': 'Vercel token',
'fileViewer.vercelTokenGetLink': '取得 Vercel token',
'fileViewer.vercelTokenPlaceholder': '貼上你的 Vercel token',
'fileViewer.vercelTokenReuseHint': '將使用已儲存的 token。輸入新 token 可替換。',
'fileViewer.vercelTokenRequired': '請先輸入並儲存 Vercel token。',
'fileViewer.cloudflareApiToken': 'Cloudflare API token',
'fileViewer.cloudflareApiTokenGetLink': '取得 Cloudflare API token',
'fileViewer.cloudflareApiTokenPlaceholder': '貼上你的 Cloudflare API token',
'fileViewer.cloudflareApiTokenReuseHint': '將使用已儲存的 Cloudflare API token。輸入新 token 可替換。',
'fileViewer.cloudflareApiTokenRequired': '請先輸入並儲存 Cloudflare API token。',
'fileViewer.cloudflareApiTokenScopeHint': 'Pages Edit 是部署必需權限;列出網域需要 Zone Read只有綁定自訂網域時才需要 DNS Edit。',
'fileViewer.vercelTeamId': 'Team ID',
'fileViewer.vercelTeamSlug': 'Team slug',
'fileViewer.cloudflareAccountId': 'Account ID',
'fileViewer.cloudflareAccountIdHint': '必填。可在 Cloudflare 控制台中找到帳 ID。',
'fileViewer.cloudflareAccountIdRequired': '請先輸入並儲存 Cloudflare Account ID。',
'fileViewer.vercelTokenReuseHint': '將使用已儲存的代碼。輸入新代碼可替換。',
'fileViewer.vercelTokenRequired': '請先輸入並儲存 Vercel 代碼。',
'fileViewer.cloudflareApiToken': 'Cloudflare API 代碼',
'fileViewer.cloudflareApiTokenGetLink': '取得 Cloudflare API 代碼',
'fileViewer.cloudflareApiTokenPlaceholder': '貼上你的 Cloudflare API 代碼',
'fileViewer.cloudflareApiTokenReuseHint': '將使用已儲存的 Cloudflare API 代碼。輸入新代碼可替換。',
'fileViewer.cloudflareApiTokenRequired': '請先輸入並儲存 Cloudflare API 代碼。',
'fileViewer.cloudflareApiTokenScopeHint': '代碼需要 Account: Cloudflare Pages: Edit 權限,以及帳號讀取權限。',
'fileViewer.vercelTeamId': '團隊 ID',
'fileViewer.vercelTeamSlug': '團隊標記',
'fileViewer.cloudflareAccountId': '帳戶 ID',
'fileViewer.cloudflareAccountIdHint': '必填。可在 Cloudflare 控制台中找到帳 ID。',
'fileViewer.cloudflareAccountIdRequired': '請先輸入並儲存 Cloudflare 帳戶 ID。',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',

View file

@ -770,6 +770,7 @@ export interface Dict {
'designFiles.downloadSelected': string;
'designFiles.deleteSelected': string;
'designFiles.clearSelection': string;
'designFiles.selectPage': string;
'designFiles.selectAll': string;
'designFiles.dropTitle': string;
'designFiles.dropDesc': string;
@ -799,6 +800,15 @@ export interface Dict {
'designFiles.kindSpreadsheet': string;
'designFiles.kindLiveArtifact': string;
'designFiles.kindBinary': string;
'designFiles.colName': string;
'designFiles.colKind': string;
'designFiles.colModified': string;
'designFiles.perPage': string;
'designFiles.all': string;
'designFiles.prev': string;
'designFiles.next': string;
'designFiles.jumpToPage': string;
'designFiles.pageInfo': string;
'quickSwitcher.placeholder': string;
'quickSwitcher.empty': string;
'quickSwitcher.noMatches': string;

View file

@ -7037,6 +7037,179 @@ button.connector-action.is-loading {
font-weight: 500;
letter-spacing: 0;
}
/* --- file list table --- */
.df-table {
width: 100%;
border-collapse: collapse;
table-layout: auto;
}
.df-table thead { border-bottom: 1px solid var(--border); }
.df-th-btn {
all: unset;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 2px;
width: 100%;
font: inherit;
color: inherit;
letter-spacing: inherit;
text-transform: inherit;
}
.df-th-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 2px;
}
.df-th-sortable {
cursor: pointer;
user-select: none;
font-size: 10.5px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
font-weight: 600;
padding: 12px 20px 6px;
text-align: left;
white-space: nowrap;
transition: color 120ms ease;
}
.df-th-sortable:hover { color: var(--text); }
.df-th-check, .df-cell-check { width: 28px; padding: 10px 0 10px 20px; }
.df-th-icon, .df-cell-icon { width: 36px; padding: 10px 0; }
.df-th-name { width: auto; }
.df-th-kind { width: 110px; }
.df-th-time { width: 100px; }
.df-th-menu, .df-cell-menu { width: 32px; padding: 10px 20px 10px 0; }
.df-sort-arrow { font-size: 10px; }
.df-file-row {
transition: background 120ms ease;
}
.df-file-row:hover { background: var(--bg-subtle); }
.df-file-row.active { background: var(--blue-bg); }
.df-file-row.active .df-row-name { color: var(--text-strong); }
.df-file-row.selected { background: var(--blue-bg); }
.df-cell-check {
text-align: center;
vertical-align: middle;
}
.df-cell-icon {
text-align: center;
vertical-align: middle;
}
.df-cell-name {
padding: 10px 12px 10px 0;
vertical-align: middle;
}
.df-row-name-btn {
all: unset;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.df-row-name-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 2px;
}
.df-cell-kind {
padding: 10px 12px 10px 0;
vertical-align: middle;
}
.df-cell-time {
padding: 10px 12px 10px 0;
vertical-align: middle;
font-size: 11.5px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.df-cell-menu {
text-align: center;
vertical-align: middle;
}
.df-kind-label {
font-size: 12px;
color: var(--text-muted);
}
/* --- pagination --- */
.df-pagination {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 20px;
font-size: 12px;
color: var(--text-muted);
flex-wrap: wrap;
}
.df-pagination-start {
justify-content: flex-start;
}
.df-pagination-center {
justify-content: center;
}
.df-pagination-end {
justify-content: flex-end;
}
.df-pagination-right {
margin-left: auto;
}
.df-pagination label {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.df-pagination select {
font-size: 12px;
padding: 2px 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
cursor: pointer;
}
.df-pagination select:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.df-page-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
padding: 4px 10px;
font-size: 12px;
border-radius: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
transition: background 120ms ease, color 120ms ease;
}
.df-page-btn:hover:not(:disabled) {
background: var(--bg-subtle);
color: var(--text);
}
.df-page-btn:disabled {
opacity: 0.4;
cursor: default;
}
.df-page-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.df-page-info {
white-space: nowrap;
color: var(--text-faint);
font-size: 11.5px;
}
.df-row {
display: grid;
grid-template-columns: 28px 36px 1fr auto auto;
@ -7078,16 +7251,20 @@ button.connector-action.is-loading {
.df-row-check:hover { background: var(--border); color: var(--text); }
.df-row-check[aria-checked="true"] { color: var(--accent-strong); }
.df-select-bar {
display: inline-flex;
align-items: center;
gap: 2px;
}
.df-select-all {
background: none;
border: none;
color: var(--text-muted);
color: var(--text-faint);
cursor: pointer;
font: inherit;
font-size: 11px;
font-weight: 500;
margin-left: auto;
padding: 2px 8px;
padding: 2px 6px;
border-radius: 4px;
transition: background 120ms ease, color 120ms ease;
}
@ -7145,6 +7322,7 @@ button.connector-action.is-loading {
transition: opacity 120ms ease;
}
.df-row:hover .df-row-menu { opacity: 1; }
.df-row-menu:focus { opacity: 1; }
.df-row-menu:hover { background: var(--border); color: var(--text); }
.df-section-more {
margin: 6px 20px 10px;

View file

@ -0,0 +1,154 @@
// @vitest-environment jsdom
import { fireEvent, render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { DesignFilesPanel } from '../../src/components/DesignFilesPanel';
import type { ProjectFile, ProjectFileKind } from '../../src/types';
function extForKind(kind: ProjectFileKind): string {
if (kind === 'html') return 'html';
if (kind === 'image') return 'png';
if (kind === 'sketch') return 'svg';
if (kind === 'text') return 'txt';
if (kind === 'code') return 'ts';
if (kind === 'pdf') return 'pdf';
return 'bin';
}
function generateFiles(count: number): ProjectFile[] {
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!,
size: 1024 * (i + 1),
mtime: Date.now() - i * 60_000,
mime: 'text/plain',
};
});
}
function renderPanel(files: ProjectFile[]) {
return render(
<DesignFilesPanel
projectId="test-project"
files={files}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
onOpenFile={vi.fn()}
onOpenLiveArtifact={vi.fn()}
onDeleteFile={vi.fn()}
onDeleteFiles={vi.fn()}
onUpload={vi.fn()}
onUploadFiles={vi.fn()}
onPaste={vi.fn()}
onNewSketch={vi.fn()}
/>,
);
}
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<HTMLButtonElement>('.df-page-btn'));
}
function getSelects(container: HTMLElement) {
return Array.from(container.querySelectorAll<HTMLSelectElement>('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('130 of 500');
const btns = getPageBtns(container);
fireEvent.click(btns[1]!);
expect(getPageInfo(container)).toContain('3160 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);
});
});

View file

@ -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();
}