mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(sidebar): rename folders from the sidebar (#999)
Follows the folder create/delete work: a typo'd directory could be
removed but not corrected, and for a folder holding models the only fix
was to move every model out by hand.
Adds POST /api/lm/{prefix}/rename-folder. Unlike the delete path this one
deliberately works on folders that hold models — a rename keeps every
file, so nothing is cascaded over: the directory is renamed on disk and
the scanner re-keys the records that pointed at the old prefix (recorded
folder list, cache file_path/folder/preview_url, hash and autov3 index
paths, excluded-model paths, and the metadata sidecars that travelled
with the directory). Ancestors are never touched, and only the leaf name
is accepted so a rename can never escape its parent.
Library roots, top-level symlinks and folders holding a staged delete are
refused; the last because a staging manifest records absolute
original/staged paths, so moving it would break undo and purge. A name
collision is a 409 target_exists conflict.
The sidebar reuses the inline-row idiom from folder creation: prefilled
with the current name, inserted in place of the node with that node
hidden while editing, Enter confirms and Escape/blur cancels. The
persisted selection and the expanded set are re-keyed across the rename
so the user keeps their place in the refreshed tree.
This commit is contained in:
@@ -1283,6 +1283,15 @@
|
|||||||
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
||||||
"noRoot": "[TODO: Translate] No model root is configured"
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
},
|
},
|
||||||
|
"renameFolder": "[TODO: Translate] Rename folder",
|
||||||
|
"renameFolderResult": {
|
||||||
|
"success": "[TODO: Translate] Folder renamed to \"{name}\"",
|
||||||
|
"failed": "[TODO: Translate] Failed to rename folder: {message}",
|
||||||
|
"targetExists": "[TODO: Translate] A folder with that name already exists here",
|
||||||
|
"busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "[TODO: Translate] Folder renaming is not supported on this page",
|
||||||
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
|
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
|
||||||
"moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.",
|
"moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.",
|
||||||
|
|||||||
@@ -1283,6 +1283,15 @@
|
|||||||
"unsupported": "Folder deletion is not supported on this page",
|
"unsupported": "Folder deletion is not supported on this page",
|
||||||
"noRoot": "No model root is configured"
|
"noRoot": "No model root is configured"
|
||||||
},
|
},
|
||||||
|
"renameFolder": "Rename folder",
|
||||||
|
"renameFolderResult": {
|
||||||
|
"success": "Folder renamed to \"{name}\"",
|
||||||
|
"failed": "Failed to rename folder: {message}",
|
||||||
|
"targetExists": "A folder with that name already exists here",
|
||||||
|
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "Folder renaming is not supported on this page",
|
||||||
|
"noRoot": "No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "Unable to determine destination path for move.",
|
"unableToResolveRoot": "Unable to determine destination path for move.",
|
||||||
"moveUnsupported": "Move is not supported for this item.",
|
"moveUnsupported": "Move is not supported for this item.",
|
||||||
|
|||||||
@@ -1283,6 +1283,15 @@
|
|||||||
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
||||||
"noRoot": "[TODO: Translate] No model root is configured"
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
},
|
},
|
||||||
|
"renameFolder": "[TODO: Translate] Rename folder",
|
||||||
|
"renameFolderResult": {
|
||||||
|
"success": "[TODO: Translate] Folder renamed to \"{name}\"",
|
||||||
|
"failed": "[TODO: Translate] Failed to rename folder: {message}",
|
||||||
|
"targetExists": "[TODO: Translate] A folder with that name already exists here",
|
||||||
|
"busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "[TODO: Translate] Folder renaming is not supported on this page",
|
||||||
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
|
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
|
||||||
"moveUnsupported": "El movimiento no es compatible con este elemento.",
|
"moveUnsupported": "El movimiento no es compatible con este elemento.",
|
||||||
|
|||||||
@@ -1283,6 +1283,15 @@
|
|||||||
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
||||||
"noRoot": "[TODO: Translate] No model root is configured"
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
},
|
},
|
||||||
|
"renameFolder": "[TODO: Translate] Rename folder",
|
||||||
|
"renameFolderResult": {
|
||||||
|
"success": "[TODO: Translate] Folder renamed to \"{name}\"",
|
||||||
|
"failed": "[TODO: Translate] Failed to rename folder: {message}",
|
||||||
|
"targetExists": "[TODO: Translate] A folder with that name already exists here",
|
||||||
|
"busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "[TODO: Translate] Folder renaming is not supported on this page",
|
||||||
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
|
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
|
||||||
"moveUnsupported": "Le déplacement n'est pas pris en charge pour cet élément.",
|
"moveUnsupported": "Le déplacement n'est pas pris en charge pour cet élément.",
|
||||||
|
|||||||
@@ -1283,6 +1283,15 @@
|
|||||||
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
||||||
"noRoot": "[TODO: Translate] No model root is configured"
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
},
|
},
|
||||||
|
"renameFolder": "[TODO: Translate] Rename folder",
|
||||||
|
"renameFolderResult": {
|
||||||
|
"success": "[TODO: Translate] Folder renamed to \"{name}\"",
|
||||||
|
"failed": "[TODO: Translate] Failed to rename folder: {message}",
|
||||||
|
"targetExists": "[TODO: Translate] A folder with that name already exists here",
|
||||||
|
"busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "[TODO: Translate] Folder renaming is not supported on this page",
|
||||||
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
|
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
|
||||||
"moveUnsupported": "העברה אינה נתמכת עבור פריט זה.",
|
"moveUnsupported": "העברה אינה נתמכת עבור פריט זה.",
|
||||||
|
|||||||
@@ -1283,6 +1283,15 @@
|
|||||||
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
||||||
"noRoot": "[TODO: Translate] No model root is configured"
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
},
|
},
|
||||||
|
"renameFolder": "[TODO: Translate] Rename folder",
|
||||||
|
"renameFolderResult": {
|
||||||
|
"success": "[TODO: Translate] Folder renamed to \"{name}\"",
|
||||||
|
"failed": "[TODO: Translate] Failed to rename folder: {message}",
|
||||||
|
"targetExists": "[TODO: Translate] A folder with that name already exists here",
|
||||||
|
"busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "[TODO: Translate] Folder renaming is not supported on this page",
|
||||||
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "移動先のパスを特定できません。",
|
"unableToResolveRoot": "移動先のパスを特定できません。",
|
||||||
"moveUnsupported": "この項目の移動はサポートされていません。",
|
"moveUnsupported": "この項目の移動はサポートされていません。",
|
||||||
|
|||||||
@@ -1283,6 +1283,15 @@
|
|||||||
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
||||||
"noRoot": "[TODO: Translate] No model root is configured"
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
},
|
},
|
||||||
|
"renameFolder": "[TODO: Translate] Rename folder",
|
||||||
|
"renameFolderResult": {
|
||||||
|
"success": "[TODO: Translate] Folder renamed to \"{name}\"",
|
||||||
|
"failed": "[TODO: Translate] Failed to rename folder: {message}",
|
||||||
|
"targetExists": "[TODO: Translate] A folder with that name already exists here",
|
||||||
|
"busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "[TODO: Translate] Folder renaming is not supported on this page",
|
||||||
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
|
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
|
||||||
"moveUnsupported": "이 항목은 이동을 지원하지 않습니다.",
|
"moveUnsupported": "이 항목은 이동을 지원하지 않습니다.",
|
||||||
|
|||||||
@@ -1283,6 +1283,15 @@
|
|||||||
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
||||||
"noRoot": "[TODO: Translate] No model root is configured"
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
},
|
},
|
||||||
|
"renameFolder": "[TODO: Translate] Rename folder",
|
||||||
|
"renameFolderResult": {
|
||||||
|
"success": "[TODO: Translate] Folder renamed to \"{name}\"",
|
||||||
|
"failed": "[TODO: Translate] Failed to rename folder: {message}",
|
||||||
|
"targetExists": "[TODO: Translate] A folder with that name already exists here",
|
||||||
|
"busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "[TODO: Translate] Folder renaming is not supported on this page",
|
||||||
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
|
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
|
||||||
"moveUnsupported": "Перемещение этого элемента не поддерживается.",
|
"moveUnsupported": "Перемещение этого элемента не поддерживается.",
|
||||||
|
|||||||
@@ -1283,6 +1283,15 @@
|
|||||||
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
||||||
"noRoot": "[TODO: Translate] No model root is configured"
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
},
|
},
|
||||||
|
"renameFolder": "[TODO: Translate] Rename folder",
|
||||||
|
"renameFolderResult": {
|
||||||
|
"success": "[TODO: Translate] Folder renamed to \"{name}\"",
|
||||||
|
"failed": "[TODO: Translate] Failed to rename folder: {message}",
|
||||||
|
"targetExists": "[TODO: Translate] A folder with that name already exists here",
|
||||||
|
"busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "[TODO: Translate] Folder renaming is not supported on this page",
|
||||||
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "无法确定移动的目标路径。",
|
"unableToResolveRoot": "无法确定移动的目标路径。",
|
||||||
"moveUnsupported": "此条目不支持移动。",
|
"moveUnsupported": "此条目不支持移动。",
|
||||||
|
|||||||
@@ -1283,6 +1283,15 @@
|
|||||||
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
||||||
"noRoot": "[TODO: Translate] No model root is configured"
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
},
|
},
|
||||||
|
"renameFolder": "[TODO: Translate] Rename folder",
|
||||||
|
"renameFolderResult": {
|
||||||
|
"success": "[TODO: Translate] Folder renamed to \"{name}\"",
|
||||||
|
"failed": "[TODO: Translate] Failed to rename folder: {message}",
|
||||||
|
"targetExists": "[TODO: Translate] A folder with that name already exists here",
|
||||||
|
"busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "[TODO: Translate] Folder renaming is not supported on this page",
|
||||||
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "無法確定移動的目標路徑。",
|
"unableToResolveRoot": "無法確定移動的目標路徑。",
|
||||||
"moveUnsupported": "此項目不支援移動。",
|
"moveUnsupported": "此項目不支援移動。",
|
||||||
|
|||||||
@@ -2530,6 +2530,39 @@ class ModelMoveHandler:
|
|||||||
self._logger.error("Error deleting folder: %s", exc, exc_info=True)
|
self._logger.error("Error deleting folder: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def rename_folder(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
folder_path = data.get("folder_path")
|
||||||
|
new_name = data.get("new_name")
|
||||||
|
if not folder_path:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Folder path is required"}, status=400
|
||||||
|
)
|
||||||
|
if not new_name:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "New folder name is required"}, status=400
|
||||||
|
)
|
||||||
|
result = await self._move_service.rename_folder(folder_path, new_name)
|
||||||
|
if result.get("success"):
|
||||||
|
if result.get("renamed"):
|
||||||
|
_broadcast_models_changed()
|
||||||
|
return web.json_response(result, status=200)
|
||||||
|
|
||||||
|
# A name collision or a staged delete inside the subtree is a
|
||||||
|
# conflict with the state the client rendered, not a bad request.
|
||||||
|
code = result.get("code")
|
||||||
|
status = 409 if code in ("target_exists", "busy") else 400
|
||||||
|
return web.json_response(result, status=status)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error renaming folder: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def move_model(self, request: web.Request) -> web.Response:
|
async def move_model(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
@@ -3482,6 +3515,7 @@ class ModelHandlerSet:
|
|||||||
"move_models_bulk": self.move.move_models_bulk,
|
"move_models_bulk": self.move.move_models_bulk,
|
||||||
"create_folder": self.move.create_folder,
|
"create_folder": self.move.create_folder,
|
||||||
"delete_folder": self.move.delete_folder,
|
"delete_folder": self.move.delete_folder,
|
||||||
|
"rename_folder": self.move.rename_folder,
|
||||||
"auto_organize_models": self.auto_organize.auto_organize_models,
|
"auto_organize_models": self.auto_organize.auto_organize_models,
|
||||||
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
|
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
|
||||||
"get_model_notes": self.query.get_model_notes,
|
"get_model_notes": self.query.get_model_notes,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"),
|
RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"),
|
||||||
RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"),
|
RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"),
|
||||||
RouteDefinition("POST", "/api/lm/{prefix}/delete-folder", "delete_folder"),
|
RouteDefinition("POST", "/api/lm/{prefix}/delete-folder", "delete_folder"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/rename-folder", "rename_folder"),
|
||||||
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
|
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
|
||||||
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
|
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
|
|||||||
@@ -712,6 +712,131 @@ class ModelMoveService:
|
|||||||
if callable(remove_known_folder):
|
if callable(remove_known_folder):
|
||||||
await remove_known_folder(relative_folder)
|
await remove_known_folder(relative_folder)
|
||||||
|
|
||||||
|
async def rename_folder(self, folder_path: str, new_name: str) -> Dict[str, Any]:
|
||||||
|
"""Rename a directory inside the model library roots.
|
||||||
|
|
||||||
|
Unlike :meth:`delete_folder` this works on folders that hold models.
|
||||||
|
A rename keeps every file, so no per-model lifecycle step is bypassed:
|
||||||
|
the directory is renamed on disk and the affected folder, cache, hash
|
||||||
|
index and metadata-sidecar records are re-keyed onto the new prefix by
|
||||||
|
the scanner.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_path: Absolute path of the directory to rename (business
|
||||||
|
path — symlinks are not resolved)
|
||||||
|
new_name: New leaf name; a single path segment, not a path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with the success flag, the previous/next library-relative
|
||||||
|
folder names and whether the directory actually moved.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not folder_path or not str(folder_path).strip():
|
||||||
|
return {"success": False, "error": "Folder path is required"}
|
||||||
|
|
||||||
|
new_name = str(new_name or "").strip()
|
||||||
|
if not new_name:
|
||||||
|
return {"success": False, "error": "New folder name is required"}
|
||||||
|
if new_name in (".", "..") or any(
|
||||||
|
char in new_name for char in '/\\:*?"<>|'
|
||||||
|
):
|
||||||
|
return {"success": False, "error": "Invalid characters in folder name"}
|
||||||
|
|
||||||
|
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
|
||||||
|
|
||||||
|
absolute_path = os.path.abspath(folder_path)
|
||||||
|
if os.path.islink(absolute_path):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Symlinked folders cannot be renamed",
|
||||||
|
}
|
||||||
|
if not os.path.isdir(absolute_path):
|
||||||
|
return {"success": False, "error": "Folder no longer exists"}
|
||||||
|
|
||||||
|
if self._is_model_root(absolute_path):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "The library root itself cannot be renamed",
|
||||||
|
}
|
||||||
|
|
||||||
|
previous_relative = self._calculate_relative_folder(absolute_path)
|
||||||
|
target = os.path.join(os.path.dirname(absolute_path), new_name)
|
||||||
|
|
||||||
|
if os.path.normpath(target) == os.path.normpath(absolute_path):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"renamed": False,
|
||||||
|
"folder": previous_relative,
|
||||||
|
"previous_folder": previous_relative,
|
||||||
|
"folder_path": absolute_path.replace(os.sep, "/"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if os.path.exists(target):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"code": "target_exists",
|
||||||
|
"error": f"A folder named \"{new_name}\" already exists here",
|
||||||
|
}
|
||||||
|
|
||||||
|
# A staging manifest records absolute original/staged paths, so
|
||||||
|
# moving a folder that holds one would break its undo and purge.
|
||||||
|
if self._has_pending_delete_job(absolute_path):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"code": "busy",
|
||||||
|
"error": (
|
||||||
|
"A staged delete is still pending inside this folder; "
|
||||||
|
"wait for the undo window to expire"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
os.rename(absolute_path, target)
|
||||||
|
|
||||||
|
new_relative = self._calculate_relative_folder(target)
|
||||||
|
await self._rename_folder_records(
|
||||||
|
previous_relative, new_relative, absolute_path, target
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"renamed": True,
|
||||||
|
"folder": new_relative,
|
||||||
|
"previous_folder": previous_relative,
|
||||||
|
"folder_path": target.replace(os.sep, "/"),
|
||||||
|
}
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"success": False, "error": str(exc)}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Error renaming folder: {exc}", exc_info=True)
|
||||||
|
return {"success": False, "error": str(exc)}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_pending_delete_job(absolute_path: str) -> bool:
|
||||||
|
"""Return True when a staged-delete batch lives inside the subtree."""
|
||||||
|
for _dirpath, dirnames, _filenames in os.walk(absolute_path):
|
||||||
|
if PENDING_DELETE_DIR_NAME in dirnames:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _rename_folder_records(
|
||||||
|
self,
|
||||||
|
previous_relative: str,
|
||||||
|
new_relative: str,
|
||||||
|
previous_path: str,
|
||||||
|
new_path: str,
|
||||||
|
) -> None:
|
||||||
|
"""Hand the rename to the scanner so folder/cache records follow it."""
|
||||||
|
if not previous_relative or not new_relative:
|
||||||
|
return
|
||||||
|
rename_known_folder = getattr(self.scanner, "rename_known_folder", None)
|
||||||
|
if callable(rename_known_folder):
|
||||||
|
await rename_known_folder(
|
||||||
|
previous_relative,
|
||||||
|
new_relative,
|
||||||
|
previous_path=previous_path,
|
||||||
|
new_path=new_path,
|
||||||
|
)
|
||||||
|
|
||||||
async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
|
async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
|
||||||
"""Move a single model file
|
"""Move a single model file
|
||||||
|
|
||||||
|
|||||||
@@ -1562,6 +1562,159 @@ class ModelScanner:
|
|||||||
"""Return True when *candidate* is *target* or lives below it."""
|
"""Return True when *candidate* is *target* or lives below it."""
|
||||||
return candidate == target or candidate.startswith(f"{target}/")
|
return candidate == target or candidate.startswith(f"{target}/")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rekey_path(value: str, old_prefix: str, new_prefix: str) -> str:
|
||||||
|
"""Move a stored path (or URL) from *old_prefix* onto *new_prefix*."""
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
normalized = value.replace("\\", "/")
|
||||||
|
if normalized.startswith(old_prefix):
|
||||||
|
return new_prefix + normalized[len(old_prefix):]
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def rename_known_folder(
|
||||||
|
self,
|
||||||
|
previous_folder: str,
|
||||||
|
new_folder: str,
|
||||||
|
*,
|
||||||
|
previous_path: str,
|
||||||
|
new_path: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Re-key folder, cache and metadata records after a directory rename.
|
||||||
|
|
||||||
|
Counterpart of :meth:`add_known_folder` / :meth:`remove_known_folder`.
|
||||||
|
A rename keeps every file, so nothing may be dropped: the recorded
|
||||||
|
folder list, the affected cache entries (``file_path``/``folder``/
|
||||||
|
``preview_url``), the hash index and the on-disk metadata sidecars are
|
||||||
|
all rewritten onto the new prefix. That is what lets a folder full of
|
||||||
|
models be renamed without a rescan and without breaking per-model
|
||||||
|
bookkeeping.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
previous_folder: Library-relative folder name before the rename
|
||||||
|
new_folder: Library-relative folder name after the rename
|
||||||
|
previous_path: Absolute directory path before the rename
|
||||||
|
new_path: Absolute directory path after the rename
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True when any recorded data was rewritten.
|
||||||
|
"""
|
||||||
|
previous = previous_folder.replace("\\", "/").strip("/")
|
||||||
|
current = new_folder.replace("\\", "/").strip("/")
|
||||||
|
if not previous or not current or previous == current:
|
||||||
|
return False
|
||||||
|
|
||||||
|
old_rel_prefix = f"{previous}/"
|
||||||
|
new_rel_prefix = f"{current}/"
|
||||||
|
old_abs_prefix = f"{str(previous_path).replace(chr(92), '/').rstrip('/')}/"
|
||||||
|
new_abs_prefix = f"{str(new_path).replace(chr(92), '/').rstrip('/')}/"
|
||||||
|
|
||||||
|
cache = self._cache
|
||||||
|
if cache is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
recorded = getattr(cache, "all_folders", None)
|
||||||
|
if recorded is not None:
|
||||||
|
rekeyed = sorted(
|
||||||
|
(
|
||||||
|
self._rekey_folder_name(entry, previous, old_rel_prefix, new_rel_prefix)
|
||||||
|
for entry in recorded
|
||||||
|
),
|
||||||
|
key=lambda entry: entry.lower(),
|
||||||
|
)
|
||||||
|
if rekeyed != list(recorded):
|
||||||
|
cache.all_folders = rekeyed
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
excluded = getattr(self, "_excluded_models", None)
|
||||||
|
if excluded:
|
||||||
|
rekeyed_excluded = [
|
||||||
|
self._rekey_path(entry, old_abs_prefix, new_abs_prefix)
|
||||||
|
for entry in excluded
|
||||||
|
]
|
||||||
|
if rekeyed_excluded != list(excluded):
|
||||||
|
self._excluded_models = rekeyed_excluded
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
touched: List[Dict[str, Any]] = []
|
||||||
|
for item in cache.raw_data or []:
|
||||||
|
folder_value = item.get("folder", "") or self._calculate_folder(
|
||||||
|
item.get("file_path", "")
|
||||||
|
)
|
||||||
|
if not self._folder_within(folder_value, previous):
|
||||||
|
continue
|
||||||
|
|
||||||
|
old_file_path = item.get("file_path", "")
|
||||||
|
if old_file_path:
|
||||||
|
cache.remove_from_version_index(item)
|
||||||
|
item["file_path"] = self._rekey_path(
|
||||||
|
old_file_path, old_abs_prefix, new_abs_prefix
|
||||||
|
)
|
||||||
|
hash_value = (item.get("sha256") or "").lower()
|
||||||
|
if hash_value:
|
||||||
|
self._hash_index.remove_by_path(old_file_path, hash_value)
|
||||||
|
self._hash_index.add_entry(
|
||||||
|
hash_value, item["file_path"], item.get("autov3") or None
|
||||||
|
)
|
||||||
|
|
||||||
|
item["folder"] = self._rekey_folder_name(
|
||||||
|
folder_value, previous, old_rel_prefix, new_rel_prefix
|
||||||
|
)
|
||||||
|
if item.get("preview_url"):
|
||||||
|
item["preview_url"] = self._rekey_path(
|
||||||
|
item["preview_url"], old_abs_prefix, new_abs_prefix
|
||||||
|
)
|
||||||
|
touched.append(item)
|
||||||
|
|
||||||
|
if touched:
|
||||||
|
changed = True
|
||||||
|
await self._rewrite_sidecar_paths(touched)
|
||||||
|
folders = set(item.get("folder", "") for item in cache.raw_data)
|
||||||
|
cache.folders = sorted(folders, key=lambda x: x.lower())
|
||||||
|
cache.rebuild_version_index()
|
||||||
|
await cache.resort()
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
await self._persist_current_cache()
|
||||||
|
|
||||||
|
self.bump_cache_version()
|
||||||
|
return changed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rekey_folder_name(
|
||||||
|
entry: str, previous: str, old_rel_prefix: str, new_rel_prefix: str
|
||||||
|
) -> str:
|
||||||
|
"""Move a library-relative folder name (and its subtree) under a new name."""
|
||||||
|
if entry == previous:
|
||||||
|
return new_rel_prefix.rstrip("/")
|
||||||
|
if entry.startswith(old_rel_prefix):
|
||||||
|
return new_rel_prefix + entry[len(old_rel_prefix):]
|
||||||
|
return entry
|
||||||
|
|
||||||
|
async def _rewrite_sidecar_paths(self, entries: List[Dict[str, Any]]) -> None:
|
||||||
|
"""Point each model's metadata sidecar at its new location.
|
||||||
|
|
||||||
|
Sidecars travel with the renamed directory, so only the recorded
|
||||||
|
``file_path``/``preview_url`` inside them need rewriting. Failures are
|
||||||
|
logged and skipped — a stale sidecar is repaired by the next metadata
|
||||||
|
refresh, and must not abort the rename.
|
||||||
|
"""
|
||||||
|
for item in entries:
|
||||||
|
file_path = item.get("file_path")
|
||||||
|
if not file_path:
|
||||||
|
continue
|
||||||
|
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||||
|
if not os.path.exists(metadata_path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await self._update_metadata_paths(metadata_path, file_path)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.warning(
|
||||||
|
"Failed to rewrite metadata sidecar %s: %s", metadata_path, exc
|
||||||
|
)
|
||||||
|
|
||||||
def _schedule_all_folders_backfill(self) -> None:
|
def _schedule_all_folders_backfill(self) -> None:
|
||||||
"""Kick off a one-shot background folder walk if none is running."""
|
"""Kick off a one-shot background folder walk if none is running."""
|
||||||
if self._all_folders_backfill_running:
|
if self._all_folders_backfill_running:
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export function getApiEndpoints(modelType) {
|
|||||||
moveBulk: `/api/lm/${modelType}/move_models_bulk`,
|
moveBulk: `/api/lm/${modelType}/move_models_bulk`,
|
||||||
createFolder: `/api/lm/${modelType}/create-folder`,
|
createFolder: `/api/lm/${modelType}/create-folder`,
|
||||||
deleteFolder: `/api/lm/${modelType}/delete-folder`,
|
deleteFolder: `/api/lm/${modelType}/delete-folder`,
|
||||||
|
renameFolder: `/api/lm/${modelType}/rename-folder`,
|
||||||
|
|
||||||
// CivitAI integration
|
// CivitAI integration
|
||||||
fetchCivitai: `/api/lm/${modelType}/fetch-civitai`,
|
fetchCivitai: `/api/lm/${modelType}/fetch-civitai`,
|
||||||
|
|||||||
@@ -1364,6 +1364,37 @@ export class BaseModelApiClient {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a folder inside the library roots.
|
||||||
|
*
|
||||||
|
* Works on folders that hold models too — the backend re-keys the affected
|
||||||
|
* cache records instead of cascading. A name collision or a staged delete
|
||||||
|
* inside the subtree surfaces as a 409 conflict, attached to the thrown
|
||||||
|
* Error as `code`.
|
||||||
|
*
|
||||||
|
* @param {string} folderPath Absolute business path of the folder
|
||||||
|
* @param {string} newName New leaf name (a single path segment)
|
||||||
|
*/
|
||||||
|
async renameFolder(folderPath, newName) {
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.renameFolder, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ folder_path: folderPath, new_name: newName })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok || result.success === false) {
|
||||||
|
const error = new Error(result.error || `Failed to rename folder`);
|
||||||
|
error.code = result.code || null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async fetchUnifiedFolderTree(options = {}) {
|
async fetchUnifiedFolderTree(options = {}) {
|
||||||
try {
|
try {
|
||||||
const { includeEmpty = false } = options;
|
const { includeEmpty = false } = options;
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export class SidebarManager {
|
|||||||
this.nonEmptyFolders = null; // models-only folder set used to dim empty nodes
|
this.nonEmptyFolders = null; // models-only folder set used to dim empty nodes
|
||||||
this._createFolderBasePath = null;
|
this._createFolderBasePath = null;
|
||||||
this._createFolderTempChildren = null; // children container added for a leaf parent during inline creation
|
this._createFolderTempChildren = null; // children container added for a leaf parent during inline creation
|
||||||
|
this._renameFolderPath = null;
|
||||||
|
this._renameFolderNode = null;
|
||||||
this._pendingDeleteFolderPath = null;
|
this._pendingDeleteFolderPath = null;
|
||||||
this._deleteFolderModalWired = false;
|
this._deleteFolderModalWired = false;
|
||||||
|
|
||||||
@@ -118,6 +120,7 @@ export class SidebarManager {
|
|||||||
this.clearAllDropHighlights();
|
this.clearAllDropHighlights();
|
||||||
this.resetDragState();
|
this.resetDragState();
|
||||||
this.hideCreateFolderInput();
|
this.hideCreateFolderInput();
|
||||||
|
this.hideRenameFolderInput();
|
||||||
|
|
||||||
this.hideSidebarHiddenIndicator();
|
this.hideSidebarHiddenIndicator();
|
||||||
|
|
||||||
@@ -136,6 +139,8 @@ export class SidebarManager {
|
|||||||
this.nonEmptyFolders = null;
|
this.nonEmptyFolders = null;
|
||||||
this._createFolderBasePath = null;
|
this._createFolderBasePath = null;
|
||||||
this._createFolderTempChildren = null;
|
this._createFolderTempChildren = null;
|
||||||
|
this._renameFolderPath = null;
|
||||||
|
this._renameFolderNode = null;
|
||||||
this._pendingDeleteFolderPath = null;
|
this._pendingDeleteFolderPath = null;
|
||||||
|
|
||||||
// Reset container margin
|
// Reset container margin
|
||||||
@@ -763,6 +768,214 @@ export class SidebarManager {
|
|||||||
this.hideCreateFolderInput();
|
this.hideCreateFolderInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Folder rename (inline row, file-explorer style) =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the folder node at *path* into an editable row.
|
||||||
|
*
|
||||||
|
* Mirrors the create-folder inline row (Enter confirms, Escape/blur
|
||||||
|
* cancels) but is inserted where the node sits and hides that node while
|
||||||
|
* editing, so the tree does not jump.
|
||||||
|
*/
|
||||||
|
showRenameFolderInput(path) {
|
||||||
|
if (!path) return;
|
||||||
|
|
||||||
|
this.hideRenameFolderInput();
|
||||||
|
|
||||||
|
const folderTree = document.getElementById('sidebarFolderTree');
|
||||||
|
if (!folderTree) return;
|
||||||
|
|
||||||
|
const node = this._findFolderNodeElement(folderTree, path);
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
const row = this._buildRenameFolderRow(this._folderLeafName(path));
|
||||||
|
node.parentElement.insertBefore(row, node);
|
||||||
|
node.style.display = 'none';
|
||||||
|
|
||||||
|
this._renameFolderNode = node;
|
||||||
|
this._renameFolderPath = path;
|
||||||
|
|
||||||
|
const input = row.querySelector('.sidebar-rename-folder-input');
|
||||||
|
if (!input) return;
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
this.handleRenameFolderSubmit();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
this.handleRenameFolderCancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Clicking away cancels, mirroring the create-folder row
|
||||||
|
input.addEventListener('blur', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this._renameFolderPath) {
|
||||||
|
this.handleRenameFolderCancel();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildRenameFolderRow(currentName) {
|
||||||
|
const isListMode = this.displayMode === 'list';
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.id = 'sidebarRenameFolderInput';
|
||||||
|
row.className = 'sidebar-create-folder-node sidebar-rename-folder-node';
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="${isListMode ? 'sidebar-node-content' : 'sidebar-tree-node-content'} sidebar-create-folder-row">
|
||||||
|
${isListMode ? '' : `
|
||||||
|
<div class="sidebar-tree-expand-icon sidebar-create-folder-spacer">
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</div>`}
|
||||||
|
<i class="fas fa-i-cursor sidebar-tree-folder-icon"></i>
|
||||||
|
<input type="text"
|
||||||
|
class="sidebar-create-folder-input sidebar-rename-folder-input"
|
||||||
|
aria-label="${escapeAttribute(translate('sidebar.renameFolder', {}, 'Rename folder'))}"
|
||||||
|
value="${escapeAttribute(currentName)}" />
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
_findFolderNodeElement(folderTree, path) {
|
||||||
|
return [...folderTree.querySelectorAll('.sidebar-tree-node, .sidebar-folder-item')]
|
||||||
|
.find(element => element.dataset.path === path) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_folderLeafName(path) {
|
||||||
|
if (!path) return '';
|
||||||
|
const index = path.lastIndexOf('/');
|
||||||
|
return index === -1 ? path : path.slice(index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
hideRenameFolderInput() {
|
||||||
|
// Clear the flag first so the input's blur handler does not treat
|
||||||
|
// removing the row as a cancel.
|
||||||
|
this._renameFolderPath = null;
|
||||||
|
|
||||||
|
const row = document.getElementById('sidebarRenameFolderInput');
|
||||||
|
if (row) {
|
||||||
|
row.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = this._renameFolderNode;
|
||||||
|
this._renameFolderNode = null;
|
||||||
|
if (node && node.isConnected) {
|
||||||
|
node.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRenameFolderCancel() {
|
||||||
|
this.hideRenameFolderInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRenameFolderSubmit() {
|
||||||
|
const input = document.querySelector('#sidebarRenameFolderInput .sidebar-rename-folder-input');
|
||||||
|
const path = this._renameFolderPath;
|
||||||
|
if (!input || !path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newName = input.value.trim();
|
||||||
|
if (!newName) {
|
||||||
|
showToast('sidebar.dragDrop.emptyFolderName', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[\\/:*?"<>|]/.test(newName)) {
|
||||||
|
showToast('sidebar.dragDrop.invalidFolderName', {}, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hideRenameFolderInput();
|
||||||
|
|
||||||
|
if (newName === this._folderLeafName(path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._renameFolder(path, newName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _renameFolder(relativePath, newName) {
|
||||||
|
if (!this._supportsFolderManagement() || typeof this.apiClient.renameFolder !== 'function') {
|
||||||
|
showToast('sidebar.renameFolderResult.unsupported', {}, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rootsData = await this.apiClient.fetchModelRoots();
|
||||||
|
const roots = rootsData?.roots || [];
|
||||||
|
const root = this._resolveDefaultRoot(roots);
|
||||||
|
if (!root) {
|
||||||
|
showToast('sidebar.renameFolderResult.noRoot', {}, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = this.combineRootAndRelativePath(root, relativePath);
|
||||||
|
const result = await this.apiClient.renameFolder(absolutePath, newName);
|
||||||
|
|
||||||
|
// Carry the user's place across the rename: the persisted
|
||||||
|
// selection and the expanded set would otherwise point at a folder
|
||||||
|
// the refreshed tree no longer contains.
|
||||||
|
const newPath = result.folder || this._siblingFolderPath(relativePath, newName);
|
||||||
|
this._rekeyFolderPath(relativePath, newPath);
|
||||||
|
|
||||||
|
await this.refresh();
|
||||||
|
|
||||||
|
showToast('sidebar.renameFolderResult.success', { name: newName }, 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SidebarManager] Error renaming folder:', error);
|
||||||
|
if (error?.code === 'target_exists') {
|
||||||
|
showToast('sidebar.renameFolderResult.targetExists', {}, 'warning');
|
||||||
|
} else if (error?.code === 'busy') {
|
||||||
|
showToast('sidebar.renameFolderResult.busy', {}, 'warning');
|
||||||
|
} else {
|
||||||
|
showToast(
|
||||||
|
'sidebar.renameFolderResult.failed',
|
||||||
|
{ message: error?.message || 'Unknown error' },
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_siblingFolderPath(relativePath, newName) {
|
||||||
|
const index = relativePath.lastIndexOf('/');
|
||||||
|
const parent = index === -1 ? '' : relativePath.slice(0, index);
|
||||||
|
return parent ? `${parent}/${newName}` : newName;
|
||||||
|
}
|
||||||
|
|
||||||
|
_rekeyFolderPath(previousPath, newPath) {
|
||||||
|
if (!previousPath || !newPath || previousPath === newPath) return;
|
||||||
|
|
||||||
|
const prefix = `${previousPath}/`;
|
||||||
|
const newPrefix = `${newPath}/`;
|
||||||
|
const rekey = (value) => {
|
||||||
|
if (value === previousPath) return newPath;
|
||||||
|
if (value.startsWith(prefix)) return newPrefix + value.slice(prefix.length);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.expandedNodes.size > 0) {
|
||||||
|
this.expandedNodes = new Set([...this.expandedNodes].map(rekey));
|
||||||
|
this.saveExpandedState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.selectedPath) {
|
||||||
|
const rekeyed = rekey(this.selectedPath);
|
||||||
|
if (rekeyed !== this.selectedPath) {
|
||||||
|
this.selectedPath = rekeyed;
|
||||||
|
if (this.pageControls?.pageState) {
|
||||||
|
this.pageControls.pageState.activeFolder = rekeyed;
|
||||||
|
}
|
||||||
|
setStorageItem(`${this.pageType}_activeFolder`, rekeyed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the folder delete modal for *path*.
|
* Open the folder delete modal for *path*.
|
||||||
*
|
*
|
||||||
@@ -1435,6 +1648,12 @@ export class SidebarManager {
|
|||||||
deleteItem.style.display = this._supportsFolderManagement() ? '' : 'none';
|
deleteItem.style.display = this._supportsFolderManagement() ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renaming an on-disk folder is likewise library-only.
|
||||||
|
const renameItem = menu.querySelector('[data-action="rename-folder"]');
|
||||||
|
if (renameItem) {
|
||||||
|
renameItem.style.display = this._supportsFolderManagement() ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
menu.style.left = `${x}px`;
|
menu.style.left = `${x}px`;
|
||||||
menu.style.top = `${y}px`;
|
menu.style.top = `${y}px`;
|
||||||
menu.style.display = 'block';
|
menu.style.display = 'block';
|
||||||
@@ -1483,6 +1702,9 @@ export class SidebarManager {
|
|||||||
case 'create-subfolder':
|
case 'create-subfolder':
|
||||||
this.showCreateFolderInput(path);
|
this.showCreateFolderInput(path);
|
||||||
break;
|
break;
|
||||||
|
case 'rename-folder':
|
||||||
|
this.showRenameFolderInput(path);
|
||||||
|
break;
|
||||||
case 'delete-folder':
|
case 'delete-folder':
|
||||||
this.showDeleteFolderModal(path);
|
this.showDeleteFolderModal(path);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -209,6 +209,9 @@
|
|||||||
<div class="context-menu-item" data-action="create-subfolder">
|
<div class="context-menu-item" data-action="create-subfolder">
|
||||||
<i class="fas fa-folder-plus"></i> <span>{{ t('sidebar.newSubfolder') }}</span>
|
<i class="fas fa-folder-plus"></i> <span>{{ t('sidebar.newSubfolder') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="rename-folder">
|
||||||
|
<i class="fas fa-i-cursor"></i> <span>{{ t('sidebar.renameFolder') }}</span>
|
||||||
|
</div>
|
||||||
<div class="context-menu-item" data-action="check-folder-updates">
|
<div class="context-menu-item" data-action="check-folder-updates">
|
||||||
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span>
|
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ function createApiClient(overrides = {}) {
|
|||||||
dir_count: 0,
|
dir_count: 0,
|
||||||
restorable: true,
|
restorable: true,
|
||||||
}),
|
}),
|
||||||
|
renameFolder: vi.fn().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
renamed: true,
|
||||||
|
folder: 'renamed',
|
||||||
|
previous_folder: 'empty',
|
||||||
|
}),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -677,3 +683,166 @@ describe('SidebarManager folder deletion', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SidebarManager folder rename', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
||||||
|
state.global.settings = {};
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renameInput() {
|
||||||
|
return document.querySelector('#sidebarRenameFolderInput .sidebar-rename-folder-input');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('turns the node into a prefilled inline row in tree mode', () => {
|
||||||
|
const manager = createManager(createApiClient());
|
||||||
|
manager.treeData = { characters: { anime: {} } };
|
||||||
|
manager.renderTree();
|
||||||
|
|
||||||
|
manager.showRenameFolderInput('characters/anime');
|
||||||
|
|
||||||
|
const row = document.getElementById('sidebarRenameFolderInput');
|
||||||
|
expect(row).not.toBeNull();
|
||||||
|
expect(renameInput().value).toBe('anime');
|
||||||
|
// The node is hidden in place, not removed: the row sits right before it
|
||||||
|
const node = document.querySelector('.sidebar-tree-node[data-path="characters/anime"]');
|
||||||
|
expect(node.style.display).toBe('none');
|
||||||
|
expect(row.nextElementSibling).toBe(node);
|
||||||
|
expect(manager._renameFolderPath).toBe('characters/anime');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts the row in place in list mode', () => {
|
||||||
|
const manager = createManager(createApiClient(), { displayMode: 'list' });
|
||||||
|
manager.foldersList = ['characters', 'characters/anime'];
|
||||||
|
manager.renderFolderList();
|
||||||
|
|
||||||
|
manager.showRenameFolderInput('characters/anime');
|
||||||
|
|
||||||
|
const row = document.getElementById('sidebarRenameFolderInput');
|
||||||
|
expect(row.querySelector('.sidebar-node-content')).not.toBeNull();
|
||||||
|
const item = document.querySelector('.sidebar-folder-item[data-path="characters/anime"]');
|
||||||
|
expect(row.nextElementSibling).toBe(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the node when the edit is canceled', () => {
|
||||||
|
const manager = createManager(createApiClient());
|
||||||
|
manager.treeData = { characters: { anime: {} } };
|
||||||
|
manager.renderTree();
|
||||||
|
|
||||||
|
manager.showRenameFolderInput('characters/anime');
|
||||||
|
manager.handleRenameFolderCancel();
|
||||||
|
|
||||||
|
expect(document.getElementById('sidebarRenameFolderInput')).toBeNull();
|
||||||
|
expect(manager._renameFolderPath).toBeNull();
|
||||||
|
const node = document.querySelector('.sidebar-tree-node[data-path="characters/anime"]');
|
||||||
|
expect(node.style.display).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renames through the API and re-keys the persisted selection', async () => {
|
||||||
|
const apiClient = createApiClient();
|
||||||
|
const manager = createManager(apiClient);
|
||||||
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||||
|
manager.selectedPath = 'characters/anime';
|
||||||
|
manager.expandedNodes = new Set(['characters', 'characters/anime']);
|
||||||
|
manager.pageControls = { pageState: { activeFolder: 'characters/anime' } };
|
||||||
|
|
||||||
|
const success = await manager._renameFolder('characters/anime', 'animation');
|
||||||
|
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(apiClient.renameFolder).toHaveBeenCalledWith('/models/loras/characters/anime', 'animation');
|
||||||
|
expect(manager.selectedPath).toBe('renamed');
|
||||||
|
expect(manager.pageControls.pageState.activeFolder).toBe('renamed');
|
||||||
|
expect(getStorageItem('loras_activeFolder')).toBe('renamed');
|
||||||
|
expect(manager.refresh).toHaveBeenCalledTimes(1);
|
||||||
|
expect(showToast).toHaveBeenCalledWith(
|
||||||
|
'sidebar.renameFolderResult.success', { name: 'animation' }, 'success'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-keys the expanded subtree and the selection', () => {
|
||||||
|
const manager = createManager(createApiClient());
|
||||||
|
manager.expandedNodes = new Set(['a', 'a/b', 'a/b/c', 'x']);
|
||||||
|
manager.selectedPath = 'a/b/c';
|
||||||
|
manager.saveExpandedState = vi.fn();
|
||||||
|
|
||||||
|
manager._rekeyFolderPath('a/b', 'a/z');
|
||||||
|
|
||||||
|
expect([...manager.expandedNodes]).toEqual(['a', 'a/z', 'a/z/c', 'x']);
|
||||||
|
expect(manager.selectedPath).toBe('a/z/c');
|
||||||
|
expect(manager.saveExpandedState).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits the inline edit and skips the API for an unchanged name', async () => {
|
||||||
|
const apiClient = createApiClient();
|
||||||
|
const manager = createManager(apiClient);
|
||||||
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||||
|
manager.treeData = { characters: { anime: {} } };
|
||||||
|
manager.renderTree();
|
||||||
|
|
||||||
|
manager.showRenameFolderInput('characters/anime');
|
||||||
|
renameInput().value = 'anime';
|
||||||
|
await manager.handleRenameFolderSubmit();
|
||||||
|
|
||||||
|
expect(apiClient.renameFolder).not.toHaveBeenCalled();
|
||||||
|
expect(document.getElementById('sidebarRenameFolderInput')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid names before calling the API', async () => {
|
||||||
|
const apiClient = createApiClient();
|
||||||
|
const manager = createManager(apiClient);
|
||||||
|
manager.treeData = { characters: { anime: {} } };
|
||||||
|
manager.renderTree();
|
||||||
|
|
||||||
|
manager.showRenameFolderInput('characters/anime');
|
||||||
|
renameInput().value = 'bad/name';
|
||||||
|
await manager.handleRenameFolderSubmit();
|
||||||
|
|
||||||
|
expect(apiClient.renameFolder).not.toHaveBeenCalled();
|
||||||
|
expect(showToast).toHaveBeenCalledWith('sidebar.dragDrop.invalidFolderName', {}, 'error');
|
||||||
|
// The row stays open so the name can be corrected
|
||||||
|
expect(document.getElementById('sidebarRenameFolderInput')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a name collision', async () => {
|
||||||
|
const conflict = Object.assign(new Error('already exists'), { code: 'target_exists' });
|
||||||
|
const apiClient = createApiClient({
|
||||||
|
renameFolder: vi.fn().mockRejectedValue(conflict),
|
||||||
|
});
|
||||||
|
const manager = createManager(apiClient);
|
||||||
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const success = await manager._renameFolder('characters/anime', 'animation');
|
||||||
|
|
||||||
|
expect(success).toBe(false);
|
||||||
|
expect(showToast).toHaveBeenCalledWith('sidebar.renameFolderResult.targetExists', {}, 'warning');
|
||||||
|
expect(manager.refresh).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes the context-menu action to the inline rename row', () => {
|
||||||
|
const manager = createManager(createApiClient());
|
||||||
|
manager.showRenameFolderInput = vi.fn();
|
||||||
|
|
||||||
|
manager._performFolderAction('rename-folder', 'characters/anime');
|
||||||
|
|
||||||
|
expect(manager.showRenameFolderInput).toHaveBeenCalledWith('characters/anime');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the rename entry when folder management is unsupported', () => {
|
||||||
|
document.body.insertAdjacentHTML('beforeend', `
|
||||||
|
<div id="sidebarFolderContextMenu" class="context-menu">
|
||||||
|
<div class="context-menu-item" data-action="rename-folder"></div>
|
||||||
|
</div>`);
|
||||||
|
const apiClient = createApiClient();
|
||||||
|
apiClient.apiConfig.config.supportsFolderManagement = false;
|
||||||
|
const manager = createManager(apiClient);
|
||||||
|
|
||||||
|
manager._showFolderContextMenu(10, 10, 'empty');
|
||||||
|
|
||||||
|
const item = document.querySelector('#sidebarFolderContextMenu [data-action="rename-folder"]');
|
||||||
|
expect(item.style.display).toBe('none');
|
||||||
|
|
||||||
|
manager._closeFolderContextMenu();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class FakeMoveService:
|
|||||||
self._result = result
|
self._result = result
|
||||||
self.received_path = None
|
self.received_path = None
|
||||||
self.received_dry_run = None
|
self.received_dry_run = None
|
||||||
|
self.received_new_name = None
|
||||||
|
|
||||||
async def create_folder(self, folder_path):
|
async def create_folder(self, folder_path):
|
||||||
self.received_path = folder_path
|
self.received_path = folder_path
|
||||||
@@ -21,6 +22,11 @@ class FakeMoveService:
|
|||||||
self.received_dry_run = dry_run
|
self.received_dry_run = dry_run
|
||||||
return self._result
|
return self._result
|
||||||
|
|
||||||
|
async def rename_folder(self, folder_path, new_name):
|
||||||
|
self.received_path = folder_path
|
||||||
|
self.received_new_name = new_name
|
||||||
|
return self._result
|
||||||
|
|
||||||
|
|
||||||
class FakeRequest:
|
class FakeRequest:
|
||||||
def __init__(self, payload):
|
def __init__(self, payload):
|
||||||
@@ -212,3 +218,116 @@ async def test_delete_folder_invalid_json_body():
|
|||||||
|
|
||||||
assert response.status == 400
|
assert response.status == 400
|
||||||
assert json.loads(response.text)["success"] is False
|
assert json.loads(response.text)["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_success():
|
||||||
|
handler, service = _make_handler(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"renamed": True,
|
||||||
|
"folder": "characters/animation",
|
||||||
|
"previous_folder": "characters/anime",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.rename_folder(
|
||||||
|
FakeRequest(
|
||||||
|
{"folder_path": "/library/characters/anime", "new_name": "animation"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["folder"] == "characters/animation"
|
||||||
|
assert service.received_path == "/library/characters/anime"
|
||||||
|
assert service.received_new_name == "animation"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_missing_path():
|
||||||
|
handler, service = _make_handler({"success": True})
|
||||||
|
|
||||||
|
response = await handler.rename_folder(FakeRequest({"new_name": "animation"}))
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
assert json.loads(response.text)["success"] is False
|
||||||
|
assert service.received_path is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_missing_name():
|
||||||
|
handler, service = _make_handler({"success": True})
|
||||||
|
|
||||||
|
response = await handler.rename_folder(
|
||||||
|
FakeRequest({"folder_path": "/library/characters/anime"})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
assert payload["success"] is False
|
||||||
|
assert service.received_new_name is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_target_exists_maps_to_409():
|
||||||
|
handler, _service = _make_handler(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"code": "target_exists",
|
||||||
|
"error": 'A folder named "animation" already exists here',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.rename_folder(
|
||||||
|
FakeRequest(
|
||||||
|
{"folder_path": "/library/characters/anime", "new_name": "animation"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 409
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
assert payload["code"] == "target_exists"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_busy_maps_to_409():
|
||||||
|
handler, _service = _make_handler(
|
||||||
|
{"success": False, "code": "busy", "error": "staged delete pending"}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.rename_folder(
|
||||||
|
FakeRequest({"folder_path": "/library/full", "new_name": "renamed"})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 409
|
||||||
|
assert json.loads(response.text)["code"] == "busy"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_invalid_name_maps_to_400():
|
||||||
|
handler, _service = _make_handler(
|
||||||
|
{"success": False, "error": "Invalid characters in folder name"}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.rename_folder(
|
||||||
|
FakeRequest({"folder_path": "/library/full", "new_name": "a/b"})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
assert json.loads(response.text)["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_invalid_json_body():
|
||||||
|
class BadJsonRequest:
|
||||||
|
async def json(self):
|
||||||
|
raise ValueError("bad json")
|
||||||
|
|
||||||
|
handler, _service = _make_handler({"success": True})
|
||||||
|
|
||||||
|
response = await handler.rename_folder(BadJsonRequest())
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
assert json.loads(response.text)["success"] is False
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class FakeScanner:
|
|||||||
self._roots = [str(root) for root in roots]
|
self._roots = [str(root) for root in roots]
|
||||||
self.known_folders: List[str] = []
|
self.known_folders: List[str] = []
|
||||||
self.removed_folders: List[str] = []
|
self.removed_folders: List[str] = []
|
||||||
|
self.renamed_folders: List[tuple] = []
|
||||||
|
|
||||||
def get_model_roots(self) -> List[str]:
|
def get_model_roots(self) -> List[str]:
|
||||||
return list(self._roots)
|
return list(self._roots)
|
||||||
@@ -25,6 +26,9 @@ class FakeScanner:
|
|||||||
async def remove_known_folder(self, folder: str) -> None:
|
async def remove_known_folder(self, folder: str) -> None:
|
||||||
self.removed_folders.append(folder)
|
self.removed_folders.append(folder)
|
||||||
|
|
||||||
|
async def rename_known_folder(self, previous: str, current: str, **kwargs) -> None:
|
||||||
|
self.renamed_folders.append((previous, current, kwargs))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_folder_creates_directory_and_registers_it(tmp_path: Path):
|
async def test_create_folder_creates_directory_and_registers_it(tmp_path: Path):
|
||||||
@@ -271,3 +275,154 @@ async def test_delete_folder_counts_nested_symlinks_without_following_them(tmp_p
|
|||||||
# The linked model is not part of the subtree being deleted
|
# The linked model is not part of the subtree being deleted
|
||||||
assert result["model_count"] == 0
|
assert result["model_count"] == 0
|
||||||
assert (real / "model.safetensors").exists()
|
assert (real / "model.safetensors").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_moves_directory_and_forwards_rekey(tmp_path: Path):
|
||||||
|
target = _make_nested(tmp_path)
|
||||||
|
(target / "model.safetensors").write_text("weights", encoding="utf-8")
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.rename_folder(str(target), "animation")
|
||||||
|
|
||||||
|
renamed = tmp_path / "characters" / "animation"
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["renamed"] is True
|
||||||
|
assert result["folder"] == "characters/animation"
|
||||||
|
assert result["previous_folder"] == "characters/anime"
|
||||||
|
assert renamed.is_dir()
|
||||||
|
assert (renamed / "model.safetensors").exists()
|
||||||
|
assert not target.exists()
|
||||||
|
|
||||||
|
previous, current, kwargs = scanner.renamed_folders[0]
|
||||||
|
assert previous == "characters/anime"
|
||||||
|
assert current == "characters/animation"
|
||||||
|
assert kwargs["previous_path"] == target.as_posix()
|
||||||
|
assert kwargs["new_path"] == renamed.as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_noop_when_name_is_unchanged(tmp_path: Path):
|
||||||
|
target = _make_nested(tmp_path)
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.rename_folder(str(target), "anime")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["renamed"] is False
|
||||||
|
assert target.is_dir()
|
||||||
|
assert scanner.renamed_folders == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_refuses_existing_target(tmp_path: Path):
|
||||||
|
target = _make_nested(tmp_path)
|
||||||
|
(tmp_path / "characters" / "animation").mkdir()
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.rename_folder(str(target), "animation")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["code"] == "target_exists"
|
||||||
|
assert target.is_dir()
|
||||||
|
assert scanner.renamed_folders == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("new_name", ["", " ", "a/b", "..", ".", "bad:name", "back\\slash"])
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_rejects_invalid_names(tmp_path: Path, new_name: str):
|
||||||
|
target = _make_nested(tmp_path)
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.rename_folder(str(target), new_name)
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert target.is_dir()
|
||||||
|
assert scanner.renamed_folders == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_refuses_the_library_root_itself(tmp_path: Path):
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.rename_folder(str(tmp_path), "renamed-root")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "root" in result["error"].lower()
|
||||||
|
assert tmp_path.is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_rejects_paths_outside_roots(tmp_path: Path):
|
||||||
|
root = tmp_path / "library"
|
||||||
|
root.mkdir()
|
||||||
|
outside = tmp_path / "outside"
|
||||||
|
outside.mkdir()
|
||||||
|
scanner = FakeScanner([root])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.rename_folder(str(outside), "renamed")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert outside.is_dir()
|
||||||
|
assert scanner.renamed_folders == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_reports_missing_directory(tmp_path: Path):
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.rename_folder(str(tmp_path / "gone"), "renamed")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "no longer exists" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_refuses_symlinked_directory(tmp_path: Path):
|
||||||
|
real = tmp_path / "real"
|
||||||
|
real.mkdir()
|
||||||
|
link = tmp_path / "link"
|
||||||
|
try:
|
||||||
|
link.symlink_to(real, target_is_directory=True)
|
||||||
|
except (OSError, NotImplementedError): # pragma: no cover - platform guard
|
||||||
|
pytest.skip("symlinks are not supported on this platform")
|
||||||
|
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.rename_folder(str(link), "renamed")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "symlink" in result["error"].lower()
|
||||||
|
assert link.is_symlink()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
|
||||||
|
target = _make_nested(tmp_path)
|
||||||
|
(target / ".lm-pending-delete").mkdir()
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.rename_folder(str(target), "animation")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["code"] == "busy"
|
||||||
|
assert target.is_dir()
|
||||||
|
assert scanner.renamed_folders == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_requires_path(tmp_path: Path):
|
||||||
|
service = ModelMoveService(FakeScanner([tmp_path]), "lora")
|
||||||
|
|
||||||
|
result = await service.rename_folder("", "renamed")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
|||||||
@@ -1609,6 +1609,161 @@ async def test_remove_known_folder_ignores_empty_input(tmp_path: Path):
|
|||||||
assert cache.all_folders == before
|
assert cache.all_folders == before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_known_folder_rekeys_folders_cache_and_sidecar(tmp_path: Path):
|
||||||
|
_, second, _ = _create_files(tmp_path)
|
||||||
|
nested = tmp_path / "nested"
|
||||||
|
preview = nested / "two.preview.png"
|
||||||
|
preview.write_text("png", encoding="utf-8")
|
||||||
|
(nested / "two.metadata.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"file_path": _normalize_path(second),
|
||||||
|
"preview_url": _normalize_path(preview),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
|
||||||
|
entry = next(item for item in cache.raw_data if item["model_name"] == "two")
|
||||||
|
entry["preview_url"] = _normalize_path(preview)
|
||||||
|
|
||||||
|
renamed = tmp_path / "renamed"
|
||||||
|
old_abs = _normalize_path(nested)
|
||||||
|
new_abs = _normalize_path(renamed)
|
||||||
|
os.rename(nested, renamed)
|
||||||
|
|
||||||
|
changed = await scanner.rename_known_folder(
|
||||||
|
"nested", "renamed", previous_path=old_abs, new_path=new_abs
|
||||||
|
)
|
||||||
|
|
||||||
|
assert changed is True
|
||||||
|
assert "renamed" in cache.all_folders
|
||||||
|
assert "nested" not in cache.all_folders
|
||||||
|
assert "renamed" in cache.folders
|
||||||
|
assert "nested" not in cache.folders
|
||||||
|
assert entry["folder"] == "renamed"
|
||||||
|
assert entry["file_path"] == _normalize_path(renamed / "two.txt")
|
||||||
|
assert entry["preview_url"] == _normalize_path(renamed / "two.preview.png")
|
||||||
|
assert scanner._hash_index.get_path("hash-two") == _normalize_path(
|
||||||
|
renamed / "two.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The sidecar travelled with the directory and was re-pointed in place
|
||||||
|
payload = json.loads(
|
||||||
|
(renamed / "two.metadata.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
assert payload["file_path"] == _normalize_path(renamed / "two.txt")
|
||||||
|
assert payload["preview_url"] == _normalize_path(renamed / "two.preview.png")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_known_folder_handles_nested_targets(tmp_path: Path):
|
||||||
|
(tmp_path / "a" / "b" / "c").mkdir(parents=True)
|
||||||
|
model = tmp_path / "a" / "b" / "c" / "m.txt"
|
||||||
|
model.write_text("m", encoding="utf-8")
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
|
||||||
|
old_abs = _normalize_path(tmp_path / "a" / "b")
|
||||||
|
new_abs = _normalize_path(tmp_path / "a" / "z")
|
||||||
|
os.rename(tmp_path / "a" / "b", tmp_path / "a" / "z")
|
||||||
|
|
||||||
|
await scanner.rename_known_folder(
|
||||||
|
"a/b", "a/z", previous_path=old_abs, new_path=new_abs
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "a/b" not in cache.all_folders
|
||||||
|
assert "a/b/c" not in cache.all_folders
|
||||||
|
assert "a/z" in cache.all_folders
|
||||||
|
assert "a/z/c" in cache.all_folders
|
||||||
|
# The parent is an untouched directory in its own right
|
||||||
|
assert "a" in cache.all_folders
|
||||||
|
|
||||||
|
entry = next(item for item in cache.raw_data if item["model_name"] == "m")
|
||||||
|
assert entry["folder"] == "a/z/c"
|
||||||
|
assert entry["file_path"] == _normalize_path(tmp_path / "a" / "z" / "c" / "m.txt")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_known_folder_keeps_unrelated_entries(tmp_path: Path):
|
||||||
|
first, _, _ = _create_files(tmp_path)
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
|
||||||
|
old_abs = _normalize_path(tmp_path / "nested")
|
||||||
|
new_abs = _normalize_path(tmp_path / "renamed")
|
||||||
|
os.rename(tmp_path / "nested", tmp_path / "renamed")
|
||||||
|
|
||||||
|
await scanner.rename_known_folder(
|
||||||
|
"nested", "renamed", previous_path=old_abs, new_path=new_abs
|
||||||
|
)
|
||||||
|
|
||||||
|
root_entry = next(item for item in cache.raw_data if item["model_name"] == "one")
|
||||||
|
assert root_entry["folder"] == ""
|
||||||
|
assert root_entry["file_path"] == _normalize_path(first)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_known_folder_rekeys_excluded_models(tmp_path: Path):
|
||||||
|
nested = tmp_path / "nested"
|
||||||
|
nested.mkdir()
|
||||||
|
(nested / "one.txt").write_text("one", encoding="utf-8")
|
||||||
|
(nested / "skip-me.txt").write_text("skip", encoding="utf-8")
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
|
||||||
|
assert scanner._excluded_models == [_normalize_path(nested / "skip-me.txt")]
|
||||||
|
|
||||||
|
old_abs = _normalize_path(nested)
|
||||||
|
new_abs = _normalize_path(tmp_path / "renamed")
|
||||||
|
os.rename(nested, tmp_path / "renamed")
|
||||||
|
|
||||||
|
await scanner.rename_known_folder(
|
||||||
|
"nested", "renamed", previous_path=old_abs, new_path=new_abs
|
||||||
|
)
|
||||||
|
|
||||||
|
assert scanner._excluded_models == [
|
||||||
|
_normalize_path(tmp_path / "renamed" / "skip-me.txt")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_known_folder_ignores_unchanged_or_empty_names(tmp_path: Path):
|
||||||
|
_create_files(tmp_path)
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
before = list(cache.all_folders)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
await scanner.rename_known_folder(
|
||||||
|
"nested",
|
||||||
|
"nested",
|
||||||
|
previous_path=_normalize_path(tmp_path / "nested"),
|
||||||
|
new_path=_normalize_path(tmp_path / "nested"),
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await scanner.rename_known_folder(
|
||||||
|
"",
|
||||||
|
"renamed",
|
||||||
|
previous_path=_normalize_path(tmp_path),
|
||||||
|
new_path=_normalize_path(tmp_path / "renamed"),
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert cache.all_folders == before
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_all_folders_updated_after_move(tmp_path: Path):
|
async def test_get_all_folders_updated_after_move(tmp_path: Path):
|
||||||
first, _, _ = _create_files(tmp_path)
|
first, _, _ = _create_files(tmp_path)
|
||||||
|
|||||||
Reference in New Issue
Block a user