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:
curo1305
2026-05-28 17:10:52 +02:00
parent 654622d358
commit 87a32b7ee8
25 changed files with 2534 additions and 163 deletions
+75 -9
View File
@@ -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)
+11 -3
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Topic, User
from db.models import Document, Topic, User
from deps.auth import get_current_user
from deps.db import get_db
from services import classifier, storage
@@ -137,10 +137,18 @@ async def suggest_topics(
"""Suggest topics for a document using AI.
D-11: classifier uses the user's namespace (system + user topics) for suggestions.
D-16 / SEC-IDOR: asserts document ownership — cross-user access returns 404
to prevent document ID enumeration (same pattern as documents router).
"""
meta = await storage.get_metadata(session, body.document_id)
if meta is None:
try:
uid = uuid.UUID(body.document_id)
except ValueError:
raise HTTPException(404, "Document not found")
doc = await session.get(Document, uid)
if doc is None or doc.user_id != current_user.id:
raise HTTPException(404, "Document not found")
try:
suggestions = await classifier.suggest_topics_for_document(session, body.document_id)
except Exception as e: