feat(design-files): add directory navigation and localization for folders (#2442)

* feat(design-files): add directory support and localization for folders in design files panel

* feat(directory-navigation): implement directory navigation and selection reset in DesignFilesPanel

* feat(rename): improve draft handling for file renaming in DesignFilesPanel
This commit is contained in:
Alan Matias 2026-05-22 06:44:01 -03:00 committed by GitHub
parent 9d7e4658df
commit 00b3f3e52d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 313 additions and 14 deletions

View file

@ -118,12 +118,36 @@ export function DesignFilesPanel({
const [kindFilter, setKindFilter] = useState<Set<ProjectFileKind>>(() => new Set());
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
const filterMenuRef = useRef<HTMLDivElement | null>(null);
const [currentDir, setCurrentDir] = useState<string>('');
// Derive immediate subdirectories and files at the current directory level
// from the flat files list. Files with names like "a/b/c.html" contribute
// "a" as a directory when currentDir is '' and "b" when currentDir is "a".
const { dirsAtCurrentDir, filesAtCurrentDir } = useMemo(() => {
const prefix = currentDir === '' ? '' : `${currentDir}/`;
const dirs = new Set<string>();
const localFiles: ProjectFile[] = [];
for (const f of files) {
if (!f.name.startsWith(prefix)) continue;
const remainder = f.name.slice(prefix.length);
const slashIdx = remainder.indexOf('/');
if (slashIdx === -1) {
localFiles.push(f);
} else {
dirs.add(remainder.slice(0, slashIdx));
}
}
return {
dirsAtCurrentDir: [...dirs].sort((a, b) => a.localeCompare(b)),
filesAtCurrentDir: localFiles,
};
}, [files, currentDir]);
const kindCounts = useMemo(() => {
const counts = new Map<ProjectFileKind, number>();
for (const f of files) counts.set(f.kind, (counts.get(f.kind) ?? 0) + 1);
for (const f of filesAtCurrentDir) counts.set(f.kind, (counts.get(f.kind) ?? 0) + 1);
return counts;
}, [files]);
}, [filesAtCurrentDir]);
const availableKinds = useMemo(
() =>
@ -151,9 +175,9 @@ export function DesignFilesPanel({
}, [availableKinds]);
const filteredFiles = useMemo(() => {
if (kindFilter.size === 0) return files;
return files.filter((f) => kindFilter.has(f.kind));
}, [files, kindFilter]);
if (kindFilter.size === 0) return filesAtCurrentDir;
return filesAtCurrentDir.filter((f) => kindFilter.has(f.kind));
}, [filesAtCurrentDir, kindFilter]);
const sortedFiles = useMemo(() => {
return [...filteredFiles].sort((a, b) => {
@ -198,7 +222,7 @@ export function DesignFilesPanel({
);
const rangeStart = safePage * effectivePageSize + 1;
const rangeEnd = Math.min((safePage + 1) * effectivePageSize, sortedFiles.length);
const allPageSelected = pageFiles.every((f) => selected.has(f.name));
const allPageSelected = pageFiles.length > 0 && pageFiles.every((f) => selected.has(f.name));
const somePageSelected = !allPageSelected && pageFiles.some((f) => selected.has(f.name));
const hasMultiplePages = totalPages > 1;
const showListControls = sortedFiles.length > 15 || selected.size > 0;
@ -233,6 +257,31 @@ export function DesignFilesPanel({
});
}, [filteredFiles, kindFilter]);
// Reset page, selection, and renaming state when the user navigates
// into or out of a directory.
useEffect(() => {
setPage(0);
setSelected(new Set());
setRenaming(null);
}, [currentDir]);
// Navigate up to the nearest ancestor that still exists when files under
// currentDir disappear (e.g. after deleting the last file in a subfolder).
useEffect(() => {
if (currentDir === '') return;
const prefix = `${currentDir}/`;
if (files.some((f) => f.name.startsWith(prefix))) return;
const parts = currentDir.split('/');
for (let i = parts.length - 1; i > 0; i--) {
const ancestor = parts.slice(0, i).join('/');
if (files.some((f) => f.name.startsWith(`${ancestor}/`))) {
setCurrentDir(ancestor);
return;
}
}
setCurrentDir('');
}, [files, currentDir]);
// Outside-click + escape to close the filter popover. Stops short of a
// full focus trap because the popover hosts only checkboxes plus a
// small clear button; the existing tab order through them is fine.
@ -400,12 +449,18 @@ export function DesignFilesPanel({
function startRename(name: string) {
setMenuPos(null);
setPreview(name);
setRenaming({ name, draft: name, saving: false });
const draft = currentDir === '' ? name : name.slice(currentDir.length + 1);
setRenaming({ name, draft, saving: false });
}
async function commitRename(name: string, draft: string) {
const nextName = draft.trim();
if (!nextName || nextName === name) {
const nextBasename = draft.trim();
if (!nextBasename) {
setRenaming(null);
return;
}
const nextName = currentDir === '' ? nextBasename : `${currentDir}/${nextBasename}`;
if (nextName === name) {
setRenaming(null);
return;
}
@ -553,7 +608,7 @@ export function DesignFilesPanel({
}}
>
<span className="df-row-name-wrap">
<span className="df-row-name">{f.name}</span>
<span className="df-row-name">{currentDir === '' ? f.name : f.name.slice(currentDir.length + 1)}</span>
<span className="df-row-sub">{humanBytes(f.size)}</span>
</span>
</button>
@ -600,8 +655,38 @@ export function DesignFilesPanel({
);
}
function renderDirRow(dirName: string) {
const fullPath = currentDir === '' ? dirName : `${currentDir}/${dirName}`;
const prefix = `${fullPath}/`;
const count = files.filter((f) => f.name.startsWith(prefix)).length;
return (
<tr key={`dir:${fullPath}`} className="df-file-row df-dir-row">
<td className="df-cell-check" />
<td className="df-cell-icon df-cell-openable" onClick={() => setCurrentDir(fullPath)}>
<span className="df-row-icon" data-kind="folder" aria-hidden>
<Icon name="folder" size={14} />
</span>
</td>
<td className="df-cell-name df-cell-openable" onClick={() => setCurrentDir(fullPath)}>
<button type="button" className="df-row-name-btn" onClick={() => setCurrentDir(fullPath)}>
<span className="df-row-name-wrap">
<span className="df-row-name">{dirName}</span>
<span className="df-row-sub">{t('designFiles.folderCount', { n: count })}</span>
</span>
</button>
</td>
<td className="df-cell-kind df-cell-openable" onClick={() => setCurrentDir(fullPath)}>
<span className="df-kind-label">{t('designFiles.kindFolder')}</span>
</td>
<td className="df-cell-time df-cell-openable" onClick={() => setCurrentDir(fullPath)} />
<td className="df-cell-menu" />
</tr>
);
}
function renderModifiedSections() {
return visibleModifiedSections.flatMap((section) => {
const dirRows = dirsAtCurrentDir.map((d) => renderDirRow(d));
const sectionRows = visibleModifiedSections.flatMap((section) => {
const sectionFiles = modifiedGroups[section];
const collapsed = collapsedModifiedSections.has(section);
const label = t(MODIFIED_SECTION_LABEL_KEY[section]);
@ -624,9 +709,11 @@ export function DesignFilesPanel({
...(collapsed ? [] : sectionFiles.map(renderFileRow)),
];
});
return [...dirRows, ...sectionRows];
}
function renderKindSections() {
const dirRows = dirsAtCurrentDir.map((d) => renderDirRow(d));
const grouped = new Map<ProjectFileKind, ProjectFile[]>();
for (const file of pageFiles) {
const next = grouped.get(file.kind) ?? [];
@ -634,7 +721,7 @@ export function DesignFilesPanel({
grouped.set(file.kind, next);
}
return [...grouped.entries()]
const kindRows = [...grouped.entries()]
.sort(([a], [b]) => kindSortPriority(a) - kindSortPriority(b))
.flatMap(([kind, kindFiles]) => [
<tr className="df-section-row" key={`${kind}-label`}>
@ -647,6 +734,7 @@ export function DesignFilesPanel({
</tr>,
...kindFiles.map(renderFileRow),
]);
return [...dirRows, ...kindRows];
}
async function handleBatchDownload() {
@ -908,6 +996,37 @@ export function DesignFilesPanel({
{kindFilterControl}
{fileActions}
</div>
{currentDir !== '' ? (
<nav className="df-breadcrumbs" aria-label={t('designFiles.crumbs')}>
<button
type="button"
className="df-breadcrumb-btn"
onClick={() => setCurrentDir('')}
>
{t('designFiles.crumbs')}
</button>
{currentDir.split('/').map((segment, idx, parts) => {
const path = parts.slice(0, idx + 1).join('/');
const isLast = idx === parts.length - 1;
return (
<span key={path} className="df-breadcrumb-segment">
<span className="df-breadcrumb-sep" aria-hidden>/</span>
{isLast ? (
<span className="df-breadcrumb-current">{segment}</span>
) : (
<button
type="button"
className="df-breadcrumb-btn"
onClick={() => setCurrentDir(path)}
>
{segment}
</button>
)}
</span>
);
})}
</nav>
) : null}
{files.length === 0 && liveArtifacts.length === 0 ? (
<div className="df-empty" data-testid="design-files-empty">
<div className="df-empty-pill">
@ -1033,7 +1152,7 @@ export function DesignFilesPanel({
))}
</div>
) : null}
{sortedFiles.length > 0 ? (
{(sortedFiles.length > 0 || dirsAtCurrentDir.length > 0) ? (
<>
{showListControls ? (
<div className="df-pagination df-pagination-start">
@ -1131,7 +1250,7 @@ export function DesignFilesPanel({
? renderModifiedSections()
: groupMode === 'kind'
? renderKindSections()
: pageFiles.map(renderFileRow)}
: [...dirsAtCurrentDir.map(renderDirRow), ...pageFiles.map(renderFileRow)]}
</tbody>
</table>
{hasMultiplePages ? (

View file

@ -895,6 +895,8 @@ export const ar: Dict = {
'designFiles.kindPresentation': 'عرض تقديمي',
'designFiles.kindSpreadsheet': 'جدول بيانات',
'designFiles.kindBinary': 'ثنائي',
'designFiles.kindFolder': 'مجلد',
'designFiles.folderCount': '{n} ملفات',
'designFiles.colName': 'الاسم',
'designFiles.colKind': 'النوع',
'designFiles.colModified': 'آخر تعديل',

View file

@ -783,6 +783,8 @@ export const de: Dict = {
'designFiles.kindPresentation': 'Präsentation',
'designFiles.kindSpreadsheet': 'Tabellenblatt',
'designFiles.kindBinary': 'Binärdatei',
'designFiles.kindFolder': 'Ordner',
'designFiles.folderCount': '{n} Dateien',
'designFiles.colName': 'Name',
'designFiles.colKind': 'Art',
'designFiles.colModified': 'Geändert',

View file

@ -1486,6 +1486,8 @@ export const en: Dict = {
'designFiles.kindSpreadsheet': 'Spreadsheet',
'designFiles.kindLiveArtifact': 'Live artifact',
'designFiles.kindBinary': 'Binary',
'designFiles.kindFolder': 'Folder',
'designFiles.folderCount': '{n} files',
'designFiles.colName': 'Name',
'designFiles.colKind': 'Kind',
'designFiles.colModified': 'Modified',

View file

@ -784,6 +784,8 @@ export const esES: Dict = {
'designFiles.kindPresentation': 'Presentación',
'designFiles.kindSpreadsheet': 'Hoja de cálculo',
'designFiles.kindBinary': 'Binario',
'designFiles.kindFolder': 'Carpeta',
'designFiles.folderCount': '{n} archivos',
'designFiles.colName': 'Nombre',
'designFiles.colKind': 'Tipo',
'designFiles.colModified': 'Modificado',

View file

@ -919,6 +919,8 @@ export const fa: Dict = {
'designFiles.kindSpreadsheet': 'صفحه گسترده',
'designFiles.kindLiveArtifact': 'مصنوع زنده',
'designFiles.kindBinary': 'باینری',
'designFiles.kindFolder': 'پوشه',
'designFiles.folderCount': '{n} فایل',
'designFiles.colName': 'نام',
'designFiles.colKind': 'نوع',
'designFiles.colModified': 'تغییر یافته',

View file

@ -911,6 +911,8 @@ export const fr: Dict = {
'designFiles.kindPresentation': 'Présentation',
'designFiles.kindSpreadsheet': 'Tableur',
'designFiles.kindBinary': 'Binaire',
'designFiles.kindFolder': 'Dossier',
'designFiles.folderCount': '{n} fichiers',
'designFiles.colName': 'Nom',
'designFiles.colKind': 'Type',
'designFiles.colModified': 'Modifié le',

View file

@ -895,6 +895,8 @@ export const hu: Dict = {
'designFiles.kindPresentation': 'Prezentáció',
'designFiles.kindSpreadsheet': 'Táblázat',
'designFiles.kindBinary': 'Bináris',
'designFiles.kindFolder': 'Mappa',
'designFiles.folderCount': '{n} fájl',
'designFiles.colName': 'Név',
'designFiles.colKind': 'Típus',
'designFiles.colModified': 'Módosítva',

View file

@ -1008,6 +1008,8 @@ export const id: Dict = {
'designFiles.kindSpreadsheet': 'Spreadsheet',
'designFiles.kindLiveArtifact': 'Live artifact',
'designFiles.kindBinary': 'Biner',
'designFiles.kindFolder': 'Folder',
'designFiles.folderCount': '{n} file',
'designFiles.colName': 'Nama',
'designFiles.colKind': 'Jenis',
'designFiles.colModified': 'Diubah',

View file

@ -811,6 +811,8 @@ export const it: Dict = {
'designFiles.kindPresentation': 'Presentazione',
'designFiles.kindSpreadsheet': 'Foglio di calcolo',
'designFiles.kindBinary': 'Binario',
'designFiles.kindFolder': 'Cartella',
'designFiles.folderCount': '{n} file',
'designFiles.colName': 'Nome',
'designFiles.colKind': 'Tipo',
'designFiles.colModified': 'Modificato il',

View file

@ -782,6 +782,8 @@ export const ja: Dict = {
'designFiles.kindPresentation': 'プレゼンテーション',
'designFiles.kindSpreadsheet': 'スプレッドシート',
'designFiles.kindBinary': 'バイナリ',
'designFiles.kindFolder': 'フォルダ',
'designFiles.folderCount': '{n} ファイル',
'designFiles.colName': '名前',
'designFiles.colKind': '種類',
'designFiles.colModified': '更新日',

View file

@ -895,6 +895,8 @@ export const ko: Dict = {
'designFiles.kindPresentation': '프레젠테이션',
'designFiles.kindSpreadsheet': '스프레드시트',
'designFiles.kindBinary': '바이너리 파일',
'designFiles.kindFolder': '폴더',
'designFiles.folderCount': '{n}개 파일',
'designFiles.colName': '이름',
'designFiles.colKind': '종류',
'designFiles.colModified': '수정일',

View file

@ -895,6 +895,8 @@ export const pl: Dict = {
'designFiles.kindPresentation': 'Prezentacja',
'designFiles.kindSpreadsheet': 'Arkusz kalkulacyjny',
'designFiles.kindBinary': 'Plik binarny',
'designFiles.kindFolder': 'Folder',
'designFiles.folderCount': '{n} plików',
'designFiles.colName': 'Nazwa',
'designFiles.colKind': 'Rodzaj',
'designFiles.colModified': 'Zmodyfikowano',

View file

@ -918,6 +918,8 @@ export const ptBR: Dict = {
'designFiles.kindSpreadsheet': 'Planilha',
'designFiles.kindLiveArtifact': 'Artefato live',
'designFiles.kindBinary': 'Binário',
'designFiles.kindFolder': 'Pasta',
'designFiles.folderCount': '{n} arquivos',
'designFiles.colName': 'Nome',
'designFiles.colKind': 'Tipo',
'designFiles.colModified': 'Modificado',

View file

@ -918,6 +918,8 @@ export const ru: Dict = {
'designFiles.kindSpreadsheet': 'Таблица',
'designFiles.kindLiveArtifact': 'Live-артефакт',
'designFiles.kindBinary': 'Бинарный',
'designFiles.kindFolder': 'Папка',
'designFiles.folderCount': '{n} файлов',
'designFiles.colName': 'Имя',
'designFiles.colKind': 'Тип',
'designFiles.colModified': 'Изменён',

View file

@ -836,6 +836,8 @@ export const th: Dict = {
'designFiles.kindSpreadsheet': 'ตารางตัวเลข',
'designFiles.kindLiveArtifact': 'ตัวแอป Live artifact',
'designFiles.kindBinary': 'ไฟล์ไบนารี',
'designFiles.kindFolder': 'โฟลเดอร์',
'designFiles.folderCount': '{n} ไฟล์',
'designFiles.colName': 'ชื่อ',
'designFiles.colKind': 'ชนิด',
'designFiles.colModified': 'ใช้งานล่าสุด',

View file

@ -882,6 +882,8 @@ export const tr: Dict = {
'designFiles.kindPresentation': 'Sunum',
'designFiles.kindSpreadsheet': 'Elektronik tablo',
'designFiles.kindBinary': 'Binary',
'designFiles.kindFolder': 'Klasör',
'designFiles.folderCount': '{n} dosya',
'designFiles.colName': 'Ad',
'designFiles.colKind': 'Tür',
'designFiles.colModified': 'Değiştirilme',

View file

@ -919,6 +919,8 @@ export const uk: Dict = {
'designFiles.kindPresentation': 'Презентація',
'designFiles.kindSpreadsheet': 'Електронна таблиця',
'designFiles.kindBinary': 'Двійковий',
'designFiles.kindFolder': 'Папка',
'designFiles.folderCount': '{n} файлів',
'designFiles.colName': 'Назва',
'designFiles.colKind': 'Тип',
'designFiles.colModified': 'Змінено',

View file

@ -1476,6 +1476,8 @@ export const zhCN: Dict = {
'designFiles.kindSpreadsheet': '电子表格',
'designFiles.kindLiveArtifact': '实时制品',
'designFiles.kindBinary': '二进制',
'designFiles.kindFolder': '文件夹',
'designFiles.folderCount': '{n} 个文件',
'designFiles.colName': '名称',
'designFiles.colKind': '类型',
'designFiles.colModified': '修改时间',

View file

@ -1087,6 +1087,8 @@ export const zhTW: Dict = {
'designFiles.kindSpreadsheet': '試算表',
'designFiles.kindLiveArtifact': '即時成品',
'designFiles.kindBinary': '二進位',
'designFiles.kindFolder': '資料夾',
'designFiles.folderCount': '{n} 個檔案',
'designFiles.colName': '名稱',
'designFiles.colKind': '類型',
'designFiles.colModified': '修改時間',

View file

@ -1798,6 +1798,8 @@ export interface Dict {
'designFiles.kindSpreadsheet': string;
'designFiles.kindLiveArtifact': string;
'designFiles.kindBinary': string;
'designFiles.kindFolder': string;
'designFiles.folderCount': string;
'designFiles.colName': string;
'designFiles.colKind': string;
'designFiles.colModified': string;

View file

@ -10059,6 +10059,29 @@ button.connector-action.is-loading {
flex: 1 1 auto;
min-width: 0;
}
.df-breadcrumbs {
display: flex;
align-items: center;
flex-wrap: wrap;
padding: 0 20px 6px;
font-size: 12px;
color: var(--text-muted);
}
.df-breadcrumb-btn {
all: unset;
cursor: pointer;
color: var(--text-muted);
padding: 2px 4px;
border-radius: 3px;
font-size: 12px;
transition: color 120ms cubic-bezier(0.23, 1, 0.32, 1), background 120ms cubic-bezier(0.23, 1, 0.32, 1);
}
.df-breadcrumb-btn:hover { color: var(--text); background: var(--bg-subtle); }
.df-breadcrumb-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.df-breadcrumb-segment { display: inline-flex; align-items: center; }
.df-breadcrumb-sep { padding: 0 2px; color: var(--text-faint); font-size: 11px; user-select: none; }
.df-breadcrumb-current { padding: 2px 4px; color: var(--text); font-weight: 500; font-size: 12px; }
.df-dir-row td { cursor: pointer; }
.df-controls-row .df-actions {
margin-left: 0;
display: inline-flex;

View file

@ -494,3 +494,120 @@ describe('DesignFilesPanel large-list regression', () => {
expect(elapsed).toBeLessThan(2000);
});
});
describe('DesignFilesPanel directory navigation', () => {
afterEach(() => {
cleanup();
});
it('collapses nested files into a single folder row at root with correct descendant count', () => {
renderPanel([
file({ name: 'assets/logo.png', kind: 'image' }),
file({ name: 'assets/icons/star.svg', kind: 'image' }),
]);
const dirRows = document.querySelectorAll('.df-dir-row');
expect(dirRows.length).toBe(1);
expect(dirRows[0]!.textContent).toContain('assets');
expect(dirRows[0]!.textContent).toContain('2');
});
it('clicking a folder row navigates into it and shows only basenames and nested dirs', () => {
renderPanel([
file({ name: 'assets/logo.png', kind: 'image' }),
file({ name: 'assets/icons/star.svg', kind: 'image' }),
]);
fireEvent.click(document.querySelector('.df-dir-row .df-row-name-btn')!);
expect(document.querySelector('.df-breadcrumbs')).toBeTruthy();
expect(document.querySelector('.df-breadcrumb-current')?.textContent).toBe('assets');
const fileRow = screen.getByTestId('design-file-row-assets/logo.png');
expect(fileRow.querySelector('.df-row-name')?.textContent).toBe('logo.png');
expect(fileRow.querySelector('.df-row-name')?.textContent).not.toContain('assets/');
const dirRows = document.querySelectorAll('.df-dir-row');
expect(dirRows.length).toBe(1);
expect(dirRows[0]!.textContent).toContain('icons');
});
it('clicking the root breadcrumb navigates back to root', () => {
renderPanel([
file({ name: 'assets/logo.png', kind: 'image' }),
file({ name: 'top.html', kind: 'html' }),
]);
fireEvent.click(document.querySelector('.df-dir-row .df-row-name-btn')!);
expect(document.querySelector('.df-breadcrumbs')).toBeTruthy();
fireEvent.click(document.querySelector('.df-breadcrumb-btn')!);
expect(document.querySelector('.df-breadcrumbs')).toBeNull();
expect(screen.getByTestId('design-file-row-top.html')).toBeTruthy();
expect(document.querySelectorAll('.df-dir-row').length).toBe(1);
});
it('clears selection and resets page when navigating into or out of a directory', () => {
renderPanel([
file({ name: 'assets/logo.png', kind: 'image' }),
file({ name: 'top.html', kind: 'html' }),
]);
const topRow = screen.getByTestId('design-file-row-top.html');
fireEvent.click(topRow.querySelector('.df-row-check')!);
expect(topRow.classList.contains('selected')).toBe(true);
fireEvent.click(document.querySelector('.df-dir-row .df-row-name-btn')!);
expect(document.querySelectorAll('.df-file-row.selected').length).toBe(0);
fireEvent.click(document.querySelector('.df-breadcrumb-btn')!);
expect(document.querySelectorAll('.df-file-row.selected').length).toBe(0);
});
it('resets currentDir automatically when all files in the current subdirectory are removed', () => {
function makePanel(files: ProjectFile[]) {
return (
<DesignFilesPanel
projectId="test-project"
files={files}
liveArtifacts={[]}
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()}
/>
);
}
const { rerender } = render(
makePanel([
file({ name: 'assets/logo.png', kind: 'image' }),
file({ name: 'top.html', kind: 'html' }),
]),
);
fireEvent.click(document.querySelector('.df-dir-row .df-row-name-btn')!);
expect(document.querySelector('.df-breadcrumb-current')?.textContent).toBe('assets');
rerender(makePanel([file({ name: 'top.html', kind: 'html' })]));
expect(document.querySelector('.df-breadcrumbs')).toBeNull();
expect(screen.getByTestId('design-file-row-top.html')).toBeTruthy();
});
it('does not show the select-all header as checked when the page contains only directory rows', () => {
renderPanel([
file({ name: 'assets/logo.png', kind: 'image' }),
]);
const headerCheck = document.querySelector('.df-th-check .df-row-check');
expect(headerCheck?.textContent).toBe('☐');
});
});