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 @@