From 914eb58ac5cf70f534dd9ad479eb57f87e6c3cde Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 8 Apr 2026 11:36:01 +0200 Subject: [PATCH] feat: real recursive folder tree in the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Folders section in the LeftSidebar previously rendered the flat list of source roots — actual subdirectories were invisible. Now it shows the full nested tree, click any node to filter, drop targets work at every depth. Backend - New GET /folders/tree returning a list of root nodes (one per active SourceRoot). Each node is { id, name, path, photo_count, children: [...] } with children sorted alphabetically at every level. Walks Folder rows whose source_root_id matches and whose path is at or beneath the source root, then attaches them by parent path so partial scans don't break the tree. - The source root's display label is overlaid on the root folder node so the top-level entry reads as "Library" instead of "/photos". - list_photos folder_id filter now does descendant matching: when a Folder id is given, it includes the folder itself and every Folder whose path is a sep-prefixed descendant. Matches the Lightroom mental model: clicking "Library" or any parent folder shows everything beneath it. The existing source-root-id branch is unchanged. Frontend - New types/api.ts FolderTreeNode interface and sourceFolders.tree() helper. - New hooks/useFolderTreeQuery.ts with a 30s staleTime and a findFolderInTree() walker for id-based name lookups. - LeftSidebar drops the flat foldersData list and uses the tree query. folderNodeToTreeItem recursively maps backend nodes into the existing TreeItem shape; renderTreeItem already knew how to recurse into children, so the tree just works at any depth. Drop targets, drag-to-move, drag-to-copy, double-click rename, and active-state highlighting all carry over to nested folders. - The renameMutation now also invalidates ['folders', 'tree'] so a source-root rename refreshes the tree label immediately. - ActiveFilterChips switches to the tree query and uses the new findFolderInTree walker so the chip label resolves correctly for sub-folder filters too — not just top-level source roots. - The "Scan all folders" button visibility now keys off the tree length instead of the flat folders length. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/routers/folders.py | 75 +++++++++++++++++++ backend/app/routers/photos.py | 34 ++++++--- .../components/filter/ActiveFilterChips.tsx | 18 ++--- .../src/components/layout/LeftSidebar.tsx | 35 +++++---- frontend/src/hooks/useFolderTreeQuery.ts | 26 +++++++ frontend/src/services/api.ts | 14 ++++ 6 files changed, 169 insertions(+), 33 deletions(-) create mode 100644 frontend/src/hooks/useFolderTreeQuery.ts diff --git a/backend/app/routers/folders.py b/backend/app/routers/folders.py index 4cee944..3a18d04 100644 --- a/backend/app/routers/folders.py +++ b/backend/app/routers/folders.py @@ -44,6 +44,81 @@ async def get_folders(db: AsyncSession = Depends(get_db)): return {"folders": folders_list} +@router.get("/tree") +async def get_folder_tree(db: AsyncSession = Depends(get_db)): + """Recursive folder tree, one root per active SourceRoot. The tree + starts at the Folder row matching the SourceRoot.path (the scanner + creates one for every walked directory), with the SourceRoot's + display name overlaid so the top-level entry reads as "Library" + instead of "/photos". + + Returns a list of root nodes; each node has: + { id, name, path, photo_count, children: [...] } + + Sub-folders that physically belong to the same source root but + weren't created on disk (e.g. the / row the scanner sometimes + creates as a parent walk) are skipped via path-prefix filtering. + """ + sr_result = await db.execute( + select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712 + ) + source_roots = sr_result.scalars().all() + + out = [] + for sr in source_roots: + # Folders physically inside this source root, by path prefix. + prefix = os.path.normpath(sr.path).rstrip(os.sep) + f_result = await db.execute( + select(Folder).where( + Folder.source_root_id == sr.id, + # Either the folder IS the source root, or it sits beneath it. + (Folder.path == prefix) | (Folder.path.like(prefix + os.sep + '%')) + ) + ) + folders = f_result.scalars().all() + if not folders: + continue + + # Build a path → node map so we can attach children regardless of + # parent_id consistency. + nodes = { + f.path: { + "id": f.id, + "name": f.name or os.path.basename(f.path), + "path": f.path, + "photo_count": f.photo_count or 0, + "children": [], + } + for f in folders + } + + root_node = None + for f in folders: + node = nodes[f.path] + if f.path == prefix: + root_node = node + # Override the display name with the source root's label. + node["name"] = sr.name or node["name"] + continue + parent_path = os.path.normpath(os.path.dirname(f.path)) + parent = nodes.get(parent_path) + if parent is not None: + parent["children"].append(node) + # If parent isn't in the set (orphan from a partial scan), drop + # the node — it can't be rendered consistently. + + if root_node is not None: + # Sort children alphabetically at every level. + def sort_recursive(n): + n["children"].sort(key=lambda c: c["name"].lower()) + for c in n["children"]: + sort_recursive(c) + sort_recursive(root_node) + out.append(root_node) + + return out + + @router.patch("/{folder_id}") async def rename_folder( folder_id: str, diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index ab62f40..6569497 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -71,15 +71,17 @@ async def list_photos( if date_to: filters.append(Photo.taken_at <= date_to) - # Folder filter — the sidebar exposes "source roots" (top-level scan - # paths) under the same UI affordance as folders, so the same param has - # to accept either a folder id or a source root id. If the value matches - # a source root, expand to every folder under that root and use IN. + # Folder filter. The sidebar can pass either a SourceRoot id or a + # Folder id; both should include descendants so clicking a parent + # folder shows everything under it (Lightroom semantics). if folder_id: sr_check = await db.execute( - select(SourceRoot.id).where(SourceRoot.id == folder_id) + select(SourceRoot).where(SourceRoot.id == folder_id) ) - if sr_check.scalar_one_or_none() is not None: + sr_row = sr_check.scalar_one_or_none() + + if sr_row is not None: + # Source root → all folders under it (any depth). child_folders = await db.execute( select(Folder.id).where(Folder.source_root_id == folder_id) ) @@ -87,11 +89,25 @@ async def list_photos( if child_ids: filters.append(Photo.folder_id.in_(child_ids)) else: - # Source root with no folder rows yet — match nothing rather - # than returning the entire library. filters.append(Photo.id == '__no_match__') else: - filters.append(Photo.folder_id == folder_id) + # Folder id → that folder + every descendant by path prefix. + target_check = await db.execute( + select(Folder).where(Folder.id == folder_id) + ) + target = target_check.scalar_one_or_none() + if target is None: + filters.append(Photo.id == '__no_match__') + else: + target_path = os.path.normpath(target.path).rstrip(os.sep) + desc_result = await db.execute( + select(Folder.id).where( + (Folder.path == target_path) + | (Folder.path.like(target_path + os.sep + '%')) + ) + ) + desc_ids = [row[0] for row in desc_result.all()] + filters.append(Photo.folder_id.in_(desc_ids)) # Media type filter if media_type: diff --git a/frontend/src/components/filter/ActiveFilterChips.tsx b/frontend/src/components/filter/ActiveFilterChips.tsx index 3920fb7..c1b2439 100644 --- a/frontend/src/components/filter/ActiveFilterChips.tsx +++ b/frontend/src/components/filter/ActiveFilterChips.tsx @@ -1,21 +1,21 @@ import { X } from 'lucide-react' import { useQuery } from '@tanstack/react-query' import { useFilterStore, hasActiveFilters } from '../../store/filterStore' -import { sourceFolders, heaps as heapsApi, tags as tagsApi } from '../../services/api' +import { sourceFolders, heaps as heapsApi, tags as tagsApi, type FolderTreeNode } from '../../services/api' +import { findFolderInTree } from '../../hooks/useFolderTreeQuery' export function ActiveFilterChips() { const f = useFilterStore() // Look up names for id-based filters so the chips show something - // human-readable instead of opaque uuids. - const { data: foldersData } = useQuery({ - queryKey: ['folders'], - queryFn: sourceFolders.list, + // human-readable instead of opaque uuids. The folder tree handles + // both top-level source roots and nested subfolders. + const { data: folderTree } = useQuery({ + queryKey: ['folders', 'tree'], + queryFn: sourceFolders.tree, enabled: f.folderId !== null, }) - const folder = f.folderId - ? (foldersData?.folders ?? []).find((x: any) => x.id === f.folderId) - : null + const folder = f.folderId ? findFolderInTree(folderTree, f.folderId) : null const { data: heaps = [] } = useQuery({ queryKey: ['heaps'], @@ -86,7 +86,7 @@ export function ActiveFilterChips() { if (f.folderId) { chips.push({ key: 'folder', - label: `Folder: ${folder?.name || folder?.path?.split('/').pop() || f.folderId}`, + label: `Folder: ${folder?.name || f.folderId}`, onRemove: () => f.setFolderId(null), }) } diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 549ab7c..dd5a210 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -12,12 +12,13 @@ import { Copy, } from 'lucide-react' import clsx from 'clsx' -import { sourceFolders, library, photos as photosApi } from '../../services/api' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from '../ToastContainer' import { useFilterStore } from '../../store/filterStore' import { HeapsPanel } from '../heaps/HeapsPanel' import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail' +import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery' interface TreeItem { id: string @@ -148,17 +149,15 @@ export function LeftSidebar() { } } - // Fetch folders from API - const { data: foldersData } = useQuery({ - queryKey: ['folders'], - queryFn: sourceFolders.list, - }) + // Fetch the recursive folder tree (one root per active source root). + const { data: folderTree = [] } = useFolderTreeQuery() const renameMutation = useMutation({ mutationFn: ({ id, name }: { id: string; name: string }) => sourceFolders.rename(id, name), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['folders'] }) + queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] }) }, onError: (e: any) => toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'), @@ -198,6 +197,18 @@ export function LeftSidebar() { setExpandedItems(newExpanded) } + // Recursively map a backend FolderTreeNode into our generic TreeItem. + const folderNodeToTreeItem = (node: FolderTreeNode): TreeItem => ({ + id: `folder-${node.id}`, + label: node.name, + icon: , + count: node.photo_count, + type: 'folder', + children: node.children.length > 0 + ? node.children.map(folderNodeToTreeItem) + : undefined, + }) + const libraryTree: TreeItem[] = [ { id: 'library', @@ -214,13 +225,7 @@ export function LeftSidebar() { id: 'folders', label: 'Folders', icon: , - children: foldersData?.folders?.map((folder: any) => ({ - id: `folder-${folder.id}`, - label: folder.name || folder.path.split('/').pop() || folder.path, - icon: , - count: folder.photo_count, - type: 'folder', - })) || [], + children: folderTree.map(folderNodeToTreeItem), }, ] @@ -411,7 +416,7 @@ export function LeftSidebar() { {/* Bottom Actions */} - {foldersData?.folders?.length > 0 && ( + {folderTree.length > 0 && (