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:
maziggy
2026-04-19 11:47:46 +02:00
parent b655b1211e
commit 5e5e8a519d
13 changed files with 225 additions and 100 deletions
+3
View File
@@ -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');
});
});
});
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -2854,6 +2854,9 @@ export default {
wrap: '折り返し',
enableTextWrapping: 'テキスト折り返しを有効化',
disableTextWrapping: 'テキスト折り返しを無効化',
collapse: '折りたたむ',
collapseFoldersByDefault: 'フォルダをデフォルトで折りたたむ',
expandFoldersByDefault: 'フォルダをデフォルトで展開する',
dragToResizeTooltip: 'ドラッグしてリサイズ、ダブルクリックでリセット',
searchFiles: 'ファイルを検索...',
allTypes: 'すべての種類',
+3
View File
@@ -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',
+3
View File
@@ -2881,6 +2881,9 @@ export default {
wrap: '换行',
enableTextWrapping: '启用文本换行',
disableTextWrapping: '禁用文本换行',
collapse: '折叠',
collapseFoldersByDefault: '默认折叠文件夹',
expandFoldersByDefault: '默认展开文件夹',
dragToResizeTooltip: '拖动调整大小,双击重置',
searchFiles: '搜索文件...',
allTypes: '所有类型',
+3
View File
@@ -2881,6 +2881,9 @@ export default {
wrap: '換行',
enableTextWrapping: '啟用文字換行',
disableTextWrapping: '停用文字換行',
collapse: '折疊',
collapseFoldersByDefault: '預設折疊資料夾',
expandFoldersByDefault: '預設展開資料夾',
dragToResizeTooltip: '拖曳調整大小,雙擊重設',
searchFiles: '搜尋檔案...',
allTypes: '所有類型',
+44 -19
View File
@@ -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
View File
@@ -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>