feat(sidebar): manage folders that live under several model roots

The sidebar's folder tree merges every model root into one relative-path
namespace, but folder operations turned a node into a path by prefixing
default_*_root. A folder living under another root failed to delete with
"Folder no longer exists" (recipes under the primary lora root while
default_lora_root is the extra one), and where the same relative folder
exists in both roots the operation silently hit the other copy — 14 of the
16 top-level folders in the reporting library are shared, so guessing a root
was never safe.

Backend:
* ModelMoveService.resolve_folder() and GET /api/lm/{prefix}/resolve-folder
  answer which directories a library-relative folder maps to
  (folder_path/root/is_symlink), in scanner root order, skipping directories
  no root holds and refusing absolute or climbing paths.
* delete_folder/rename_folder tag a vanished directory with code "missing"
  so the sidebar can tell "this node is stale, refresh" from a failed
  operation.

Frontend:
* _resolveFolderCandidates() is the single place that turns a node into
  absolute paths: default root first, the old root-prefix fallback only
  while a single root is configured, and an explicit unresolved error for a
  multi-root library — nothing is guessed silently any more.
* One copy keeps the single-target modal, which now names the resolved
  absolute path. Several copies render one checkbox row per copy, each
  dry-run against the delete guard ("no models" / "contains N model
  file(s)..." / a deletion is still pending / no longer exists / symbolic
  link): a blocked copy is unticked, disabled and explained, the button
  reads "Delete N folders", every ticked copy is deleted and guarded on its
  own, and a partial failure is reported without discarding the successes.
* Rows are built once per open and only their status text is updated, so
  ticking a box no longer rebuilds the list, steals focus or resizes the
  modal mid-click; the action row keeps a fixed button width.
* Rename offers a root picker in its inline row, create inherits the
  parent's root when the parent resolves to exactly one directory, and the
  undo restores every copy a delete removed.

i18n: 26 new keys (sidebar.deleteFolderModal.*, .deleteFolderResult.*,
.renameFolderResult.*, .folderRoot.*, .folderResult.*) translated in all 9
locales, with the en wording normalized to the established "model root" noun
(it had said "library root") and the new terminology recorded in
docs/i18n-translation-guidelines.md.

Verified: pytest 3686 passed, vitest 1473 passed (86 in the folder-management
suite), pytest tests/i18n 20 passed, sync_translation_keys.py --dry-run
clean. A sandboxed standalone instance with two roots confirmed that deleting
one copy leaves the node in place, that the twin's model card survives the
purge, and that deleting both copies and undoing restores both directories.
This commit is contained in:
Will Miao
2026-10-06 17:14:07 +08:00
parent 5ebf5aeccc
commit 2dcaf6a30e
23 changed files with 2220 additions and 180 deletions
+104 -6
View File
@@ -561,6 +561,85 @@ class ModelMoveService:
return rel.replace(os.sep, "/")
return ""
def resolve_folder(self, folder: str) -> Dict[str, Any]:
"""Map a library-relative folder onto the directories it stands for.
The unified folder tree merges every model root into a single
relative-path namespace, so a tree node does not carry the root it came
from: the same relative folder can exist under several roots, or under
one that is not the default root. Folder operations take an absolute
business path, so a caller holding only the rendered node must ask which
directories it maps to instead of assuming a root — assuming one is how
deleting a folder that sits right there fails with "Folder no longer
exists", or, when a same-named folder exists in another root, silently
acts on that other directory.
Args:
folder: Library-relative folder path (``a/b``; backslashes are
accepted). Absolute paths are rejected — a caller that already
holds an absolute business path does not need this.
Returns:
Dictionary with the success flag, the normalized relative folder and
``candidates``: one entry per model root that holds the directory on
disk (``folder_path``/``root``/``is_symlink``), in scanner root
order. An empty list means the directory exists under no root.
"""
try:
relative = self._normalize_relative_folder(folder)
except ValueError as exc:
return {"success": False, "error": str(exc)}
candidates: List[Dict[str, Any]] = []
for root in self.scanner.get_model_roots():
abs_root = os.path.abspath(root)
candidate = os.path.abspath(os.path.join(abs_root, relative))
# normpath already collapsed any "." / ".." segment; the
# containment check is belt-and-braces, not traversal defence.
if candidate != abs_root and not candidate.startswith(abs_root + os.sep):
continue
if not os.path.isdir(candidate):
continue
candidates.append(
{
"folder_path": candidate.replace(os.sep, "/"),
"root": abs_root.replace(os.sep, "/"),
"is_symlink": os.path.islink(candidate),
}
)
return {
"success": True,
"folder": relative.replace(os.sep, "/"),
"candidates": candidates,
}
@staticmethod
def _normalize_relative_folder(folder: str) -> str:
"""Normalize a library-relative folder path, raising ``ValueError``.
Absolute paths (POSIX, drive-letter or UNC) and paths that climb out of
the library root are refused: both mean the caller is confused about
which space it is working in, and guessing would be worse than an error.
"""
raw = str(folder or "").strip()
if not raw:
raise ValueError("Folder path is required")
normalized = raw.replace("\\", "/")
if normalized.startswith("/") or (len(normalized) > 1 and normalized[1] == ":"):
raise ValueError("Folder path must be relative to a library root")
normalized = os.path.normpath(normalized)
if (
normalized == ".."
or normalized.startswith("../")
or normalized.startswith(".." + os.sep)
):
raise ValueError("Folder path must stay inside the library root")
return normalized
async def delete_folder(self, folder_path: str, dry_run: bool = False) -> Dict[str, Any]:
"""Delete a model-free directory inside the model library roots.
@@ -598,7 +677,13 @@ class ModelMoveService:
"error": "Symlinked folders cannot be deleted",
}
if not os.path.isdir(absolute_path):
return {"success": False, "error": "Folder no longer exists"}
# `missing` is its own code so the sidebar can tell "this node's
# directory is gone, refresh" apart from a failed operation.
return {
"success": False,
"code": "missing",
"error": "Folder no longer exists",
}
if self._is_model_root(absolute_path):
return {
@@ -679,7 +764,7 @@ class ModelMoveService:
except OSError: # pragma: no cover - best-effort cleanup
pass
await self._forget_folder(relative_folder)
await self._forget_folder(relative_folder, absolute_path)
return {
"success": True,
@@ -789,13 +874,20 @@ class ModelMoveService:
return set()
return {_normalize_match_path(path) for path in paths if path}
async def _forget_folder(self, relative_folder: str) -> None:
"""Drop a removed directory from the scanner's folder/cache records."""
async def _forget_folder(
self, relative_folder: str, absolute_path: Optional[str] = None
) -> None:
"""Drop a removed directory from the scanner's folder/cache records.
``absolute_path`` is what the caller actually deleted: the scanner needs
it to purge the right model cards, because the same relative folder can
exist under several roots.
"""
if not relative_folder:
return
remove_known_folder = getattr(self.scanner, "remove_known_folder", None)
if callable(remove_known_folder):
await remove_known_folder(relative_folder)
await remove_known_folder(relative_folder, absolute_path)
async def rename_folder(self, folder_path: str, new_name: str) -> Dict[str, Any]:
"""Rename a directory inside the model library roots.
@@ -836,7 +928,13 @@ class ModelMoveService:
"error": "Symlinked folders cannot be renamed",
}
if not os.path.isdir(absolute_path):
return {"success": False, "error": "Folder no longer exists"}
# `missing` is its own code so the sidebar can tell "this node's
# directory is gone, refresh" apart from a failed operation.
return {
"success": False,
"code": "missing",
"error": "Folder no longer exists",
}
if self._is_model_root(absolute_path):
return {