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.
This commit is contained in:
maziggy
2026-06-22 11:40:58 +02:00
parent 7e6b390d74
commit 5cbefca6a0
25 changed files with 2013 additions and 161 deletions
+83 -1
View File
@@ -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)