feat: real recursive folder tree in the sidebar

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 11:36:01 +02:00
parent bb7c2b12d6
commit 914eb58ac5
6 changed files with 169 additions and 33 deletions

View File

@@ -44,6 +44,81 @@ async def get_folders(db: AsyncSession = Depends(get_db)):
return {"folders": folders_list} 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}") @router.patch("/{folder_id}")
async def rename_folder( async def rename_folder(
folder_id: str, folder_id: str,

View File

@@ -71,15 +71,17 @@ async def list_photos(
if date_to: if date_to:
filters.append(Photo.taken_at <= date_to) filters.append(Photo.taken_at <= date_to)
# Folder filter — the sidebar exposes "source roots" (top-level scan # Folder filter. The sidebar can pass either a SourceRoot id or a
# paths) under the same UI affordance as folders, so the same param has # Folder id; both should include descendants so clicking a parent
# to accept either a folder id or a source root id. If the value matches # folder shows everything under it (Lightroom semantics).
# a source root, expand to every folder under that root and use IN.
if folder_id: if folder_id:
sr_check = await db.execute( 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( child_folders = await db.execute(
select(Folder.id).where(Folder.source_root_id == folder_id) select(Folder.id).where(Folder.source_root_id == folder_id)
) )
@@ -87,11 +89,25 @@ async def list_photos(
if child_ids: if child_ids:
filters.append(Photo.folder_id.in_(child_ids)) filters.append(Photo.folder_id.in_(child_ids))
else: else:
# Source root with no folder rows yet — match nothing rather
# than returning the entire library.
filters.append(Photo.id == '__no_match__') filters.append(Photo.id == '__no_match__')
else: 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 # Media type filter
if media_type: if media_type:

View File

@@ -1,21 +1,21 @@
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useFilterStore, hasActiveFilters } from '../../store/filterStore' 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() { export function ActiveFilterChips() {
const f = useFilterStore() const f = useFilterStore()
// Look up names for id-based filters so the chips show something // Look up names for id-based filters so the chips show something
// human-readable instead of opaque uuids. // human-readable instead of opaque uuids. The folder tree handles
const { data: foldersData } = useQuery({ // both top-level source roots and nested subfolders.
queryKey: ['folders'], const { data: folderTree } = useQuery<FolderTreeNode[]>({
queryFn: sourceFolders.list, queryKey: ['folders', 'tree'],
queryFn: sourceFolders.tree,
enabled: f.folderId !== null, enabled: f.folderId !== null,
}) })
const folder = f.folderId const folder = f.folderId ? findFolderInTree(folderTree, f.folderId) : null
? (foldersData?.folders ?? []).find((x: any) => x.id === f.folderId)
: null
const { data: heaps = [] } = useQuery({ const { data: heaps = [] } = useQuery({
queryKey: ['heaps'], queryKey: ['heaps'],
@@ -86,7 +86,7 @@ export function ActiveFilterChips() {
if (f.folderId) { if (f.folderId) {
chips.push({ chips.push({
key: 'folder', key: 'folder',
label: `Folder: ${folder?.name || folder?.path?.split('/').pop() || f.folderId}`, label: `Folder: ${folder?.name || f.folderId}`,
onRemove: () => f.setFolderId(null), onRemove: () => f.setFolderId(null),
}) })
} }

View File

@@ -12,12 +12,13 @@ import {
Copy, Copy,
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { sourceFolders, library, photos as photosApi } from '../../services/api' import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
import { useFilterStore } from '../../store/filterStore' import { useFilterStore } from '../../store/filterStore'
import { HeapsPanel } from '../heaps/HeapsPanel' import { HeapsPanel } from '../heaps/HeapsPanel'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail' import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
interface TreeItem { interface TreeItem {
id: string id: string
@@ -148,17 +149,15 @@ export function LeftSidebar() {
} }
} }
// Fetch folders from API // Fetch the recursive folder tree (one root per active source root).
const { data: foldersData } = useQuery({ const { data: folderTree = [] } = useFolderTreeQuery()
queryKey: ['folders'],
queryFn: sourceFolders.list,
})
const renameMutation = useMutation({ const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => mutationFn: ({ id, name }: { id: string; name: string }) =>
sourceFolders.rename(id, name), sourceFolders.rename(id, name),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['folders'] }) queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
}, },
onError: (e: any) => onError: (e: any) =>
toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'), toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'),
@@ -198,6 +197,18 @@ export function LeftSidebar() {
setExpandedItems(newExpanded) setExpandedItems(newExpanded)
} }
// Recursively map a backend FolderTreeNode into our generic TreeItem.
const folderNodeToTreeItem = (node: FolderTreeNode): TreeItem => ({
id: `folder-${node.id}`,
label: node.name,
icon: <Folder className="h-4 w-4" />,
count: node.photo_count,
type: 'folder',
children: node.children.length > 0
? node.children.map(folderNodeToTreeItem)
: undefined,
})
const libraryTree: TreeItem[] = [ const libraryTree: TreeItem[] = [
{ {
id: 'library', id: 'library',
@@ -214,13 +225,7 @@ export function LeftSidebar() {
id: 'folders', id: 'folders',
label: 'Folders', label: 'Folders',
icon: <Folder className="h-4 w-4" />, icon: <Folder className="h-4 w-4" />,
children: foldersData?.folders?.map((folder: any) => ({ children: folderTree.map(folderNodeToTreeItem),
id: `folder-${folder.id}`,
label: folder.name || folder.path.split('/').pop() || folder.path,
icon: <Folder className="h-4 w-4" />,
count: folder.photo_count,
type: 'folder',
})) || [],
}, },
] ]
@@ -411,7 +416,7 @@ export function LeftSidebar() {
</div> </div>
{/* Bottom Actions */} {/* Bottom Actions */}
{foldersData?.folders?.length > 0 && ( {folderTree.length > 0 && (
<div className="border-t border-border p-3"> <div className="border-t border-border p-3">
<button <button
onClick={handleScanAll} onClick={handleScanAll}

View File

@@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query'
import { sourceFolders, type FolderTreeNode } from '../services/api'
export const FOLDER_TREE_QUERY_KEY = ['folders', 'tree'] as const
export function useFolderTreeQuery() {
return useQuery<FolderTreeNode[]>({
queryKey: FOLDER_TREE_QUERY_KEY,
queryFn: sourceFolders.tree,
staleTime: 30_000,
})
}
/** Walk the tree to find a node by id. Used for chip name lookups. */
export function findFolderInTree(
tree: FolderTreeNode[] | undefined,
id: string
): FolderTreeNode | null {
if (!tree) return null
for (const node of tree) {
if (node.id === id) return node
const child = findFolderInTree(node.children, id)
if (child) return child
}
return null
}

View File

@@ -12,12 +12,26 @@ const api = axios.create({
// Source Folders API. Source roots are config-driven now (PHOTO_DIRS in // Source Folders API. Source roots are config-driven now (PHOTO_DIRS in
// .env → bootstrap on backend startup), so the UI only reads them and // .env → bootstrap on backend startup), so the UI only reads them and
// optionally renames the display label. // optionally renames the display label.
export interface FolderTreeNode {
id: string
name: string
path: string
photo_count: number
children: FolderTreeNode[]
}
export const sourceFolders = { export const sourceFolders = {
list: async () => { list: async () => {
const response = await api.get('/folders') const response = await api.get('/folders')
return response.data return response.data
}, },
/** Recursive folder tree, one root per active source root. */
tree: async (): Promise<FolderTreeNode[]> => {
const response = await api.get('/folders/tree')
return response.data
},
scan: async (folderId: string) => { scan: async (folderId: string) => {
const response = await api.post(`/folders/${folderId}/scan`) const response = await api.post(`/folders/${folderId}/scan`)
return response.data return response.data