mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
feat(file-manager): collapse folders by default toggle (#996)
Add a "Collapse" toggle in the File Manager sidebar header next to "Wrap". When enabled, the folder tree opens with only top-level folders visible on every page load; disabled restores the previous fully-expanded default. Toggling the preference also immediately re-collapses or re-expands the current tree via a key-remount trick on each top-level FolderTreeItem, so the change takes effect without a page reload. Preference persists to localStorage under library-collapse-folders, matching the existing library-* convention. Backwards-compatible: FolderTreeItem gains an optional defaultExpanded prop defaulting to true, so no callers see a behavior change. Missing localStorage key coerces to false, so existing users keep the old expanded-by-default behavior until they flip the toggle. New strings added to all 8 locales under fileManager.*. Wiki "File Manager" page gains a "Folder sidebar preferences" section that documents both Wrap and Collapse toggles. Four vitest cases cover default, preloaded-collapsed, click-to-collapse, and click-to-expand paths.
This commit is contained in:
@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
|
||||
## [0.2.4b1] - Unreleased
|
||||
|
||||
### Improved
|
||||
- **File Manager: Collapse Folders by Default** ([#996](https://github.com/maziggy/bambuddy/issues/996)) — Added a **Collapse** toggle next to **Wrap** in the File Manager sidebar header. When enabled, the folder tree opens with only top-level folders visible on every page load; disabling it restores the previous fully-expanded default. Toggling the preference also immediately re-collapses/re-expands the current tree — no reload required. Persisted to localStorage under `library-collapse-folders`, matching the existing `library-*` preference pattern. Thanks to @AshieTashi for the request.
|
||||
|
||||
### Changed
|
||||
- **Docker runtime image on Debian Trixie** — The production Docker image now builds on `python:3.13-slim-trixie` instead of the Bookworm-based `python:3.13-slim`. Picks up ffmpeg 5 → 7 (HEVC/AV1 improvements for camera capture), OpenSSL 3.0 → 3.3, and two more years of APT package freshness. Frontend-builder stays on Bookworm until the Node.js image team publishes Trixie variants — users never see that stage.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Tests for the FileManagerPage component.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
@@ -859,4 +859,77 @@ describe('FileManagerPage', () => {
|
||||
expect(screen.getByText('testuser')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('folder tree collapse preference (#996)', () => {
|
||||
// localStorage is globally mocked in setup.ts (returns undefined by default),
|
||||
// so we program each test's getItem return value explicitly.
|
||||
const getItemMock = localStorage.getItem as ReturnType<typeof vi.fn>;
|
||||
const setItemMock = localStorage.setItem as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
getItemMock.mockReset();
|
||||
setItemMock.mockReset();
|
||||
});
|
||||
|
||||
it('defaults to expanded (nested folders visible) when library-collapse-folders is unset', async () => {
|
||||
getItemMock.mockReturnValue(null);
|
||||
render(<FileManagerPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Functional Parts')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Brackets')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('honors library-collapse-folders=true on load (nested folders hidden)', async () => {
|
||||
getItemMock.mockImplementation((key: string) =>
|
||||
key === 'library-collapse-folders' ? 'true' : null
|
||||
);
|
||||
render(<FileManagerPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Functional Parts')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Brackets')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses nested folders and persists preference when Collapse is clicked', async () => {
|
||||
getItemMock.mockReturnValue(null);
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Brackets')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The Collapse button sits next to Wrap in the sidebar header.
|
||||
// Its text content is "Collapse" (from fileManager.collapse).
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Brackets')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(setItemMock).toHaveBeenCalledWith('library-collapse-folders', 'true');
|
||||
});
|
||||
|
||||
it('re-expands nested folders and persists preference when Collapse is toggled off', async () => {
|
||||
getItemMock.mockImplementation((key: string) =>
|
||||
key === 'library-collapse-folders' ? 'true' : null
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Functional Parts')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Brackets')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Brackets')).toBeInTheDocument();
|
||||
});
|
||||
expect(setItemMock).toHaveBeenCalledWith('library-collapse-folders', 'false');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2894,6 +2894,9 @@ export default {
|
||||
wrap: 'Umbrechen',
|
||||
enableTextWrapping: 'Textumbruch aktivieren',
|
||||
disableTextWrapping: 'Textumbruch deaktivieren',
|
||||
collapse: 'Einklappen',
|
||||
collapseFoldersByDefault: 'Ordner standardmäßig einklappen',
|
||||
expandFoldersByDefault: 'Ordner standardmäßig ausklappen',
|
||||
dragToResizeTooltip: 'Ziehen zum Ändern der Größe, Doppelklick zum Zurücksetzen',
|
||||
searchFiles: 'Dateien suchen...',
|
||||
allTypes: 'Alle Typen',
|
||||
|
||||
@@ -2897,6 +2897,9 @@ export default {
|
||||
wrap: 'Wrap',
|
||||
enableTextWrapping: 'Enable text wrapping',
|
||||
disableTextWrapping: 'Disable text wrapping',
|
||||
collapse: 'Collapse',
|
||||
collapseFoldersByDefault: 'Collapse folders by default',
|
||||
expandFoldersByDefault: 'Expand folders by default',
|
||||
dragToResizeTooltip: 'Drag to resize, double-click to reset',
|
||||
searchFiles: 'Search files...',
|
||||
allTypes: 'All types',
|
||||
|
||||
@@ -2816,6 +2816,9 @@ export default {
|
||||
wrap: 'Retour ligne',
|
||||
enableTextWrapping: 'Activer retour ligne',
|
||||
disableTextWrapping: 'Désactiver retour ligne',
|
||||
collapse: 'Réduire',
|
||||
collapseFoldersByDefault: 'Réduire les dossiers par défaut',
|
||||
expandFoldersByDefault: 'Développer les dossiers par défaut',
|
||||
dragToResizeTooltip: 'Glisser pour redimensionner, double-clic reset',
|
||||
searchFiles: 'Chercher fichiers...',
|
||||
allTypes: 'Tous types',
|
||||
|
||||
@@ -2815,6 +2815,9 @@ export default {
|
||||
wrap: 'A capo',
|
||||
enableTextWrapping: 'Abilita a capo testo',
|
||||
disableTextWrapping: 'Disabilita a capo testo',
|
||||
collapse: 'Comprimi',
|
||||
collapseFoldersByDefault: 'Comprimi le cartelle per impostazione predefinita',
|
||||
expandFoldersByDefault: 'Espandi le cartelle per impostazione predefinita',
|
||||
dragToResizeTooltip: 'Trascina per ridimensionare, doppio clic per reset',
|
||||
searchFiles: 'Cerca file...',
|
||||
allTypes: 'Tutti i tipi',
|
||||
|
||||
@@ -2854,6 +2854,9 @@ export default {
|
||||
wrap: '折り返し',
|
||||
enableTextWrapping: 'テキスト折り返しを有効化',
|
||||
disableTextWrapping: 'テキスト折り返しを無効化',
|
||||
collapse: '折りたたむ',
|
||||
collapseFoldersByDefault: 'フォルダをデフォルトで折りたたむ',
|
||||
expandFoldersByDefault: 'フォルダをデフォルトで展開する',
|
||||
dragToResizeTooltip: 'ドラッグしてリサイズ、ダブルクリックでリセット',
|
||||
searchFiles: 'ファイルを検索...',
|
||||
allTypes: 'すべての種類',
|
||||
|
||||
@@ -2829,6 +2829,9 @@ export default {
|
||||
wrap: 'Quebrar texto',
|
||||
enableTextWrapping: 'Ativar quebra de texto',
|
||||
disableTextWrapping: 'Desativar quebra de texto',
|
||||
collapse: 'Recolher',
|
||||
collapseFoldersByDefault: 'Recolher pastas por padrão',
|
||||
expandFoldersByDefault: 'Expandir pastas por padrão',
|
||||
dragToResizeTooltip: 'Arraste para redimensionar, clique duas vezes para redefinir',
|
||||
searchFiles: 'Pesquisar arquivos...',
|
||||
allTypes: 'Todos os tipos',
|
||||
|
||||
@@ -2881,6 +2881,9 @@ export default {
|
||||
wrap: '换行',
|
||||
enableTextWrapping: '启用文本换行',
|
||||
disableTextWrapping: '禁用文本换行',
|
||||
collapse: '折叠',
|
||||
collapseFoldersByDefault: '默认折叠文件夹',
|
||||
expandFoldersByDefault: '默认展开文件夹',
|
||||
dragToResizeTooltip: '拖动调整大小,双击重置',
|
||||
searchFiles: '搜索文件...',
|
||||
allTypes: '所有类型',
|
||||
|
||||
@@ -2881,6 +2881,9 @@ export default {
|
||||
wrap: '換行',
|
||||
enableTextWrapping: '啟用文字換行',
|
||||
disableTextWrapping: '停用文字換行',
|
||||
collapse: '折疊',
|
||||
collapseFoldersByDefault: '預設折疊資料夾',
|
||||
expandFoldersByDefault: '預設展開資料夾',
|
||||
dragToResizeTooltip: '拖曳調整大小,雙擊重設',
|
||||
searchFiles: '搜尋檔案...',
|
||||
allTypes: '所有類型',
|
||||
|
||||
@@ -525,12 +525,13 @@ interface FolderTreeItemProps {
|
||||
onRename: (folder: LibraryFolderTree) => void;
|
||||
depth?: number;
|
||||
wrapNames?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
hasPermission: (permission: Permission) => boolean;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, hasPermission, t }: FolderTreeItemProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, defaultExpanded = true, hasPermission, t }: FolderTreeItemProps) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const hasChildren = folder.children.length > 0;
|
||||
const isLinked = folder.project_id || folder.archive_id;
|
||||
@@ -664,6 +665,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
|
||||
onRename={onRename}
|
||||
depth={depth + 1}
|
||||
wrapNames={wrapNames}
|
||||
defaultExpanded={defaultExpanded}
|
||||
hasPermission={hasPermission}
|
||||
t={t}
|
||||
/>
|
||||
@@ -915,6 +917,9 @@ export function FileManagerPage() {
|
||||
const [wrapFolderNames, setWrapFolderNames] = useState(() => {
|
||||
return localStorage.getItem('library-wrap-folders') === 'true';
|
||||
});
|
||||
const [collapseFoldersByDefault, setCollapseFoldersByDefault] = useState(() => {
|
||||
return localStorage.getItem('library-collapse-folders') === 'true';
|
||||
});
|
||||
|
||||
// Resizable sidebar state
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
@@ -1532,21 +1537,38 @@ export function FileManagerPage() {
|
||||
</div>
|
||||
<div className="p-3 border-b border-bambu-dark-tertiary flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium text-white">{t('fileManager.folders')}</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newValue = !wrapFolderNames;
|
||||
setWrapFolderNames(newValue);
|
||||
localStorage.setItem('library-wrap-folders', String(newValue));
|
||||
}}
|
||||
className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
|
||||
wrapFolderNames
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'text-bambu-gray hover:text-white hover:bg-bambu-dark'
|
||||
}`}
|
||||
title={wrapFolderNames ? t('fileManager.disableTextWrapping') : t('fileManager.enableTextWrapping')}
|
||||
>
|
||||
{t('fileManager.wrap')}
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
const newValue = !collapseFoldersByDefault;
|
||||
setCollapseFoldersByDefault(newValue);
|
||||
localStorage.setItem('library-collapse-folders', String(newValue));
|
||||
}}
|
||||
className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
|
||||
collapseFoldersByDefault
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'text-bambu-gray hover:text-white hover:bg-bambu-dark'
|
||||
}`}
|
||||
title={collapseFoldersByDefault ? t('fileManager.expandFoldersByDefault') : t('fileManager.collapseFoldersByDefault')}
|
||||
>
|
||||
{t('fileManager.collapse')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newValue = !wrapFolderNames;
|
||||
setWrapFolderNames(newValue);
|
||||
localStorage.setItem('library-wrap-folders', String(newValue));
|
||||
}}
|
||||
className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
|
||||
wrapFolderNames
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'text-bambu-gray hover:text-white hover:bg-bambu-dark'
|
||||
}`}
|
||||
title={wrapFolderNames ? t('fileManager.disableTextWrapping') : t('fileManager.enableTextWrapping')}
|
||||
>
|
||||
{t('fileManager.wrap')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{/* All Files (root) */}
|
||||
@@ -1562,10 +1584,12 @@ export function FileManagerPage() {
|
||||
<span className="text-sm">{t('fileManager.allFiles')}</span>
|
||||
</div>
|
||||
|
||||
{/* Folder tree */}
|
||||
{/* Folder tree — re-key on the collapse toggle so flipping it
|
||||
remounts every FolderTreeItem, which re-reads defaultExpanded
|
||||
and makes the preference take effect immediately. */}
|
||||
{folders?.map((folder) => (
|
||||
<FolderTreeItem
|
||||
key={folder.id}
|
||||
key={`${folder.id}-${collapseFoldersByDefault ? 'c' : 'e'}`}
|
||||
folder={folder}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelect={setSelectedFolderId}
|
||||
@@ -1573,6 +1597,7 @@ export function FileManagerPage() {
|
||||
onLink={setLinkFolder}
|
||||
onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
|
||||
wrapNames={wrapFolderNames}
|
||||
defaultExpanded={!collapseFoldersByDefault}
|
||||
hasPermission={hasPermission}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-D8a3o-KR.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BD5LspKc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-3s5orqQ4.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user