ソースを参照

feat(file-manager): split "All Files" into internal-only + External views (#1621)

  Reporter linked a NAS and the auto-imported files drowned their own
  Bambuddy uploads in the "All Files" sidebar listing. There was no filter
  to escape it — only per-folder clicks. Restore the pre-external semantics:
  "All Files" now lists managed-storage files only. The combined
  across-every-external view moves to a new sibling sidebar entry,
  "External", that only appears when at least one external folder is linked.

  Backend: GET /api/v1/library/files gains internal_only and external_only
  query flags. Filter is on LibraryFile.is_external. Both flags set is a
  400, not a silent pick-one.

  Frontend: new topLevelView state on FileManagerPage (default internal);
  the query passes the scope only when selectedFolderId is null. Mobile
  selector dropdown uses __top:internal / __top:external sentinels so the
  same state round-trips through option values. Empty-state copy
  distinguishes internal-empty from external-empty.
maziggy 3 ヶ月 前
コミット
4851f54595

ファイルの差分が大きいため隠しています
+ 1 - 0
CHANGELOG.md


+ 19 - 0
backend/app/api/routes/library.py

@@ -1629,6 +1629,8 @@ async def list_files(
     folder_id: int | None = None,
     project_id: int | None = None,
     include_root: bool = True,
+    internal_only: bool = False,
+    external_only: bool = False,
     db: AsyncSession = Depends(get_db),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
 ):
@@ -1639,7 +1641,19 @@ async def list_files(
         project_id: Return all files across folders linked to this project (bulk fetch, avoids N+1).
         include_root: If True and folder_id is None, returns files at root level.
                      If False and folder_id is None, returns all files.
+        internal_only: Restrict the result to files in managed storage (`is_external=False`).
+                       Used by the File Manager's "All Files" sidebar entry so a linked NAS
+                       with hundreds of files doesn't drown the user's own uploads (#1621).
+        external_only: Restrict the result to files under external folders
+                       (`is_external=True`) — the symmetric combined view for users with
+                       multiple linked external sources (#1621).
     """
+    if internal_only and external_only:
+        raise HTTPException(
+            status_code=400,
+            detail="internal_only and external_only are mutually exclusive",
+        )
+
     query = LibraryFile.active().options(selectinload(LibraryFile.created_by))
 
     if folder_id is not None:
@@ -1651,6 +1665,11 @@ async def list_files(
     elif include_root:
         query = query.where(LibraryFile.folder_id.is_(None))
 
+    if internal_only:
+        query = query.where(LibraryFile.is_external.is_(False))
+    elif external_only:
+        query = query.where(LibraryFile.is_external.is_(True))
+
     query = query.order_by(LibraryFile.filename)
     result = await db.execute(query)
     files = result.scalars().all()

+ 46 - 0
backend/tests/integration/test_library_api.py

@@ -243,6 +243,52 @@ class TestLibraryFilesAPI:
         assert len(result) == 1
         assert result[0]["id"] == other_file.id
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_files_internal_only(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
+        """#1621: `internal_only=true` restricts the listing to files in managed
+        storage (`is_external=False`) so a linked NAS with hundreds of files
+        doesn't drown the user's own uploads in the "All Files" sidebar view."""
+        internal_folder = await folder_factory(name="My uploads")
+        external_folder = await folder_factory(name="NAS", is_external=True, external_path="/mnt/nas")
+
+        internal_file = await file_factory(folder_id=internal_folder.id, filename="mine.3mf", is_external=False)
+        await file_factory(folder_id=external_folder.id, filename="nas.3mf", is_external=True)
+        root_file = await file_factory(filename="root.3mf", is_external=False)  # Root-uploaded is always internal.
+
+        response = await async_client.get("/api/v1/library/files?include_root=false&internal_only=true")
+        assert response.status_code == 200
+        ids = {f["id"] for f in response.json()}
+        assert ids == {internal_file.id, root_file.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_files_external_only(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
+        """#1621 symmetric: `external_only=true` returns the combined view
+        across every linked external folder so users with several mounts can
+        see all external content in one place without clicking each folder."""
+        internal_folder = await folder_factory(name="My uploads")
+        nas_a = await folder_factory(name="NAS A", is_external=True, external_path="/mnt/a")
+        nas_b = await folder_factory(name="NAS B", is_external=True, external_path="/mnt/b")
+
+        await file_factory(folder_id=internal_folder.id, filename="mine.3mf", is_external=False)
+        ext_a = await file_factory(folder_id=nas_a.id, filename="a.3mf", is_external=True)
+        ext_b = await file_factory(folder_id=nas_b.id, filename="b.3mf", is_external=True)
+
+        response = await async_client.get("/api/v1/library/files?include_root=false&external_only=true")
+        assert response.status_code == 200
+        ids = {f["id"] for f in response.json()}
+        assert ids == {ext_a.id, ext_b.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_files_internal_and_external_mutually_exclusive(self, async_client: AsyncClient, db_session):
+        """Both flags together is a caller bug — fail loud (400) rather than
+        silently picking one, so a frontend regression is caught immediately."""
+        response = await async_client.get("/api/v1/library/files?internal_only=true&external_only=true")
+        assert response.status_code == 400
+        assert "mutually exclusive" in response.json()["detail"]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_get_file(self, async_client: AsyncClient, file_factory, db_session):

+ 91 - 0
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -933,6 +933,97 @@ describe('FileManagerPage', () => {
     });
   });
 
+  describe('Internal / External top-level views (#1621)', () => {
+    const externalMockFolders = [
+      ...mockFolders,
+      {
+        id: 99,
+        name: 'NAS Library',
+        parent_id: null,
+        file_count: 200,
+        project_id: null,
+        archive_id: null,
+        project_name: null,
+        archive_name: null,
+        is_external: true,
+        external_readonly: false,
+        external_path: '/mnt/nas',
+        children: [],
+      },
+    ];
+
+    it('shows the External sidebar entry only when at least one external folder is linked', async () => {
+      // Default mockFolders have no is_external entries → no External row.
+      const { unmount } = render(<FileManagerPage />);
+      await waitFor(() => {
+        expect(screen.getByText('All Files')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('External')).not.toBeInTheDocument();
+      unmount();
+
+      // With an external folder linked, the row appears.
+      server.use(
+        http.get('/api/v1/library/folders', () => HttpResponse.json(externalMockFolders)),
+      );
+      render(<FileManagerPage />);
+      await waitFor(() => {
+        expect(screen.getByText('External')).toBeInTheDocument();
+      });
+    });
+
+    it('sends internal_only=true by default ("All Files" = managed storage only)', async () => {
+      const scopes: string[] = [];
+      server.use(
+        http.get('/api/v1/library/folders', () => HttpResponse.json(externalMockFolders)),
+        http.get('/api/v1/library/files', ({ request }) => {
+          const url = new URL(request.url);
+          scopes.push(
+            url.searchParams.get('internal_only') === 'true'
+              ? 'internal'
+              : url.searchParams.get('external_only') === 'true'
+                ? 'external'
+                : 'all',
+          );
+          return HttpResponse.json(mockFiles);
+        }),
+      );
+
+      render(<FileManagerPage />);
+      await waitFor(() => {
+        expect(scopes).toContain('internal');
+      });
+    });
+
+    it('switches to external_only=true when the External sidebar entry is clicked', async () => {
+      const scopes: string[] = [];
+      server.use(
+        http.get('/api/v1/library/folders', () => HttpResponse.json(externalMockFolders)),
+        http.get('/api/v1/library/files', ({ request }) => {
+          const url = new URL(request.url);
+          scopes.push(
+            url.searchParams.get('internal_only') === 'true'
+              ? 'internal'
+              : url.searchParams.get('external_only') === 'true'
+                ? 'external'
+                : 'all',
+          );
+          return HttpResponse.json([]);
+        }),
+      );
+
+      const { default: userEvent } = await import('@testing-library/user-event');
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('External')).toBeInTheDocument());
+
+      await user.click(screen.getByText('External'));
+
+      await waitFor(() => {
+        expect(scopes).toContain('external');
+      });
+    });
+  });
+
   describe('"All Files" view (#1499)', () => {
     it('requests every file (include_root=false) so subfolder contents are visible', async () => {
       const rootFile = {

+ 8 - 1
frontend/src/api/client.ts

@@ -5541,7 +5541,12 @@ export const api = {
   getLibraryFoldersByArchive: (archiveId: number) =>
     request<LibraryFolder[]>(`/library/folders/by-archive/${archiveId}`),
 
-  getLibraryFiles: (folderId?: number | null, includeRoot = true, projectId?: number) => {
+  getLibraryFiles: (
+    folderId?: number | null,
+    includeRoot = true,
+    projectId?: number,
+    scope?: 'internal' | 'external',
+  ) => {
     const params = new URLSearchParams();
     if (folderId !== undefined && folderId !== null) {
       params.set('folder_id', String(folderId));
@@ -5550,6 +5555,8 @@ export const api = {
       params.set('project_id', String(projectId));
     }
     params.set('include_root', String(includeRoot));
+    if (scope === 'internal') params.set('internal_only', 'true');
+    else if (scope === 'external') params.set('external_only', 'true');
     return request<LibraryFileListItem[]>(`/library/files?${params}`);
   },
   getLibraryFile: (id: number) => request<LibraryFile>(`/library/files/${id}`),

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -3181,6 +3181,9 @@ export default {
     size: 'Größe',
     free: 'Frei',
     allFiles: 'Alle Dateien',
+    allExternal: 'Extern',
+    externalIsEmpty: 'Keine externen Dateien',
+    externalEmptyDescription: 'Dateien aus deinen verknüpften externen Ordnern erscheinen hier.',
     wrap: 'Umbrechen',
     enableTextWrapping: 'Textumbruch aktivieren',
     disableTextWrapping: 'Textumbruch deaktivieren',

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -3184,6 +3184,9 @@ export default {
     size: 'Size',
     free: 'Free',
     allFiles: 'All Files',
+    allExternal: 'External',
+    externalIsEmpty: 'No external files',
+    externalEmptyDescription: 'Files in your linked external folders will appear here.',
     wrap: 'Wrap',
     enableTextWrapping: 'Enable text wrapping',
     disableTextWrapping: 'Disable text wrapping',

+ 3 - 0
frontend/src/i18n/locales/es.ts

@@ -3184,6 +3184,9 @@ export default {
     size: 'Tamaño',
     free: 'Libre',
     allFiles: 'Todos los archivos',
+    allExternal: 'Externos',
+    externalIsEmpty: 'Sin archivos externos',
+    externalEmptyDescription: 'Los archivos de tus carpetas externas vinculadas aparecerán aquí.',
     wrap: 'Ajustar',
     enableTextWrapping: 'Activar el ajuste de texto',
     disableTextWrapping: 'Desactivar el ajuste de texto',

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -3170,6 +3170,9 @@ export default {
     size: 'Taille',
     free: 'Libre',
     allFiles: 'Tous les fichiers',
+    allExternal: 'Externes',
+    externalIsEmpty: 'Aucun fichier externe',
+    externalEmptyDescription: 'Les fichiers de vos dossiers externes liés apparaîtront ici.',
     wrap: 'Retour ligne',
     enableTextWrapping: 'Activer retour ligne',
     disableTextWrapping: 'Désactiver retour ligne',

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -3169,6 +3169,9 @@ export default {
     size: 'Dimensione',
     free: 'Libero',
     allFiles: 'Tutti i file',
+    allExternal: 'Esterni',
+    externalIsEmpty: 'Nessun file esterno',
+    externalEmptyDescription: 'I file delle tue cartelle esterne collegate appariranno qui.',
     wrap: 'A capo',
     enableTextWrapping: 'Abilita a capo testo',
     disableTextWrapping: 'Disabilita a capo testo',

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -3181,6 +3181,9 @@ export default {
     size: 'サイズ',
     free: '空き:',
     allFiles: 'すべてのファイル',
+    allExternal: '外部',
+    externalIsEmpty: '外部ファイルはありません',
+    externalEmptyDescription: 'リンクされた外部フォルダ内のファイルがここに表示されます。',
     wrap: '折り返し',
     enableTextWrapping: 'テキスト折り返しを有効化',
     disableTextWrapping: 'テキスト折り返しを無効化',

+ 3 - 0
frontend/src/i18n/locales/ko.ts

@@ -2994,6 +2994,9 @@ export default {
     size: '크기',
     free: '여유',
     allFiles: '모든 파일',
+    allExternal: '외부',
+    externalIsEmpty: '외부 파일 없음',
+    externalEmptyDescription: '연결된 외부 폴더의 파일이 여기에 표시됩니다.',
     wrap: '줄 바꿈',
     enableTextWrapping: '텍스트 줄 바꿈 활성화',
     disableTextWrapping: '텍스트 줄 바꿈 비활성화',

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

@@ -3169,6 +3169,9 @@ export default {
     size: 'Tamanho',
     free: 'Livre',
     allFiles: 'Todos os arquivos',
+    allExternal: 'Externos',
+    externalIsEmpty: 'Nenhum arquivo externo',
+    externalEmptyDescription: 'Os arquivos das suas pastas externas vinculadas aparecerão aqui.',
     wrap: 'Quebrar texto',
     enableTextWrapping: 'Ativar quebra de texto',
     disableTextWrapping: 'Desativar quebra de texto',

+ 3 - 0
frontend/src/i18n/locales/tr.ts

@@ -3176,6 +3176,9 @@ export default {
     size: 'Boyut',
     free: 'Boş',
     allFiles: 'Tüm Dosyalar',
+    allExternal: 'Harici',
+    externalIsEmpty: 'Harici dosya yok',
+    externalEmptyDescription: 'Bağlı harici klasörlerinizdeki dosyalar burada görünecek.',
     wrap: 'Sar',
     enableTextWrapping: 'Metin sarmayı etkinleştir',
     disableTextWrapping: 'Metin sarmayı devre dışı bırak',

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

@@ -3169,6 +3169,9 @@ export default {
     size: '大小',
     free: '剩余',
     allFiles: '所有文件',
+    allExternal: '外部',
+    externalIsEmpty: '没有外部文件',
+    externalEmptyDescription: '关联的外部文件夹中的文件将在此显示。',
     wrap: '换行',
     enableTextWrapping: '启用文本换行',
     disableTextWrapping: '禁用文本换行',

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

@@ -3169,6 +3169,9 @@ export default {
     size: '大小',
     free: '剩餘',
     allFiles: '所有檔案',
+    allExternal: '外部',
+    externalIsEmpty: '沒有外部檔案',
+    externalEmptyDescription: '已連結的外部資料夾中的檔案將顯示於此。',
     wrap: '換行',
     enableTextWrapping: '啟用文字換行',
     disableTextWrapping: '停用文字換行',

+ 68 - 13
frontend/src/pages/FileManagerPage.tsx

@@ -949,6 +949,11 @@ export function FileManagerPage() {
 
   // State
   const [selectedFolderId, setSelectedFolderId] = useState<number | null>(initialFolderId);
+  // Which top-level pseudo-view the sidebar shows when no specific folder is
+  // selected: "internal" = files in Bambuddy's managed storage, "external" =
+  // combined view across every linked external folder (#1621). Per-folder
+  // selection bypasses this (selectedFolderId !== null disables the filter).
+  const [topLevelView, setTopLevelView] = useState<'internal' | 'external'>('internal');
   const [selectedFiles, setSelectedFiles] = useState<number[]>([]);
   const [showNewFolderModal, setShowNewFolderModal] = useState(false);
   const [showExternalFolderModal, setShowExternalFolderModal] = useState(false);
@@ -1071,11 +1076,19 @@ export function FileManagerPage() {
   });
 
   const { data: files, isLoading: filesLoading } = useQuery({
-    queryKey: ['library-files', selectedFolderId],
-    // "All Files" (selectedFolderId === null) lists every file across folders,
-    // so include_root must be false — true would scope the result to files at
-    // the library root only and hide everything nested in subfolders (#1499).
-    queryFn: () => api.getLibraryFiles(selectedFolderId, false),
+    queryKey: ['library-files', selectedFolderId, topLevelView],
+    // When a specific folder is selected we list its contents directly; when
+    // no folder is selected the topLevelView pseudo-node decides whether the
+    // server scopes the result to internal-managed-storage files or to the
+    // union of every external folder (#1621). include_root stays false so the
+    // listing still descends into subfolders (regression guard from #1499).
+    queryFn: () =>
+      api.getLibraryFiles(
+        selectedFolderId,
+        false,
+        undefined,
+        selectedFolderId === null ? topLevelView : undefined,
+      ),
   });
 
   const { data: stats } = useQuery({
@@ -1577,11 +1590,22 @@ export function FileManagerPage() {
         {/* Mobile folder selector */}
         <div className="lg:hidden">
           <select
-            value={selectedFolderId ?? ''}
-            onChange={(e) => setSelectedFolderId(e.target.value ? parseInt(e.target.value, 10) : null)}
+            value={selectedFolderId !== null ? String(selectedFolderId) : `__top:${topLevelView}`}
+            onChange={(e) => {
+              const v = e.target.value;
+              if (v.startsWith('__top:')) {
+                setSelectedFolderId(null);
+                setTopLevelView(v.slice('__top:'.length) as 'internal' | 'external');
+              } else {
+                setSelectedFolderId(parseInt(v, 10));
+              }
+            }}
             className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-bambu-green"
           >
-            <option value="">📁 {t('fileManager.allFiles')}</option>
+            <option value="__top:internal">📁 {t('fileManager.allFiles')}</option>
+            {folders?.some((f) => f.is_external) && (
+              <option value="__top:external">🔗 {t('fileManager.allExternal')}</option>
+            )}
             {folders && (() => {
               // Flatten folder tree for mobile selector
               const flattenFolders = (items: LibraryFolderTree[], depth = 0): { id: number; name: string; fileCount: number; depth: number }[] => {
@@ -1667,19 +1691,44 @@ export function FileManagerPage() {
             </div>
           </div>
           <div className="flex-1 overflow-y-auto p-2">
-            {/* All Files (root) */}
+            {/* All Files = the user's own uploaded / managed-storage files
+                only. External folders are surfaced separately below to keep
+                a linked NAS from drowning the user's own uploads (#1621). */}
             <div
               className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors ${
-                selectedFolderId === null
+                selectedFolderId === null && topLevelView === 'internal'
                   ? 'bg-bambu-green/20 text-bambu-green'
                   : 'hover:bg-bambu-dark text-white'
               }`}
-              onClick={() => setSelectedFolderId(null)}
+              onClick={() => {
+                setSelectedFolderId(null);
+                setTopLevelView('internal');
+              }}
             >
               <FileBox className="w-4 h-4" />
               <span className="text-sm">{t('fileManager.allFiles')}</span>
             </div>
 
+            {/* External (combined) — only shown when at least one external
+                folder is linked. Single folder users don't need a combined
+                view; clicking the individual folder is just as fast. */}
+            {folders?.some((f) => f.is_external) && (
+              <div
+                className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors ${
+                  selectedFolderId === null && topLevelView === 'external'
+                    ? 'bg-bambu-green/20 text-bambu-green'
+                    : 'hover:bg-bambu-dark text-white'
+                }`}
+                onClick={() => {
+                  setSelectedFolderId(null);
+                  setTopLevelView('external');
+                }}
+              >
+                <FolderSymlink className="w-4 h-4 text-purple-400" />
+                <span className="text-sm">{t('fileManager.allExternal')}</span>
+              </div>
+            )}
+
             {/* 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. */}
@@ -1952,12 +2001,18 @@ export function FileManagerPage() {
                 <FileBox className="w-12 h-12 text-bambu-gray/50" />
               </div>
               <h3 className="text-lg font-medium text-white mb-2">
-                {selectedFolderId !== null ? t('fileManager.folderIsEmpty') : t('fileManager.noFilesYet')}
+                {selectedFolderId !== null
+                  ? t('fileManager.folderIsEmpty')
+                  : topLevelView === 'external'
+                    ? t('fileManager.externalIsEmpty')
+                    : t('fileManager.noFilesYet')}
               </h3>
               <p className="text-bambu-gray text-center max-w-md mb-6">
                 {selectedFolderId !== null
                   ? t('fileManager.folderEmptyDescription')
-                  : t('fileManager.noFilesDescription')}
+                  : topLevelView === 'external'
+                    ? t('fileManager.externalEmptyDescription')
+                    : t('fileManager.noFilesDescription')}
               </p>
               <Button
                 onClick={() => setShowUploadModal(true)}

ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/index-DrAXd6Gv.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-C7bMJs5u.js"></script>
+    <script type="module" crossorigin src="/assets/index-DrAXd6Gv.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Df3XYvpK.css">
   </head>
   <body>

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません