Browse Source

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.
maziggy 1 month ago
parent
commit
5e5e8a519d

+ 3 - 0
CHANGELOG.md

@@ -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.
 

+ 74 - 1
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -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 - 0
frontend/src/i18n/locales/de.ts

@@ -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 - 0
frontend/src/i18n/locales/en.ts

@@ -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 - 0
frontend/src/i18n/locales/fr.ts

@@ -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 - 0
frontend/src/i18n/locales/it.ts

@@ -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 - 0
frontend/src/i18n/locales/ja.ts

@@ -2854,6 +2854,9 @@ export default {
     wrap: '折り返し',
     enableTextWrapping: 'テキスト折り返しを有効化',
     disableTextWrapping: 'テキスト折り返しを無効化',
+    collapse: '折りたたむ',
+    collapseFoldersByDefault: 'フォルダをデフォルトで折りたたむ',
+    expandFoldersByDefault: 'フォルダをデフォルトで展開する',
     dragToResizeTooltip: 'ドラッグしてリサイズ、ダブルクリックでリセット',
     searchFiles: 'ファイルを検索...',
     allTypes: 'すべての種類',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -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 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -2881,6 +2881,9 @@ export default {
     wrap: '换行',
     enableTextWrapping: '启用文本换行',
     disableTextWrapping: '禁用文本换行',
+    collapse: '折叠',
+    collapseFoldersByDefault: '默认折叠文件夹',
+    expandFoldersByDefault: '默认展开文件夹',
     dragToResizeTooltip: '拖动调整大小,双击重置',
     searchFiles: '搜索文件...',
     allTypes: '所有类型',

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -2881,6 +2881,9 @@ export default {
     wrap: '換行',
     enableTextWrapping: '啟用文字換行',
     disableTextWrapping: '停用文字換行',
+    collapse: '折疊',
+    collapseFoldersByDefault: '預設折疊資料夾',
+    expandFoldersByDefault: '預設展開資料夾',
     dragToResizeTooltip: '拖曳調整大小,雙擊重設',
     searchFiles: '搜尋檔案...',
     allTypes: '所有類型',

+ 44 - 19
frontend/src/pages/FileManagerPage.tsx

@@ -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 it is too large
+ 0 - 0
static/assets/index-BD5LspKc.js


+ 1 - 1
static/index.html

@@ -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>

Some files were not shown because too many files changed in this diff