"""Folder management.""" from __future__ import annotations from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status from sqlalchemy import select from sqlalchemy.orm import Session as DBSession from lembas.api.deps import Db, RequiredUser, require_permission from lembas.db.models import KINDS, Folder from lembas.services.agent import policy as agent_policy # Every route here manages folders, so the guard belongs on the router. router = APIRouter( prefix="/api/folders", tags=["folders"], dependencies=[Depends(require_permission("folder.manage"))], ) MAX_DEPTH = 8 def _owned_folder(db: DBSession, folder_id: str, user_id: str) -> Folder: folder = db.get(Folder, folder_id) if folder is None or folder.user_id != user_id: raise HTTPException(status.HTTP_404_NOT_FOUND, "That folder no longer exists.") return folder def _depth_of(db: DBSession, folder: Folder | None) -> int: depth = 0 seen: set[str] = set() while folder is not None and folder.id not in seen: seen.add(folder.id) depth += 1 folder = db.get(Folder, folder.parent_id) if folder.parent_id else None return depth def _descendants(db: DBSession, folder: Folder) -> set[str]: """Every folder under this one, and this one. Bounded by MAX_DEPTH.""" found = {folder.id} frontier = [folder.id] for _ in range(MAX_DEPTH + 1): if not frontier: break children = list( db.scalars(select(Folder).where(Folder.parent_id.in_(frontier))) ) frontier = [c.id for c in children if c.id not in found] found.update(frontier) return found def _subtree_height(db: DBSession, folder: Folder) -> int: """How many levels this folder's own subtree occupies, itself included. A move has to consider it: the constraint is on the *deepest leaf* after the move, not on the folder being dragged. """ height = 1 frontier = [folder.id] for _ in range(MAX_DEPTH + 1): children = list( db.scalars(select(Folder.id).where(Folder.parent_id.in_(frontier))) ) if not children: break height += 1 frontier = children return height def candidate_parents(db: DBSession, user_id: str, folder: Folder) -> list[Folder]: """Folders this one could be moved into. Everything the person owns, minus the folder itself and its own subtree -- which is the cycle guard in `update_folder` stated as a list rather than as a refusal. A picker that offers a move the route will reject is a control that looks like it works. Depth is checked at the route rather than filtered here: it depends on how tall *this* folder's subtree is, and a select that silently omitted a folder for that reason would be unexplainable from the screen. """ blocked = _descendants(db, folder) return [ candidate for candidate in db.scalars( select(Folder).where(Folder.user_id == user_id).order_by(Folder.name) ) if candidate.id not in blocked ] def _refresh_sidebar() -> Response: """Tell the browser to reload so the tree re-renders. The folder tree is recursive and a change can move any part of it, so re-rendering the whole sidebar server-side is both simpler and less error-prone than trying to patch individual nodes over the wire. """ response = Response(status_code=status.HTTP_204_NO_CONTENT) response.headers["HX-Refresh"] = "true" return response def _prompted(request: Request) -> str: """What somebody typed into an `hx-prompt` dialog, if anything. htmx sends it as a header rather than a field, because the element carrying the attribute may not be a form control at all. `ui.js` swaps the browser's own prompt for the themed one and hands the answer back through the same header, so this reads identically either way. """ return (request.headers.get("HX-Prompt") or "").strip() @router.post("") async def create_folder( request: Request, db: Db, user: RequiredUser, name: str = Form(""), parent_id: str = Form(""), ) -> Response: name = name.strip() or _prompted(request) parent = _owned_folder(db, parent_id, user.id) if parent_id else None # A cap on nesting, so a runaway client cannot build a tree deep enough to # blow the recursion limit in the template. if parent is not None and _depth_of(db, parent) >= MAX_DEPTH: raise HTTPException( status.HTTP_400_BAD_REQUEST, f"Folders cannot be nested more than {MAX_DEPTH} deep.", ) db.add( Folder( user_id=user.id, name=name[:200] or "New folder", parent_id=parent.id if parent else None, ) ) db.commit() return _refresh_sidebar() # The settings a folder hands to chats started inside it, and how far each may # run. A table rather than a run of `if` blocks so the save handler and the form # cannot come to disagree about which fields exist -- the same reasoning the # tool label table carries. _SEEDS = { "description": 500, "system_prompt": 20_000, "model_id": 300, "ssh_profile_id": 32, "project_dir": 1000, } @router.patch("/{folder_id}") async def update_folder( request: Request, db: Db, user: RequiredUser, folder_id: str, ) -> Response: """Rename, move, collapse, or set what this folder hands to its chats. Reads the raw form rather than declaring `Form(None)` parameters, because FastAPI cannot tell an empty field from an absent one -- a submitted `x=` arrives as None, so "clear this prompt" and "leave it alone" would be the same request. Key presence is the distinction, which is the rule `api/chats.py:update_chat` already follows and the reason every field here is clearable. """ folder = _owned_folder(db, folder_id, user.id) form = await request.form() # A rename can arrive from a settings form or from an `hx-prompt` button on # the folder row; one route serves both. A blank name is ignored rather than # stored, since a folder nobody can see the name of is one nobody can find. name = str(form.get("name") or "").strip() or _prompted(request) if name: folder.name = name[:200] if "parent_id" in form: parent_id = str(form["parent_id"]).strip() new_parent = _owned_folder(db, parent_id, user.id) if parent_id else None # Reparenting a folder into its own subtree would detach that subtree # from the root and make it unreachable. cursor = new_parent while cursor is not None: if cursor.id == folder.id: raise HTTPException( status.HTTP_400_BAD_REQUEST, "A folder cannot be moved inside itself.", ) cursor = db.get(Folder, cursor.parent_id) if cursor.parent_id else None # And the depth cap, which `create_folder` has always applied and this # path never did -- moving a three-deep subtree under a six-deep folder # builds a tree nine deep, which is what MAX_DEPTH exists to keep out of # the recursive sidebar template. It went unnoticed because nothing in # the interface could submit `parent_id` at all until now. subtree = _subtree_height(db, folder) if new_parent is not None and _depth_of(db, new_parent) + subtree > MAX_DEPTH: raise HTTPException( status.HTTP_400_BAD_REQUEST, f"Folders cannot be nested more than {MAX_DEPTH} deep.", ) folder.parent_id = new_parent.id if new_parent else None if "collapsed" in form: folder.collapsed = str(form["collapsed"]).lower() in ("1", "true", "on", "yes") for field, limit in _SEEDS.items(): if field in form: setattr(folder, field, str(form[field]).strip()[:limit]) # Both are vocabularies rather than free text, and both accept "" for "no # opinion". Anything else is dropped rather than stored: a folder seeding a # kind that is not a kind would hand every chat started in it a value that # `_new_chat` then has to ignore anyway. if "kind" in form: wanted = str(form["kind"]).strip() folder.kind = wanted if wanted in KINDS else "" if "agent_mode" in form: wanted = str(form["agent_mode"]).strip() folder.agent_mode = wanted if wanted in agent_policy.MODES else "" db.commit() # One rule for every caller: reload. A rename or a move changes the tree, # and a save from the settings page comes back showing what was stored -- # which is what somebody who pressed Save wants to see anyway. return _refresh_sidebar() @router.delete("/{folder_id}") async def delete_folder(db: Db, user: RequiredUser, folder_id: str) -> Response: """Delete a folder. Child folders go with it; chats do not. Chats fall back to the unfiled list (the FK is ON DELETE SET NULL), because losing a conversation to a mis-clicked folder delete is unforgivable. """ folder = _owned_folder(db, folder_id, user.id) db.delete(folder) db.commit() return _refresh_sidebar()