feat: arborescence interactive avec expand/collapse, breadcrumb et navigation par dossier
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

- API /api/local-workspace/tree: arbre récursif complet (support optionnel ?folder=ID)
- API /api/local-workspace/breadcrumb: fil d'Ariane parent pour un dossier
- Page /local-workspace: supporte ?folder=ID pour naviguer dans un dossier
- Sidebar: expand/collapse (▶/▼) sur les dossiers, clic dossier → /local-workspace?folder=ID
- CSS: styles chevron .tree-chevron, .tree-folder-link
- Alpine.js: expandedFolders, toggleTreeFolder, navigateToFolder
This commit is contained in:
2026-07-11 08:24:57 -04:00
parent 54abe8ac3b
commit e9a419b83f
4 changed files with 331 additions and 65 deletions
+95 -24
View File
@@ -491,8 +491,11 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
@router.get("/local-workspace", response_class=HTMLResponse)
async def local_workspace_page(request: Request):
"""Local workspace page with file/folder tree."""
async def local_workspace_page(request: Request, folder: int = None):
"""Local workspace page with file/folder tree.
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
"""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, [])
@@ -502,40 +505,108 @@ async def local_workspace_page(request: Request):
ws = _get_active_workspace(request)
ws_id = ws["id"] if ws else None
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title FROM pages WHERE parent_id IS NULL AND workspace_id=? ORDER BY created_at DESC",
(ws_id,),
).fetchall() if ws_id else []
items = [{"id": r["id"], "name": r["title"] or "Untitled", "type": "page"} for r in rows]
ctx = {**sidebar, "user": user, "workspace_name": sidebar.get("active_ws_name", "My Workspace"), "items": items}
# Build breadcrumb if navigating into a folder
breadcrumb = []
current_folder_id = folder
if folder and ws_id:
with get_conn() as conn:
breadcrumb = _build_breadcrumb(conn, folder)
ctx = {
**sidebar,
"user": user,
"workspace_name": sidebar.get("active_ws_name", "My Workspace"),
"current_folder_id": current_folder_id or 0,
"breadcrumb": breadcrumb,
}
template = env.get_template("local_workspace.html")
return template.render(**ctx)
@router.get("/api/local-workspace/tree")
async def local_workspace_tree(request: Request):
"""Return the file/folder tree filtered by active workspace."""
async def local_workspace_tree(request: Request, folder: int = None):
"""Return the file/folder tree filtered by active workspace.
If ?folder=ID is provided, returns only that folder's children.
Otherwise returns the full recursive tree from root.
"""
ws = _get_active_workspace(request)
ws_id = ws["id"] if ws else None
if not ws_id:
return {"tree": [], "breadcrumb": []}
with get_conn() as conn:
roots = conn.execute(
"SELECT id, title, parent_section FROM pages WHERE parent_id IS NULL AND workspace_id=? ORDER BY created_at DESC",
if folder:
# Show only this folder's children + build breadcrumb
children = _build_tree_children(conn, folder, ws_id)
breadcrumb = _build_breadcrumb(conn, folder)
return {"tree": children, "breadcrumb": breadcrumb, "current_folder": folder}
else:
# Full tree from root
roots = _build_tree_children(conn, None, ws_id)
return {"tree": roots, "breadcrumb": [], "current_folder": None}
@router.get("/api/local-workspace/breadcrumb")
async def local_workspace_breadcrumb(request: Request, folder: int):
"""Return breadcrumb trail for a folder."""
with get_conn() as conn:
breadcrumb = _build_breadcrumb(conn, folder)
return {"breadcrumb": breadcrumb}
def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
"""Recursively build the tree of children for a node."""
if parent_id is None:
rows = conn.execute(
"SELECT id, title, parent_section FROM pages "
"WHERE parent_id IS NULL AND workspace_id=? ORDER BY created_at DESC",
(ws_id,),
).fetchall() if ws_id else []
).fetchall()
else:
rows = conn.execute(
"SELECT id, title, parent_section FROM pages "
"WHERE parent_id=? AND workspace_id=? ORDER BY created_at DESC",
(parent_id, ws_id),
).fetchall()
tree = []
for r in roots:
with get_conn() as conn:
children = conn.execute(
"SELECT id, title, parent_section FROM pages WHERE parent_id=? ORDER BY created_at", (r["id"],)
).fetchall()
for r in rows:
is_folder = r["parent_section"] == "Workspace"
rtype = "folder" if is_folder else "page"
item = {"id": r["id"], "name": r["title"] or "Untitled", "type": rtype,
"children": [{"id": c["id"], "name": c["title"] or "Untitled", "type": "page"} for c in children]}
tree.append(item)
return {"tree": tree}
children = _build_tree_children(conn, r["id"], ws_id)
tree.append({
"id": r["id"],
"name": r["title"] or "Untitled",
"type": "folder" if is_folder else "page",
"is_folder": is_folder,
"children": children,
"has_children": len(children) > 0,
})
return tree
def _build_breadcrumb(conn, folder_id: int) -> list:
"""Build breadcrumb trail from root to folder_id."""
breadcrumb = []
current = folder_id
seen = set()
while current and current not in seen:
seen.add(current)
row = conn.execute(
"SELECT id, title, parent_id, parent_section FROM pages WHERE id=?",
(current,),
).fetchone()
if row:
breadcrumb.insert(0, {
"id": row["id"],
"name": row["title"] or "Untitled",
"is_folder": row["parent_section"] == "Workspace",
})
current = row["parent_id"]
else:
break
return breadcrumb
@router.post("/api/local-workspace/items")