feat(phase-4): complete UX redesign — FileManagerView, FolderTreeItem, test suite, and all Phase 4 fixes
Adds the unified file manager view (Windows Explorer-style), collapsible folder tree sidebar item, full vitest test suite (55 tests, 4 files), and commits all Phase 4 backend/frontend fixes that were staged but uncommitted. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+75
-9
@@ -112,6 +112,21 @@ async def create_folder(
|
||||
if parent is None or parent.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||
|
||||
# Explicit duplicate check — UniqueConstraint won't fire when parent_id IS NULL
|
||||
# because SQL treats NULL as distinct from NULL in unique indexes.
|
||||
dup = await session.execute(
|
||||
select(Folder).where(
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.name == body.name,
|
||||
Folder.parent_id == parent_uuid,
|
||||
)
|
||||
)
|
||||
if dup.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
|
||||
folder = Folder(
|
||||
user_id=current_user.id,
|
||||
name=body.name,
|
||||
@@ -144,23 +159,57 @@ async def create_folder(
|
||||
|
||||
@router.get("")
|
||||
async def list_folders(
|
||||
parent_id: Optional[str] = None,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""List the current user's top-level folders (parent_id IS NULL).
|
||||
"""List the current user's folders at a given level.
|
||||
|
||||
FOLD-02: returns only folders belonging to current_user with no parent.
|
||||
FOLD-02: when parent_id is omitted, returns root folders (parent_id IS NULL).
|
||||
When parent_id is supplied, returns that folder's direct children (asserts ownership).
|
||||
Each folder includes has_children so the frontend can hide expand arrows on leaf nodes.
|
||||
"""
|
||||
parent_uuid: Optional[uuid.UUID] = None
|
||||
if parent_id is not None:
|
||||
try:
|
||||
parent_uuid = uuid.UUID(parent_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||
parent_folder = await session.get(Folder, parent_uuid)
|
||||
if parent_folder is None or parent_folder.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||
|
||||
if parent_uuid is None:
|
||||
where_clause = Folder.parent_id.is_(None)
|
||||
else:
|
||||
where_clause = Folder.parent_id == parent_uuid
|
||||
|
||||
result = await session.execute(
|
||||
select(Folder)
|
||||
.where(
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.parent_id.is_(None),
|
||||
)
|
||||
.where(Folder.user_id == current_user.id, where_clause)
|
||||
.order_by(Folder.name)
|
||||
)
|
||||
folders = result.scalars().all()
|
||||
return {"items": [_folder_to_dict(f) for f in folders]}
|
||||
|
||||
# One extra query to know which of these folders have sub-folders.
|
||||
# Allows the frontend to hide expand arrows on leaf nodes without extra round-trips.
|
||||
folder_ids = [f.id for f in folders]
|
||||
folders_with_children: set = set()
|
||||
if folder_ids:
|
||||
children_result = await session.execute(
|
||||
select(Folder.parent_id.distinct()).where(
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.parent_id.in_(folder_ids),
|
||||
)
|
||||
)
|
||||
folders_with_children = {row[0] for row in children_result}
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{**_folder_to_dict(f), "has_children": f.id in folders_with_children}
|
||||
for f in folders
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ── GET /api/folders/{folder_id} ──────────────────────────────────────────────
|
||||
@@ -235,6 +284,22 @@ async def rename_folder(
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
old_name = folder.name
|
||||
# Explicit duplicate check — same NULL parent_id issue as create_folder.
|
||||
if body.name != folder.name:
|
||||
dup = await session.execute(
|
||||
select(Folder).where(
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.name == body.name,
|
||||
Folder.parent_id == folder.parent_id,
|
||||
Folder.id != folder.id,
|
||||
)
|
||||
)
|
||||
if dup.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
|
||||
folder.name = body.name
|
||||
try:
|
||||
await session.commit()
|
||||
@@ -303,7 +368,8 @@ async def delete_folder(
|
||||
" WHERE f.user_id = :uid"
|
||||
") SELECT id FROM subtree"
|
||||
),
|
||||
{"root_id": str(folder.id), "uid": str(current_user.id)},
|
||||
# Use .hex (no dashes) — SQLite stores UUID as 32-char hex; PostgreSQL accepts both.
|
||||
{"root_id": folder.id.hex, "uid": current_user.id.hex},
|
||||
)
|
||||
subtree_folder_ids = [str(row[0]) for row in cte_result.fetchall()]
|
||||
except OperationalError:
|
||||
@@ -344,7 +410,7 @@ async def delete_folder(
|
||||
"CASE WHEN used_bytes > :delta THEN used_bytes - :delta ELSE 0 END "
|
||||
"WHERE user_id = :uid"
|
||||
),
|
||||
{"delta": total_bytes, "uid": str(current_user.id)},
|
||||
{"delta": total_bytes, "uid": current_user.id.hex},
|
||||
)
|
||||
|
||||
# Delete MinIO objects best-effort (per-object, never abort on failure)
|
||||
|
||||
Reference in New Issue
Block a user