From 4938faa049936cd16ced1da65519b1b7fc4cac3f Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 15 Sep 2026 20:05:10 +0800 Subject: [PATCH] feat(sidebar): delete folders from the sidebar (#999) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folders created from the sidebar had no in-app way back out: the only removal path was to leave ComfyUI, delete the directory by hand and rescan. A typo'd folder also polluted the move/download destination picker permanently, since it reads the same all_folders source. Adds POST /api/lm/{prefix}/delete-folder, restricted to directories whose subtree holds no model weight files — a folder-level cascade would bypass the per-model lifecycle bookkeeping (metadata sidecars, previews, cache entries, pending-delete staging, recipe references). The service walks the directory itself instead of trusting the possibly stale cache, reports what it would remove (models / files / subfolders / symlinks), and refuses library roots, top-level symlinks (shutil.rmtree rejects those) and folders holding a staged delete, whose manifest would be invalidated by the move. Symbolic links inside the subtree are counted but never followed. ModelScanner.remove_known_folder mirrors add_known_folder: the removed subtree leaves all_folders while ancestors are kept (every recorded ancestor exists on disk in its own right), stale cache entries under the prefix are purged and the folder list recomputed. The handler broadcasts models_changed so destination pickers drop the folder too. The sidebar entry is a destructive context-menu item. The modal opens in a confirm state for model-free folders and an explanatory one when the subtree still holds models, decided from the models-only set that already dims empty nodes; a stale tree is caught by the 409 not_empty/busy conflict. Truly empty folders get the existing 20s undo affordance, implemented by re-creating the directory. --- locales/de.json | 20 ++ locales/en.json | 20 ++ locales/es.json | 20 ++ locales/fr.json | 20 ++ locales/he.json | 20 ++ locales/ja.json | 20 ++ locales/ko.json | 20 ++ locales/ru.json | 20 ++ locales/zh-CN.json | 20 ++ locales/zh-TW.json | 20 ++ py/routes/handlers/model_handlers.py | 32 +++ py/routes/model_route_registrar.py | 1 + py/services/model_file_service.py | 178 +++++++++++++- py/services/model_scanner.py | 57 +++++ static/js/api/apiConfig.js | 1 + static/js/api/baseModelApi.js | 34 +++ static/js/components/SidebarManager.js | 200 +++++++++++++++- static/js/managers/ModalManager.js | 13 ++ templates/components/context_menu.html | 4 + .../components/modals/confirm_modals.html | 15 ++ .../sidebarManager.folderManagement.test.js | 218 +++++++++++++++++- tests/routes/test_model_move_handler.py | 121 ++++++++++ tests/services/test_model_file_service.py | 184 +++++++++++++++ tests/services/test_model_scanner.py | 62 +++++ 24 files changed, 1317 insertions(+), 3 deletions(-) diff --git a/locales/de.json b/locales/de.json index f7058d01..94ee7bb1 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1263,6 +1263,26 @@ "unsupported": "[TODO: Translate] Folder creation is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "deleteFolder": "[TODO: Translate] Delete folder", + "deleteFolderModal": { + "title": "[TODO: Translate] Delete folder?", + "message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "[TODO: Translate] Folder", + "emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "[TODO: Translate] Folder is not empty", + "notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "[TODO: Translate] Delete folder" + }, + "deleteFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" deleted", + "successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "[TODO: Translate] Folder restored", + "failed": "[TODO: Translate] Failed to delete folder: {message}", + "notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.", "moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.", diff --git a/locales/en.json b/locales/en.json index db841f3b..b2c5f4cb 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1263,6 +1263,26 @@ "unsupported": "Folder creation is not supported on this page", "noRoot": "No model root is configured" }, + "deleteFolder": "Delete folder", + "deleteFolderModal": { + "title": "Delete folder?", + "message": "The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "Folder", + "emptyNote": "This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "Folder is not empty", + "notEmptyMessage": "This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "Delete folder" + }, + "deleteFolderResult": { + "success": "Folder \"{name}\" deleted", + "successWithFiles": "Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "Folder restored", + "failed": "Failed to delete folder: {message}", + "notEmpty": "This folder still contains models. Refresh the sidebar and try again.", + "busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "Folder deletion is not supported on this page", + "noRoot": "No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "Unable to determine destination path for move.", "moveUnsupported": "Move is not supported for this item.", diff --git a/locales/es.json b/locales/es.json index 28193ecd..4ead463a 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1263,6 +1263,26 @@ "unsupported": "[TODO: Translate] Folder creation is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "deleteFolder": "[TODO: Translate] Delete folder", + "deleteFolderModal": { + "title": "[TODO: Translate] Delete folder?", + "message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "[TODO: Translate] Folder", + "emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "[TODO: Translate] Folder is not empty", + "notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "[TODO: Translate] Delete folder" + }, + "deleteFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" deleted", + "successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "[TODO: Translate] Folder restored", + "failed": "[TODO: Translate] Failed to delete folder: {message}", + "notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.", "moveUnsupported": "El movimiento no es compatible con este elemento.", diff --git a/locales/fr.json b/locales/fr.json index 0ee41ce8..37634a55 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1263,6 +1263,26 @@ "unsupported": "[TODO: Translate] Folder creation is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "deleteFolder": "[TODO: Translate] Delete folder", + "deleteFolderModal": { + "title": "[TODO: Translate] Delete folder?", + "message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "[TODO: Translate] Folder", + "emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "[TODO: Translate] Folder is not empty", + "notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "[TODO: Translate] Delete folder" + }, + "deleteFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" deleted", + "successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "[TODO: Translate] Folder restored", + "failed": "[TODO: Translate] Failed to delete folder: {message}", + "notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "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.", diff --git a/locales/he.json b/locales/he.json index dbff4651..ee6308c5 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1263,6 +1263,26 @@ "unsupported": "[TODO: Translate] Folder creation is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "deleteFolder": "[TODO: Translate] Delete folder", + "deleteFolderModal": { + "title": "[TODO: Translate] Delete folder?", + "message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "[TODO: Translate] Folder", + "emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "[TODO: Translate] Folder is not empty", + "notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "[TODO: Translate] Delete folder" + }, + "deleteFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" deleted", + "successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "[TODO: Translate] Folder restored", + "failed": "[TODO: Translate] Failed to delete folder: {message}", + "notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.", "moveUnsupported": "העברה אינה נתמכת עבור פריט זה.", diff --git a/locales/ja.json b/locales/ja.json index 385564c5..fbb7dc3d 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1263,6 +1263,26 @@ "unsupported": "[TODO: Translate] Folder creation is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "deleteFolder": "[TODO: Translate] Delete folder", + "deleteFolderModal": { + "title": "[TODO: Translate] Delete folder?", + "message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "[TODO: Translate] Folder", + "emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "[TODO: Translate] Folder is not empty", + "notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "[TODO: Translate] Delete folder" + }, + "deleteFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" deleted", + "successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "[TODO: Translate] Folder restored", + "failed": "[TODO: Translate] Failed to delete folder: {message}", + "notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "移動先のパスを特定できません。", "moveUnsupported": "この項目の移動はサポートされていません。", diff --git a/locales/ko.json b/locales/ko.json index e86ff47b..248802e1 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1263,6 +1263,26 @@ "unsupported": "[TODO: Translate] Folder creation is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "deleteFolder": "[TODO: Translate] Delete folder", + "deleteFolderModal": { + "title": "[TODO: Translate] Delete folder?", + "message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "[TODO: Translate] Folder", + "emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "[TODO: Translate] Folder is not empty", + "notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "[TODO: Translate] Delete folder" + }, + "deleteFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" deleted", + "successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "[TODO: Translate] Folder restored", + "failed": "[TODO: Translate] Failed to delete folder: {message}", + "notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.", "moveUnsupported": "이 항목은 이동을 지원하지 않습니다.", diff --git a/locales/ru.json b/locales/ru.json index 4359e40d..bbe986ae 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1263,6 +1263,26 @@ "unsupported": "[TODO: Translate] Folder creation is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "deleteFolder": "[TODO: Translate] Delete folder", + "deleteFolderModal": { + "title": "[TODO: Translate] Delete folder?", + "message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "[TODO: Translate] Folder", + "emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "[TODO: Translate] Folder is not empty", + "notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "[TODO: Translate] Delete folder" + }, + "deleteFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" deleted", + "successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "[TODO: Translate] Folder restored", + "failed": "[TODO: Translate] Failed to delete folder: {message}", + "notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "Не удалось определить путь назначения для перемещения.", "moveUnsupported": "Перемещение этого элемента не поддерживается.", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 4abc41c0..20f60d4f 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1263,6 +1263,26 @@ "unsupported": "[TODO: Translate] Folder creation is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "deleteFolder": "[TODO: Translate] Delete folder", + "deleteFolderModal": { + "title": "[TODO: Translate] Delete folder?", + "message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "[TODO: Translate] Folder", + "emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "[TODO: Translate] Folder is not empty", + "notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "[TODO: Translate] Delete folder" + }, + "deleteFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" deleted", + "successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "[TODO: Translate] Folder restored", + "failed": "[TODO: Translate] Failed to delete folder: {message}", + "notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "无法确定移动的目标路径。", "moveUnsupported": "此条目不支持移动。", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index a85a7081..b84ea0d2 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1263,6 +1263,26 @@ "unsupported": "[TODO: Translate] Folder creation is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "deleteFolder": "[TODO: Translate] Delete folder", + "deleteFolderModal": { + "title": "[TODO: Translate] Delete folder?", + "message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "[TODO: Translate] Folder", + "emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "[TODO: Translate] Folder is not empty", + "notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "[TODO: Translate] Delete folder" + }, + "deleteFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" deleted", + "successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "[TODO: Translate] Folder restored", + "failed": "[TODO: Translate] Failed to delete folder: {message}", + "notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "無法確定移動的目標路徑。", "moveUnsupported": "此項目不支援移動。", diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index beb4e9c7..68b68c9b 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -2499,6 +2499,37 @@ class ModelMoveHandler: self._logger.error("Error creating folder: %s", exc, exc_info=True) return web.json_response({"success": False, "error": str(exc)}, status=500) + async def delete_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") + if not folder_path: + return web.json_response( + {"success": False, "error": "Folder path is required"}, status=400 + ) + dry_run = bool(data.get("dry_run")) + result = await self._move_service.delete_folder( + folder_path, dry_run=dry_run + ) + if result.get("success"): + if not dry_run: + _broadcast_models_changed() + return web.json_response(result, status=200) + + # "not_empty" / "busy" are conflicts between the tree the client + # rendered and the on-disk truth; everything else is a bad request. + code = result.get("code") + status = 409 if code in ("not_empty", "busy") else 400 + return web.json_response(result, status=status) + except Exception as exc: + self._logger.error("Error deleting 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: try: data = await request.json() @@ -3450,6 +3481,7 @@ class ModelHandlerSet: "move_model": self.move.move_model, "move_models_bulk": self.move.move_models_bulk, "create_folder": self.move.create_folder, + "delete_folder": self.move.delete_folder, "auto_organize_models": self.auto_organize.auto_organize_models, "get_auto_organize_progress": self.auto_organize.get_auto_organize_progress, "get_model_notes": self.query.get_model_notes, diff --git a/py/routes/model_route_registrar.py b/py/routes/model_route_registrar.py index 7fa536c7..c00472da 100644 --- a/py/routes/model_route_registrar.py +++ b/py/routes/model_route_registrar.py @@ -41,6 +41,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("POST", "/api/lm/{prefix}/move_model", "move_model"), 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}/delete-folder", "delete_folder"), RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition( diff --git a/py/services/model_file_service.py b/py/services/model_file_service.py index 88ddc15a..87d12828 100644 --- a/py/services/model_file_service.py +++ b/py/services/model_file_service.py @@ -2,13 +2,15 @@ import asyncio import fnmatch import os import logging +import shutil from typing import Any, Dict, List, Optional, Sequence, Set from abc import ABC, abstractmethod from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs -from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE +from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE, MODEL_FILE_EXTENSIONS from ..services.settings_manager import get_settings_manager from ..services.model_lifecycle_service import _require_path_in_library_roots +from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME logger = logging.getLogger(__name__) @@ -536,6 +538,180 @@ class ModelMoveService: return rel.replace(os.sep, "/") return "" + 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. + + Only directories whose subtree holds no model weight files can be + removed: a folder-level cascade would bypass the per-model lifecycle + bookkeeping (metadata sidecars, previews, cache entries, pending-delete + staging and recipe references), so it is deliberately refused. Leftover + non-model files (stray previews, sidecars, ``.bak`` files) are reported + in the manifest before they are removed. + + Args: + folder_path: Absolute path of the directory to remove (business + path — symlinks are not resolved) + dry_run: When true, only report what would be removed + + Returns: + Dictionary with the success flag plus a removal manifest + (``model_count``/``file_count``/``dir_count``/``symlink_count``/ + ``total_bytes``/``restorable``) on success. + """ + try: + if not folder_path or not str(folder_path).strip(): + return {"success": False, "error": "Folder path is required"} + + _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): + # shutil.rmtree refuses symlinked roots, and silently deleting + # the link (leaving the real directory behind) is a separate + # decision we do not make here. + return { + "success": False, + "error": "Symlinked folders cannot be deleted", + } + 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 deleted", + } + + manifest = self._collect_folder_manifest(absolute_path) + + if manifest["pending_delete_job"]: + return { + "success": False, + "code": "busy", + "error": ( + "A staged delete is still pending inside this folder; " + "wait for the undo window to expire" + ), + "manifest": manifest, + } + + if manifest["model_count"] > 0: + return { + "success": False, + "code": "not_empty", + "error": ( + f"Folder still contains {manifest['model_count']} model " + "file(s); delete or move them first" + ), + "manifest": manifest, + } + + relative_folder = self._calculate_relative_folder(absolute_path) + + if dry_run: + return { + "success": True, + "dry_run": True, + "folder_path": absolute_path.replace(os.sep, "/"), + "folder": relative_folder, + **manifest, + } + + shutil.rmtree(absolute_path) + + await self._forget_folder(relative_folder) + + return { + "success": True, + "dry_run": False, + "folder_path": absolute_path.replace(os.sep, "/"), + "folder": relative_folder, + **manifest, + } + except ValueError as exc: + return {"success": False, "error": str(exc)} + except Exception as exc: + logger.error(f"Error deleting folder: {exc}", exc_info=True) + return {"success": False, "error": str(exc)} + + def _is_model_root(self, absolute_path: str) -> bool: + """Return True when the path *is* one of the configured library roots.""" + normalized = os.path.normpath(absolute_path) + for root in self.scanner.get_model_roots(): + if os.path.normpath(os.path.abspath(root)) == normalized: + return True + return False + + @staticmethod + def _is_model_file(file_name: str) -> bool: + """Return True when the file name carries a model weight extension.""" + return os.path.splitext(file_name)[1].lower() in MODEL_FILE_EXTENSIONS + + def _collect_folder_manifest(self, absolute_path: str) -> Dict[str, Any]: + """Describe everything a recursive delete of *absolute_path* removes. + + Walking is intentional: the scanner cache can be stale, and a model file + that appeared on disk since the last scan must still block the delete. + Symbolic links are never followed (``os.walk`` default) and are counted + separately — ``shutil.rmtree`` unlinks them without touching their + targets. + """ + model_count = 0 + file_count = 0 + dir_count = 0 + symlink_count = 0 + total_bytes = 0 + pending_delete_job = False + + for dirpath, dirnames, filenames in os.walk(absolute_path): + if PENDING_DELETE_DIR_NAME in dirnames: + pending_delete_job = True + + for name in dirnames: + if os.path.islink(os.path.join(dirpath, name)): + symlink_count += 1 + else: + dir_count += 1 + + for name in filenames: + full_path = os.path.join(dirpath, name) + if os.path.islink(full_path): + symlink_count += 1 + continue + if self._is_model_file(name): + model_count += 1 + else: + file_count += 1 + try: + total_bytes += os.path.getsize(full_path) + except OSError: # pragma: no cover - defensive + pass + + return { + "model_count": model_count, + "file_count": file_count, + "dir_count": dir_count, + "symlink_count": symlink_count, + "total_bytes": total_bytes, + "pending_delete_job": pending_delete_job, + # A truly empty directory is the only case an "undo" can restore by + # simply recreating it; a folder holding stray files is gone for good. + "restorable": ( + model_count == 0 + and file_count == 0 + and dir_count == 0 + and symlink_count == 0 + ), + } + + async def _forget_folder(self, relative_folder: str) -> None: + """Drop a removed directory from the scanner's folder/cache records.""" + 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) + async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]: """Move a single model file diff --git a/py/services/model_scanner.py b/py/services/model_scanner.py index 919ccc98..3864e9e5 100644 --- a/py/services/model_scanner.py +++ b/py/services/model_scanner.py @@ -1505,6 +1505,63 @@ class ModelScanner: await self._persist_current_cache() self.bump_cache_version() + async def remove_known_folder(self, folder: str) -> None: + """Forget a folder (and its subtree) that no longer exists on disk. + + Counterpart of :meth:`add_known_folder`, called after a directory is + removed between scans (e.g. via the delete-folder API) so folder trees + and the move/download destination pickers stop offering it without a + full rescan. Ancestors are kept on purpose: every recorded ancestor + exists on disk in its own right, so only the removed subtree is dropped. + + Cache entries that referenced the now-missing directory are purged as + well, which keeps a stale (phantom) model card from surviving the + deletion. When ``all_folders`` has not been recorded yet (legacy + snapshot) only the cache purge runs — the scheduled backfill walk + rebuilds the folder list from disk. + """ + normalized = folder.replace("\\", "/").strip("/") + if not normalized: + return + cache = self._cache + if cache is None: + return + + prefix = f"{normalized}/" + + folders_changed = False + recorded = getattr(cache, "all_folders", None) + if recorded is not None: + updated = [ + entry + for entry in recorded + if entry != normalized and not entry.startswith(prefix) + ] + if updated != list(recorded): + cache.all_folders = updated + folders_changed = True + + stale_paths = [ + item.get("file_path") + for item in (cache.raw_data or []) + if self._folder_within(item.get("folder", ""), normalized) + ] + if stale_paths: + # The purge persists the cache — including the already updated + # all_folders list — and bumps the version itself. + await self._batch_update_cache_for_deleted_models(stale_paths) + folders = set(item.get("folder", "") for item in cache.raw_data) + cache.folders = sorted(folders, key=lambda x: x.lower()) + elif folders_changed: + await self._persist_current_cache() + + self.bump_cache_version() + + @staticmethod + def _folder_within(candidate: str, target: str) -> bool: + """Return True when *candidate* is *target* or lives below it.""" + return candidate == target or candidate.startswith(f"{target}/") + def _schedule_all_folders_backfill(self) -> None: """Kick off a one-shot background folder walk if none is running.""" if self._all_folders_backfill_running: diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index 15b5bde0..d6aa33b5 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -84,6 +84,7 @@ export function getApiEndpoints(modelType) { moveModel: `/api/lm/${modelType}/move_model`, moveBulk: `/api/lm/${modelType}/move_models_bulk`, createFolder: `/api/lm/${modelType}/create-folder`, + deleteFolder: `/api/lm/${modelType}/delete-folder`, // CivitAI integration fetchCivitai: `/api/lm/${modelType}/fetch-civitai`, diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index b495fedc..682de035 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1330,6 +1330,40 @@ export class BaseModelApiClient { return result; } + /** + * Delete a model-free folder inside the library roots. + * + * Only model-free folders can be removed; the backend answers with a 409 + * `not_empty`/`busy` conflict otherwise. Those codes are attached to the + * thrown Error (`code`, `manifest`) so callers can explain the refusal + * instead of showing a bare message. + * + * @param {string} folderPath Absolute business path of the folder + * @param {{dryRun?: boolean}} [options] + */ + async deleteFolder(folderPath, options = {}) { + const { dryRun = false } = options || {}; + + const response = await fetch(this.apiConfig.endpoints.deleteFolder, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ folder_path: folderPath, dry_run: dryRun }) + }); + + const result = await response.json().catch(() => ({})); + + if (!response.ok || result.success === false) { + const error = new Error(result.error || `Failed to delete folder`); + error.code = result.code || null; + error.manifest = result.manifest || null; + throw error; + } + + return result; + } + async fetchUnifiedFolderTree(options = {}) { try { const { includeEmpty = false } = options; diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index 08af10a0..95daadf7 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -6,7 +6,8 @@ import { getModelApiClient } from '../api/modelApiFactory.js'; import { translate } from '../utils/i18nHelpers.js'; import { state, getCurrentPageState } from '../state/index.js'; import { bulkManager } from '../managers/BulkManager.js'; -import { showToast } from '../utils/uiHelpers.js'; +import { modalManager } from '../managers/ModalManager.js'; +import { showToast, showActionToast } from '../utils/uiHelpers.js'; import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js'; import { escapeHtml, escapeAttribute } from './shared/utils.js'; import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js'; @@ -46,6 +47,8 @@ export class SidebarManager { this.nonEmptyFolders = null; // models-only folder set used to dim empty nodes this._createFolderBasePath = null; this._createFolderTempChildren = null; // children container added for a leaf parent during inline creation + this._pendingDeleteFolderPath = null; + this._deleteFolderModalWired = false; // Bind methods this.handleTreeClick = this.handleTreeClick.bind(this); @@ -133,6 +136,7 @@ export class SidebarManager { this.nonEmptyFolders = null; this._createFolderBasePath = null; this._createFolderTempChildren = null; + this._pendingDeleteFolderPath = null; // Reset container margin const container = document.querySelector('.container'); @@ -759,6 +763,188 @@ export class SidebarManager { this.hideCreateFolderInput(); } + /** + * Open the folder delete modal for *path*. + * + * The tree already knows whether the subtree holds models (the same + * models-only set that dims empty nodes), so the modal opens in one of two + * states without a round trip: a confirmation for a model-free folder, or + * an explanation when models would have to be cascaded over — a + * folder-level cascade would bypass the per-model lifecycle bookkeeping, + * so the backend refuses it and the UI says why. + */ + showDeleteFolderModal(path) { + const modal = document.getElementById('deleteFolderModal'); + if (!modal) return; + + // Defensive: the modal may have been absent when listeners were wired. + this._wireDeleteFolderModal(); + + const title = modal.querySelector('[data-role="title"]'); + const message = modal.querySelector('[data-role="message"]'); + const info = modal.querySelector('[data-role="info"]'); + const confirmBtn = modal.querySelector('[data-action="confirm-delete-folder"]'); + + const holdsModels = this.nonEmptyFolders ? this.nonEmptyFolders.has(path) : false; + + const pathLine = `${escapeHtml(translate('sidebar.deleteFolderModal.folderLabel', {}, 'Folder'))}: ${escapeHtml(path)}`; + + if (holdsModels) { + this._pendingDeleteFolderPath = null; + title.textContent = translate( + 'sidebar.deleteFolderModal.notEmptyTitle', {}, 'Folder is not empty' + ); + message.textContent = translate( + 'sidebar.deleteFolderModal.notEmptyMessage', {}, + 'This folder still contains models. Delete or move them first.' + ); + info.innerHTML = pathLine; + confirmBtn.style.display = 'none'; + modal.dataset.state = 'blocked'; + } else { + this._pendingDeleteFolderPath = path; + title.textContent = translate( + 'sidebar.deleteFolderModal.title', {}, 'Delete folder?' + ); + message.textContent = translate( + 'sidebar.deleteFolderModal.message', {}, + 'The folder and everything inside it will be permanently removed from disk.' + ); + info.innerHTML = `${pathLine}
${escapeHtml(translate( + 'sidebar.deleteFolderModal.emptyNote', {}, 'This folder contains no models.' + ))}`; + confirmBtn.style.display = ''; + modal.dataset.state = 'confirm'; + } + + modalManager.showModal('deleteFolderModal'); + } + + hideDeleteFolderModal() { + this._pendingDeleteFolderPath = null; + modalManager.closeModal('deleteFolderModal'); + } + + async handleDeleteFolderConfirm() { + const path = this._pendingDeleteFolderPath; + this.hideDeleteFolderModal(); + + if (!path) return false; + + return this._deleteFolder(path); + } + + async _deleteFolder(relativePath) { + if (!this._supportsFolderManagement() || typeof this.apiClient.deleteFolder !== 'function') { + showToast('sidebar.deleteFolderResult.unsupported', {}, 'error'); + return false; + } + + try { + const rootsData = await this.apiClient.fetchModelRoots(); + const roots = rootsData?.roots || []; + const root = this._resolveDefaultRoot(roots); + if (!root) { + showToast('sidebar.deleteFolderResult.noRoot', {}, 'error'); + return false; + } + + const absolutePath = this.combineRootAndRelativePath(root, relativePath); + const result = await this.apiClient.deleteFolder(absolutePath); + + // Drop the node (and its subtree) from the persisted expand state + // before refreshing, otherwise stale keys accumulate forever. A + // selection inside the removed subtree is left to + // restoreSelectedFolder(), which falls back to the root and + // reloads the grid when the folder is gone from the fresh tree. + this._forgetRemovedFolder(relativePath); + + await this.refresh(); + + const name = result.folder || relativePath; + if (result.restorable) { + // A truly empty folder is reproducible one-for-one, so offer + // the same 20s undo affordance the model delete flow uses. + showActionToast('sidebar.deleteFolderResult.success', { name }, 'success', { + actionText: translate('toast.undo.action', {}, 'Undo'), + onAction: () => this._restoreDeletedFolder(absolutePath, relativePath), + }); + } else { + showToast( + 'sidebar.deleteFolderResult.successWithFiles', + { name, count: (result.file_count || 0) + (result.dir_count || 0) }, + 'success' + ); + } + + return true; + } catch (error) { + console.error('[SidebarManager] Error deleting folder:', error); + if (error?.code === 'not_empty') { + showToast('sidebar.deleteFolderResult.notEmpty', {}, 'warning'); + } else if (error?.code === 'busy') { + showToast('sidebar.deleteFolderResult.busy', {}, 'warning'); + } else { + showToast( + 'sidebar.deleteFolderResult.failed', + { message: error?.message || 'Unknown error' }, + 'error' + ); + } + return false; + } + } + + async _restoreDeletedFolder(absolutePath, relativePath) { + try { + await this.apiClient.createFolder(absolutePath); + this._forgetRemovedFolder(relativePath); + await this.refresh(); + showToast('sidebar.deleteFolderResult.restored', {}, 'success'); + return true; + } catch (error) { + console.error('[SidebarManager] Error restoring deleted folder:', error); + showToast('toast.undo.failed', { error: error?.message || '' }, 'error'); + return false; + } + } + + _forgetRemovedFolder(folderPath) { + if (!folderPath) return; + + const prefix = `${folderPath}/`; + let changed = false; + for (const node of Array.from(this.expandedNodes)) { + if (node === folderPath || node.startsWith(prefix)) { + this.expandedNodes.delete(node); + changed = true; + } + } + if (changed) { + this.saveExpandedState(); + } + } + + _wireDeleteFolderModal() { + if (this._deleteFolderModalWired) return; + + const modal = document.getElementById('deleteFolderModal'); + if (!modal) return; + + modal.addEventListener('click', (event) => { + const item = event.target.closest('[data-action]'); + if (!item) return; + const action = item.dataset.action; + if (action === 'cancel-delete-folder') { + this.hideDeleteFolderModal(); + } else if (action === 'confirm-delete-folder') { + this.handleDeleteFolderConfirm(); + } + }); + + this._deleteFolderModalWired = true; + } + saveSelectedFolder() { setStorageItem(`${this.pageType}_activeFolder`, this.selectedPath); } @@ -866,6 +1052,9 @@ export class SidebarManager { if (hideToggle) { hideToggle.addEventListener('click', this.handleHideToggle); } + + // Folder delete confirmation modal buttons + this._wireDeleteFolderModal(); } handleDocumentClick(event) { @@ -1240,6 +1429,12 @@ export class SidebarManager { createItem.style.display = this._supportsFolderManagement() ? '' : 'none'; } + // Deletion is gated the same way: recipes have virtual folders only. + const deleteItem = menu.querySelector('[data-action="delete-folder"]'); + if (deleteItem) { + deleteItem.style.display = this._supportsFolderManagement() ? '' : 'none'; + } + menu.style.left = `${x}px`; menu.style.top = `${y}px`; menu.style.display = 'block'; @@ -1288,6 +1483,9 @@ export class SidebarManager { case 'create-subfolder': this.showCreateFolderInput(path); break; + case 'delete-folder': + this.showDeleteFolderModal(path); + break; case 'check-folder-updates': try { await performFolderUpdateCheck(path); diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index 45ad2cc3..20d5eda3 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -243,6 +243,18 @@ export class ModalManager { }); } + // Add deleteFolderModal registration + const deleteFolderModal = document.getElementById('deleteFolderModal'); + if (deleteFolderModal) { + this.registerModal('deleteFolderModal', { + element: deleteFolderModal, + onClose: () => { + this.getModal('deleteFolderModal').element.classList.remove('show'); + document.body.classList.remove('modal-open'); + } + }); + } + // Add helpModal registration const helpModal = document.getElementById('helpModal'); if (helpModal) { @@ -441,6 +453,7 @@ export class ModalManager { id === "clearCacheModal" || id === "bulkDeleteModal" || id === "checkUpdatesConfirmModal" || + id === "deleteFolderModal" || id === "resolveFilenameConflictsModal" ) { modal.element.classList.add("show"); diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index 41f7e4ae..d2e1d7ae 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -212,6 +212,10 @@
{{ t('sidebar.folderUpdateCheck.label') }}
+
+
+ {{ t('sidebar.deleteFolder') }} +
diff --git a/templates/components/modals/confirm_modals.html b/templates/components/modals/confirm_modals.html index d613fffd..f49d3aa2 100644 --- a/templates/components/modals/confirm_modals.html +++ b/templates/components/modals/confirm_modals.html @@ -82,6 +82,21 @@ + + + +