Просмотр исходного кода

feat(file-manager): recursive subfolder search + per-folder markdown description panel (#1268)

  Reporter (@zumik3-del, seconded by @unLieb) asked for three File Manager
  improvements: recursive search, tags, and a markdown preview side panel.
  This commit ships the two scoped ones; tags is held back gated on the
  "give the issue a thumbs up" interest check Martin posted on the issue
  because it's a much larger surface (M2M schema, CRUD endpoints, tag UI +
  filter + autocomplete + i18n for the management surface) and isn't the
  right call without a real demand signal.

  1) Recursive search inside the selected folder.

     Until now, selecting "Toys" and typing "robot" only found files
     directly in Toys/ — anything under Toys/Cars/Race/ stayed invisible.
     The page's client-side filter ran over a server-narrowed listing
     (/library/files?folder_id=X is strict equality on folder_id), so the
     client filter couldn't see what the listing never loaded.

     list_files (backend/app/api/routes/library.py:1729+) gains a
     recursive=true query param. When combined with folder_id, the route
     walks library_folders.parent_id via a recursive CTE rooted at the
     requested folder and returns every descendant folder's files in one
     query. Recursive CTEs work on both SQLite >=3.8.3 (2014, well below
     Bambuddy's runtime floor) and Postgres without dialect branching.
     Default off so the existing folder-browsing call sites (Project /
     Archive detail, the FE's no-search case) keep their narrow scope.

     FE opts in only when both a folder is selected AND searchQuery is
     non-empty (FileManagerPage.tsx — derived as searchExpandsSubfolders,
     threaded through the useQuery key so the cache invalidates on
     toggle). Small "Including subfolders" caption renders under the
     search input when active so the user understands why a file from two
     levels deep showed up.

  2) Per-folder markdown description panel.

     New endpoint GET /library/folders/{folder_id}/readme returns the
     first .md file in the folder as {filename, content, truncated}.
     Selection prefers README.md / readme.md / description.md
     (case-insensitive via func.lower(filename) LIKE '%.md' + an
     in-Python stem-preference sort), falls back to the
     alphabetically-first *.md otherwise. 404 when no markdown is present
     so the FE can hide the side panel — non-users pay no UI cost.

     Bytes are clipped at 512 KiB (_README_BYTES_CAP) with a truncated
     flag so the panel can warn the reader. UTF-8 decode uses
     errors="replace" so one bad byte never blanks the panel.

     New FolderReadmePanel.tsx fetches on folder-select and renders via
     react-markdown@9 + remark-gfm@4 (tables, strikethrough, task lists).
     Collapsible (default expanded), max-height 24rem with internal
     scroll. react-markdown 9 doesn't render raw HTML by default — no
     dompurify needed. Links open in a new tab with rel=noopener
     noreferrer. Tailwind has no typography plugin in this project so
     per-element components map h1/h2/h3/p/ul/ol/code/blockquote/table
     to explicit utility classes that match the rest of the app.

  Scope and permissions.

  Both endpoints reuse the existing LIBRARY_READ_ALL / LIBRARY_READ_OWN
  ownership-aware pair, so a viewer-tier user with read_own only sees
  their own files in recursive listings and can only fetch the README of
  folders containing their own files. No new permission, no DB migration.

  The recursive CTE is a single SQL query — no N+1, no per-folder
  round-trip, scales to deeply-nested model libraries.
maziggy 2 месяцев назад
Родитель
Сommit
5cbefca6a0

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 83 - 1
backend/app/api/routes/library.py

@@ -54,6 +54,7 @@ from backend.app.schemas.library import (
     FileUpdate,
     FileUploadResponse,
     FolderCreate,
+    FolderReadmeResponse,
     FolderResponse,
     FolderTreeItem,
     FolderUpdate,
@@ -1035,6 +1036,72 @@ async def get_folder(
     )
 
 
+_README_BYTES_CAP = 512 * 1024  # 512 KiB — model descriptions don't need more
+_README_PREFERRED_STEMS = ("readme", "description")
+
+
+@router.get("/folders/{folder_id}/readme", response_model=FolderReadmeResponse)
+async def get_folder_readme(
+    folder_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
+):
+    """Return the first markdown description file for a folder (#1268).
+
+    Picks ``README.md`` / ``readme.md`` / ``description.md`` first (any case),
+    otherwise the alphabetically-first ``*.md`` in the folder. 404 when no
+    markdown file is present so the FE can hide the side panel.
+    """
+    user, can_read_all = auth_result
+
+    folder_row = await db.execute(select(LibraryFolder.id).where(LibraryFolder.id == folder_id))
+    if folder_row.scalar_one_or_none() is None:
+        raise HTTPException(status_code=404, detail="Folder not found")
+
+    query = LibraryFile.active().where(
+        LibraryFile.folder_id == folder_id,
+        func.lower(LibraryFile.filename).like("%.md"),
+    )
+    if user is not None and not can_read_all:
+        query = query.where(LibraryFile.created_by_id == user.id)
+    result = await db.execute(query)
+    candidates = result.scalars().all()
+    if not candidates:
+        raise HTTPException(status_code=404, detail="No markdown description in folder")
+
+    def sort_key(f: LibraryFile) -> tuple[int, str]:
+        stem = os.path.splitext(f.filename.lower())[0]
+        try:
+            return (_README_PREFERRED_STEMS.index(stem), f.filename.lower())
+        except ValueError:
+            return (len(_README_PREFERRED_STEMS), f.filename.lower())
+
+    pick = sorted(candidates, key=sort_key)[0]
+
+    abs_path = to_absolute_path(pick.file_path)
+    if not abs_path or not abs_path.exists():
+        raise HTTPException(status_code=404, detail="Markdown file missing on disk")
+
+    try:
+        raw = abs_path.read_bytes()
+    except OSError as e:
+        logger.warning("Folder readme read failed for %s: %s", abs_path, e)
+        raise HTTPException(status_code=500, detail="Could not read markdown file") from None
+
+    truncated = len(raw) > _README_BYTES_CAP
+    if truncated:
+        raw = raw[:_README_BYTES_CAP]
+    # `errors="replace"` so a single bad byte never blanks the panel.
+    content = raw.decode("utf-8", errors="replace")
+
+    return FolderReadmeResponse(filename=pick.filename, content=content, truncated=truncated)
+
+
 @router.put("/folders/{folder_id}", response_model=FolderResponse)
 async def update_folder(
     folder_id: int,
@@ -1722,6 +1789,7 @@ async def list_files(
     include_root: bool = True,
     internal_only: bool = False,
     external_only: bool = False,
+    recursive: bool = False,
     db: AsyncSession = Depends(get_db),
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
@@ -1743,6 +1811,11 @@ async def list_files(
         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).
+        recursive: When combined with ``folder_id``, also include files in every
+                   descendant subfolder (#1268). Implemented via a recursive CTE
+                   that walks ``library_folders.parent_id``. Default off so
+                   existing callers (folder browsing, etc.) keep their narrow
+                   single-folder semantics.
     """
     if internal_only and external_only:
         raise HTTPException(
@@ -1755,7 +1828,16 @@ async def list_files(
     if user is not None and not can_read_all:
         query = query.where(LibraryFile.created_by_id == user.id)
 
-    if folder_id is not None:
+    if folder_id is not None and recursive:
+        # Walk the subtree starting at folder_id and collect every descendant
+        # id. Recursive CTE works on both SQLite (>=3.8.3, shipped 2014) and
+        # Postgres without dialect branching.
+        roots = (
+            select(LibraryFolder.id).where(LibraryFolder.id == folder_id).cte(name="folder_descendants", recursive=True)
+        )
+        descendants = roots.union_all(select(LibraryFolder.id).join(roots, LibraryFolder.parent_id == roots.c.id))
+        query = query.where(LibraryFile.folder_id.in_(select(descendants.c.id)))
+    elif folder_id is not None:
         query = query.where(LibraryFile.folder_id == folder_id)
     elif project_id is not None:
         # Single join instead of one query per folder (avoids N+1 pattern)

+ 13 - 0
backend/app/schemas/library.py

@@ -63,6 +63,19 @@ class FolderResponse(BaseModel):
         from_attributes = True
 
 
+class FolderReadmeResponse(BaseModel):
+    """Markdown sidebar payload for a folder (#1268).
+
+    ``filename`` is the on-disk name (so the UI can show "README.md") and
+    ``content`` is the raw markdown — the FE renders it. ``truncated`` is
+    True when the source file was clipped at the size cap.
+    """
+
+    filename: str
+    content: str
+    truncated: bool
+
+
 class FolderTreeItem(BaseModel):
     """Schema for folder tree item (includes children)."""
 

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

@@ -487,6 +487,139 @@ class TestLibraryFilesAPI:
         assert test_file["created_by_id"] == user.id
         assert test_file["created_by_username"] == "testuploader"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_files_recursive_includes_subfolders(
+        self, async_client: AsyncClient, folder_factory, file_factory
+    ):
+        """#1268: ?recursive=true with folder_id must include every descendant.
+
+        Tree:
+            toys/             ← f_toys, direct file "robot_top.3mf"
+              cars/           ← child of toys, file "robot_car.3mf"
+                race/         ← grandchild, file "robot_race.3mf"
+            other/            ← unrelated, file "robot_other.3mf" (must NOT appear)
+        """
+        toys = await folder_factory(name="toys")
+        cars = await folder_factory(name="cars", parent_id=toys.id)
+        race = await folder_factory(name="race", parent_id=cars.id)
+        other = await folder_factory(name="other")
+
+        top = await file_factory(folder_id=toys.id, filename="robot_top.3mf")
+        mid = await file_factory(folder_id=cars.id, filename="robot_car.3mf")
+        deep = await file_factory(folder_id=race.id, filename="robot_race.3mf")
+        await file_factory(folder_id=other.id, filename="robot_other.3mf")
+
+        # Non-recursive: only the file directly under toys.
+        r = await async_client.get(f"/api/v1/library/files?folder_id={toys.id}")
+        assert r.status_code == 200
+        assert {f["id"] for f in r.json()} == {top.id}
+
+        # Recursive: toys + cars + race files, but NOT other/.
+        r = await async_client.get(f"/api/v1/library/files?folder_id={toys.id}&recursive=true")
+        assert r.status_code == 200
+        assert {f["id"] for f in r.json()} == {top.id, mid.id, deep.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_files_recursive_without_folder_id_is_noop(
+        self, async_client: AsyncClient, folder_factory, file_factory
+    ):
+        """recursive=true is meaningful only with folder_id — without it the
+        existing include_root branch handles scoping. Just confirming the new
+        param doesn't shadow that path."""
+        folder = await folder_factory()
+        f_in = await file_factory(folder_id=folder.id)
+        f_root = await file_factory()
+
+        r = await async_client.get("/api/v1/library/files?include_root=false&recursive=true")
+        assert r.status_code == 200
+        assert {f["id"] for f in r.json()} == {f_in.id, f_root.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_folder_readme_returns_first_markdown(
+        self, async_client: AsyncClient, folder_factory, file_factory
+    ):
+        """#1268: /folders/{id}/readme reads on-disk content of the first .md."""
+        folder = await folder_factory()
+        with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") as f:
+            f.write("# Robot\n\nA cute little robot.")
+            md_path = f.name
+        try:
+            await file_factory(
+                folder_id=folder.id,
+                filename="README.md",
+                file_path=md_path,
+                file_type="md",
+                file_size=Path(md_path).stat().st_size,
+            )
+            r = await async_client.get(f"/api/v1/library/folders/{folder.id}/readme")
+            assert r.status_code == 200
+            body = r.json()
+            assert body["filename"] == "README.md"
+            assert body["content"] == "# Robot\n\nA cute little robot."
+            assert body["truncated"] is False
+        finally:
+            import os
+
+            os.unlink(md_path)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_folder_readme_prefers_readme_over_other_md(
+        self, async_client: AsyncClient, folder_factory, file_factory
+    ):
+        """When the folder has multiple .md files, README.md / description.md
+        wins regardless of insertion order or filename case."""
+        folder = await folder_factory()
+        with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") as f:
+            f.write("notes notes notes")
+            notes_path = f.name
+        with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") as f:
+            f.write("the real one")
+            readme_path = f.name
+        try:
+            # notes.md inserted FIRST — naive ordering would pick this one.
+            await file_factory(
+                folder_id=folder.id,
+                filename="notes.md",
+                file_path=notes_path,
+                file_type="md",
+            )
+            await file_factory(
+                folder_id=folder.id,
+                filename="readme.md",  # lowercase to confirm case-insensitive match
+                file_path=readme_path,
+                file_type="md",
+            )
+            r = await async_client.get(f"/api/v1/library/folders/{folder.id}/readme")
+            assert r.status_code == 200
+            assert r.json()["filename"] == "readme.md"
+            assert r.json()["content"] == "the real one"
+        finally:
+            import os
+
+            os.unlink(notes_path)
+            os.unlink(readme_path)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_folder_readme_404_when_no_markdown(
+        self, async_client: AsyncClient, folder_factory, file_factory
+    ):
+        """No .md in the folder → 404 so the FE can hide the side panel."""
+        folder = await folder_factory()
+        await file_factory(folder_id=folder.id, filename="model.3mf", file_type="3mf")
+        r = await async_client.get(f"/api/v1/library/folders/{folder.id}/readme")
+        assert r.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_folder_readme_404_when_folder_missing(self, async_client: AsyncClient):
+        r = await async_client.get("/api/v1/library/folders/999999/readme")
+        assert r.status_code == 404
+
 
 class TestLibraryAddToQueueAPI:
     """Integration tests for /api/v1/library/files/add-to-queue endpoint."""

Разница между файлами не показана из-за своего большого размера
+ 1166 - 98
frontend/package-lock.json


+ 2 - 0
frontend/package.json

@@ -39,9 +39,11 @@
     "react": "^19.2.0",
     "react-dom": "^19.2.0",
     "react-i18next": "^16.3.5",
+    "react-markdown": "^9.1.0",
     "react-router-dom": "^7.16.0",
     "react-simple-keyboard": "^3.8.164",
     "recharts": "^3.5.1",
+    "remark-gfm": "^4.0.1",
     "three": "^0.181.2"
   },
   "overrides": {

+ 58 - 0
frontend/src/__tests__/components/FolderReadmePanel.test.tsx

@@ -0,0 +1,58 @@
+/**
+ * Tests for FolderReadmePanel (#1268).
+ */
+
+import { describe, it, expect } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { FolderReadmePanel } from '../../components/FolderReadmePanel';
+import { server } from '../mocks/server';
+
+describe('FolderReadmePanel', () => {
+  it('renders nothing when the folder has no markdown (404)', async () => {
+    server.use(
+      http.get('/api/v1/library/folders/:id/readme', () =>
+        HttpResponse.json({ detail: 'No markdown' }, { status: 404 }),
+      ),
+    );
+    render(<FolderReadmePanel folderId={1} />);
+    // Wait briefly so the query has time to resolve, then confirm no panel
+    // chrome leaked into the DOM (the test render util mounts toast/provider
+    // wrappers, so we can't assert `container.firstChild === null`).
+    await waitFor(() => {
+      expect(screen.queryByText('Truncated')).not.toBeInTheDocument();
+      expect(document.querySelector('button[type="button"] svg.lucide-file-text')).toBeNull();
+    });
+  });
+
+  it('renders markdown content and the filename when present', async () => {
+    server.use(
+      http.get('/api/v1/library/folders/:id/readme', () =>
+        HttpResponse.json({
+          filename: 'README.md',
+          content: '# Robot model\n\nA cute robot.',
+          truncated: false,
+        }),
+      ),
+    );
+    render(<FolderReadmePanel folderId={42} />);
+    expect(await screen.findByText('README.md')).toBeInTheDocument();
+    expect(await screen.findByRole('heading', { name: 'Robot model' })).toBeInTheDocument();
+    expect(screen.getByText('A cute robot.')).toBeInTheDocument();
+  });
+
+  it('shows a Truncated chip when the API flags the content as clipped', async () => {
+    server.use(
+      http.get('/api/v1/library/folders/:id/readme', () =>
+        HttpResponse.json({
+          filename: 'description.md',
+          content: 'very long content',
+          truncated: true,
+        }),
+      ),
+    );
+    render(<FolderReadmePanel folderId={7} />);
+    expect(await screen.findByText('Truncated')).toBeInTheDocument();
+  });
+});

+ 9 - 0
frontend/src/api/client.ts

@@ -5787,6 +5787,7 @@ export const api = {
     includeRoot = true,
     projectId?: number,
     scope?: 'internal' | 'external',
+    recursive = false,
   ) => {
     const params = new URLSearchParams();
     if (folderId !== undefined && folderId !== null) {
@@ -5798,8 +5799,16 @@ export const api = {
     params.set('include_root', String(includeRoot));
     if (scope === 'internal') params.set('internal_only', 'true');
     else if (scope === 'external') params.set('external_only', 'true');
+    // recursive=true expands the folder_id filter to include every descendant
+    // folder (#1268). Only meaningful when folder_id is set; ignored server-side
+    // otherwise. Off by default so non-search callers keep folder-scoped behavior.
+    if (recursive) params.set('recursive', 'true');
     return request<LibraryFileListItem[]>(`/library/files?${params}`);
   },
+  getLibraryFolderReadme: (folderId: number) =>
+    request<{ filename: string; content: string; truncated: boolean }>(
+      `/library/folders/${folderId}/readme`,
+    ),
   getLibraryFile: (id: number) => request<LibraryFile>(`/library/files/${id}`),
   uploadLibraryFile: async (
     file: File,

+ 101 - 0
frontend/src/components/FolderReadmePanel.tsx

@@ -0,0 +1,101 @@
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { ChevronDown, ChevronUp, FileText } from 'lucide-react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+
+import { api } from '../api/client';
+
+interface FolderReadmePanelProps {
+  folderId: number;
+}
+
+/**
+ * Side panel that renders a `.md` file from the selected folder (#1268).
+ * Hidden when the folder has no markdown file. Disables raw HTML and links
+ * stay text-only — same posture as the print-archive note panel.
+ */
+export function FolderReadmePanel({ folderId }: FolderReadmePanelProps) {
+  const { t } = useTranslation();
+  const [collapsed, setCollapsed] = useState(false);
+
+  const { data, isLoading, error } = useQuery({
+    queryKey: ['folder-readme', folderId],
+    queryFn: () => api.getLibraryFolderReadme(folderId),
+    retry: false,
+    staleTime: 30_000,
+  });
+
+  if (isLoading || error || !data) return null;
+
+  return (
+    <div className="mb-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg overflow-hidden">
+      <button
+        type="button"
+        onClick={() => setCollapsed((v) => !v)}
+        className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left hover:bg-bambu-dark/40 transition-colors"
+      >
+        <div className="flex items-center gap-2 min-w-0">
+          <FileText className="w-4 h-4 text-bambu-green flex-shrink-0" />
+          <span className="text-sm font-medium text-white truncate" title={data.filename}>
+            {data.filename}
+          </span>
+          {data.truncated && (
+            <span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400 flex-shrink-0">
+              {t('fileManager.readme.truncated')}
+            </span>
+          )}
+        </div>
+        {collapsed ? (
+          <ChevronDown className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+        ) : (
+          <ChevronUp className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+        )}
+      </button>
+      {!collapsed && (
+        <div className="px-4 py-3 border-t border-bambu-dark-tertiary max-h-96 overflow-y-auto text-sm text-bambu-gray-light leading-relaxed space-y-2">
+          <ReactMarkdown
+            remarkPlugins={[remarkGfm]}
+            components={{
+              h1: ({ children }) => <h1 className="text-lg font-semibold text-white mt-2 mb-1">{children}</h1>,
+              h2: ({ children }) => <h2 className="text-base font-semibold text-white mt-2 mb-1">{children}</h2>,
+              h3: ({ children }) => <h3 className="text-sm font-semibold text-white mt-2 mb-1">{children}</h3>,
+              p: ({ children }) => <p className="my-1">{children}</p>,
+              ul: ({ children }) => <ul className="list-disc list-inside space-y-0.5 ml-2">{children}</ul>,
+              ol: ({ children }) => <ol className="list-decimal list-inside space-y-0.5 ml-2">{children}</ol>,
+              li: ({ children }) => <li>{children}</li>,
+              code: ({ children, ...props }) => {
+                const inline = !(props as { className?: string }).className;
+                return inline ? (
+                  <code className="px-1 py-0.5 bg-bambu-dark rounded text-xs font-mono text-bambu-green">{children}</code>
+                ) : (
+                  <code className="block p-2 bg-bambu-dark rounded text-xs font-mono text-bambu-gray-light overflow-x-auto">{children}</code>
+                );
+              },
+              pre: ({ children }) => <pre className="my-2">{children}</pre>,
+              blockquote: ({ children }) => (
+                <blockquote className="border-l-2 border-bambu-dark-tertiary pl-3 text-bambu-gray italic">{children}</blockquote>
+              ),
+              a: ({ children, href }) => (
+                <a href={href} target="_blank" rel="noopener noreferrer" className="text-bambu-green hover:underline">
+                  {children}
+                </a>
+              ),
+              table: ({ children }) => (
+                <div className="overflow-x-auto">
+                  <table className="min-w-full text-xs border-collapse">{children}</table>
+                </div>
+              ),
+              th: ({ children }) => <th className="border border-bambu-dark-tertiary px-2 py-1 text-left font-semibold text-white">{children}</th>,
+              td: ({ children }) => <td className="border border-bambu-dark-tertiary px-2 py-1">{children}</td>,
+              hr: () => <hr className="border-bambu-dark-tertiary my-2" />,
+            }}
+          >
+            {data.content}
+          </ReactMarkdown>
+        </div>
+      )}
+    </div>
+  );
+}

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

@@ -3340,6 +3340,10 @@ export default {
     folderSortByActivity: 'Nach letzter Aktivität',
     dragToResizeTooltip: 'Ziehen zum Ändern der Größe, Doppelklick zum Zurücksetzen',
     searchFiles: 'Dateien suchen...',
+    searchSubfoldersHint: 'Inklusive Unterordner',
+    readme: {
+      truncated: 'Gekürzt',
+    },
     allTypes: 'Alle Typen',
     prints: 'Drucke',
     ascending: 'Aufsteigend',

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

@@ -3355,6 +3355,10 @@ export default {
     folderSortByActivity: 'By recent activity',
     dragToResizeTooltip: 'Drag to resize, double-click to reset',
     searchFiles: 'Search files...',
+    searchSubfoldersHint: 'Including subfolders',
+    readme: {
+      truncated: 'Truncated',
+    },
     allTypes: 'All types',
     prints: 'Prints',
     ascending: 'Ascending',

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

@@ -3343,6 +3343,10 @@ export default {
     folderSortByActivity: 'Por actividad reciente',
     dragToResizeTooltip: 'Arrastre para redimensionar, doble clic para restablecer',
     searchFiles: 'Buscar archivos...',
+    searchSubfoldersHint: 'Incluyendo subcarpetas',
+    readme: {
+      truncated: 'Truncado',
+    },
     allTypes: 'Todos los tipos',
     prints: 'Impresiones',
     ascending: 'Ascendente',

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

@@ -3329,6 +3329,10 @@ export default {
     folderSortByActivity: 'Par activité récente',
     dragToResizeTooltip: 'Glisser pour redimensionner, double-clic reset',
     searchFiles: 'Chercher fichiers...',
+    searchSubfoldersHint: 'Sous-dossiers inclus',
+    readme: {
+      truncated: 'Tronqué',
+    },
     allTypes: 'Tous types',
     prints: 'Impressions',
     ascending: 'Croissant',

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

@@ -3328,6 +3328,10 @@ export default {
     folderSortByActivity: 'Per attività recente',
     dragToResizeTooltip: 'Trascina per ridimensionare, doppio clic per reset',
     searchFiles: 'Cerca file...',
+    searchSubfoldersHint: 'Sottocartelle incluse',
+    readme: {
+      truncated: 'Troncato',
+    },
     allTypes: 'Tutti i tipi',
     prints: 'Stampe',
     ascending: 'Crescente',

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

@@ -3340,6 +3340,10 @@ export default {
     folderSortByActivity: '最終更新順',
     dragToResizeTooltip: 'ドラッグしてリサイズ、ダブルクリックでリセット',
     searchFiles: 'ファイルを検索...',
+    searchSubfoldersHint: 'サブフォルダーを含む',
+    readme: {
+      truncated: '切り詰め',
+    },
     allTypes: 'すべての種類',
     prints: '印刷回数',
     ascending: '昇順',

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

@@ -3153,6 +3153,10 @@ export default {
     folderSortByActivity: '최근 활동순',
     dragToResizeTooltip: '드래그하여 크기 조정, 더블클릭하여 초기화',
     searchFiles: '파일 검색...',
+    searchSubfoldersHint: '하위 폴더 포함',
+    readme: {
+      truncated: '잘림',
+    },
     allTypes: '모든 유형',
     prints: '인쇄물',
     ascending: '오름차순',

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

@@ -3328,6 +3328,10 @@ export default {
     folderSortByActivity: 'Por atividade recente',
     dragToResizeTooltip: 'Arraste para redimensionar, clique duas vezes para redefinir',
     searchFiles: 'Pesquisar arquivos...',
+    searchSubfoldersHint: 'Incluindo subpastas',
+    readme: {
+      truncated: 'Truncado',
+    },
     allTypes: 'Todos os tipos',
     prints: 'Impressões',
     ascending: 'Crescente',

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

@@ -3335,6 +3335,10 @@ export default {
     folderSortByActivity: 'Son etkinliğe göre',
     dragToResizeTooltip: 'Yeniden boyutlandırmak için sürükleyin, sıfırlamak için çift tıklayın',
     searchFiles: 'Dosyalarda ara...',
+    searchSubfoldersHint: 'Alt klasörler dahil',
+    readme: {
+      truncated: 'Kısaltıldı',
+    },
     allTypes: 'Tüm türler',
     prints: 'Baskılar',
     ascending: 'Artan',

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

@@ -3328,6 +3328,10 @@ export default {
     folderSortByActivity: '按最近活动',
     dragToResizeTooltip: '拖动调整大小,双击重置',
     searchFiles: '搜索文件...',
+    searchSubfoldersHint: '包含子文件夹',
+    readme: {
+      truncated: '已截断',
+    },
     allTypes: '所有类型',
     prints: '打印',
     ascending: '升序',

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

@@ -3328,6 +3328,10 @@ export default {
     folderSortByActivity: '依最近活動',
     dragToResizeTooltip: '拖曳調整大小,雙擊重設',
     searchFiles: '搜尋檔案...',
+    searchSubfoldersHint: '包含子資料夾',
+    readme: {
+      truncated: '已截斷',
+    },
     allTypes: '所有類型',
     prints: '列印',
     ascending: '升序',

+ 21 - 1
frontend/src/pages/FileManagerPage.tsx

@@ -59,6 +59,7 @@ import { PrintModal } from '../components/PrintModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
 import { SliceModal } from '../components/SliceModal';
 import { FileUploadModal } from '../components/FileUploadModal';
+import { FolderReadmePanel } from '../components/FolderReadmePanel';
 import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
@@ -1124,8 +1125,15 @@ export function FileManagerPage() {
     staleTime: 30_000,
   });
 
+  // #1268: when a folder is selected and the user has typed a search query,
+  // ask the server to expand the result to every descendant folder so the
+  // client-side filter can match files in subfolders too. Without this the
+  // listing is just the immediate children and "robot.3mf" two levels deep
+  // is invisible from the parent. Only kicks in for folder-scoped views —
+  // root and the internal/external pseudo-nodes already return the union.
+  const searchExpandsSubfolders = selectedFolderId !== null && searchQuery.trim().length > 0;
   const { data: files, isLoading: filesLoading } = useQuery({
-    queryKey: ['library-files', selectedFolderId, topLevelView],
+    queryKey: ['library-files', selectedFolderId, topLevelView, searchExpandsSubfolders],
     // 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
@@ -1137,6 +1145,7 @@ export function FileManagerPage() {
         false,
         undefined,
         selectedFolderId === null ? topLevelView : undefined,
+        searchExpandsSubfolders,
       ),
   });
 
@@ -1858,6 +1867,9 @@ export function FileManagerPage() {
 
         {/* Files area */}
         <div className="flex-1 flex flex-col min-w-0 min-h-0">
+          {/* Markdown description panel (#1268) — auto-hides if the folder
+              has no README/description.md so non-users pay no UI cost. */}
+          {selectedFolderId !== null && <FolderReadmePanel folderId={selectedFolderId} />}
           {/* External folder info bar */}
           {selectedFolder?.is_external && (
             <div className="flex items-center gap-3 mb-4 p-3 bg-purple-500/10 border border-purple-500/30 rounded-lg">
@@ -1905,6 +1917,14 @@ export function FileManagerPage() {
                   onChange={(e) => setSearchQuery(e.target.value)}
                   className="w-full pl-9 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
                 />
+                {searchExpandsSubfolders && (
+                  <span
+                    className="absolute -bottom-4 left-0 text-[10px] text-bambu-gray whitespace-nowrap"
+                    title={t('fileManager.searchSubfoldersHint')}
+                  >
+                    {t('fileManager.searchSubfoldersHint')}
+                  </span>
+                )}
               </div>
 
               {/* Type filter */}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-Ba3h_OWp.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/index-Bp52mo4E.css


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/assets/index-ChoCDzOQ.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CtWW9ce9.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Bp52mo4E.css">
+    <script type="module" crossorigin src="/assets/index-Ba3h_OWp.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-ChoCDzOQ.css">
   </head>
   <body>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов