mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
9d7e4658df
commit
00b3f3e52d
23 changed files with 313 additions and 14 deletions
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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': 'آخر تعديل',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'تغییر یافته',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': '更新日',
|
||||
|
|
|
|||
|
|
@ -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': '수정일',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'Изменён',
|
||||
|
|
|
|||
|
|
@ -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': 'ใช้งานล่าสุด',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'Змінено',
|
||||
|
|
|
|||
|
|
@ -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': '修改时间',
|
||||
|
|
|
|||
|
|
@ -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': '修改時間',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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('☐');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue