mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
feat(sidebar): delete folders from the sidebar (#999)
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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user