mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-26 13:34:08 -03:00
fix(sidebar): verify folder deletion against the backend
The sidebar derives "empty folder" from the models-only list, which omits models flagged `exclude: true`, while the delete guard walks the folder on disk and refuses on any weight file. A folder whose models are all excluded therefore looked empty, offered the confirmation, and then failed with "still contains models". The delete modal still opens on that prediction, but is now corrected by a dry run of the very delete the user is about to confirm, so the button state cannot contradict the backend. The confirm button stays disabled while the check runs, and a late answer is discarded once the modal is dismissed or retargeted. The dry run also covers weight files no scanner indexes (a lora folder holding only a `.gguf`, say) and files that appeared after the last scan. `_collect_folder_manifest()` now reports `excluded_model_count`, and the refusal names the excluded models, so the message explains the mismatch instead of reading like a bug. Locale files carry the sync placeholders in this commit; the translations follow.
This commit is contained in:
@@ -1350,6 +1350,10 @@
|
||||
"emptyNote": "Dieser Ordner enthält keine Modelle. Alle anderen darin enthaltenen Dateien werden ebenfalls gelöscht.",
|
||||
"notEmptyTitle": "Ordner ist nicht leer",
|
||||
"notEmptyMessage": "Dieser Ordner enthält noch Modelle. Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
|
||||
"notEmptyMessageCount": "[TODO: Translate] This folder still contains {count} model file(s). Delete or move them first — deleting a folder never cascades over model files.",
|
||||
"notEmptyMessageExcluded": "[TODO: Translate] This folder still contains {count} model file(s), {excluded} of them excluded from the library. Un-exclude them in Manage Excluded Models and delete them first — deleting a folder never cascades over model files.",
|
||||
"busyTitle": "[TODO: Translate] A deletion is still pending",
|
||||
"checking": "[TODO: Translate] Checking the folder contents...",
|
||||
"confirm": "Ordner löschen"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1362,7 @@
|
||||
"restored": "Ordner wiederhergestellt",
|
||||
"failed": "Ordner konnte nicht gelöscht werden: {message}",
|
||||
"notEmpty": "Dieser Ordner enthält noch Modelle. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
|
||||
"notEmptyWithCount": "[TODO: Translate] This folder still contains {count} model file(s). Refresh the sidebar and try again.",
|
||||
"busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.",
|
||||
"unsupported": "Das Löschen von Ordnern wird auf dieser Seite nicht unterstützt",
|
||||
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
|
||||
|
||||
@@ -1350,6 +1350,10 @@
|
||||
"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.",
|
||||
"notEmptyMessageCount": "This folder still contains {count} model file(s). Delete or move them first — deleting a folder never cascades over model files.",
|
||||
"notEmptyMessageExcluded": "This folder still contains {count} model file(s), {excluded} of them excluded from the library. Un-exclude them in Manage Excluded Models and delete them first — deleting a folder never cascades over model files.",
|
||||
"busyTitle": "A deletion is still pending",
|
||||
"checking": "Checking the folder contents...",
|
||||
"confirm": "Delete folder"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1362,7 @@
|
||||
"restored": "Folder restored",
|
||||
"failed": "Failed to delete folder: {message}",
|
||||
"notEmpty": "This folder still contains models. Refresh the sidebar and try again.",
|
||||
"notEmptyWithCount": "This folder still contains {count} model file(s). 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"
|
||||
|
||||
@@ -1350,6 +1350,10 @@
|
||||
"emptyNote": "Esta carpeta no contiene modelos. Los demás archivos que contenga también se eliminarán.",
|
||||
"notEmptyTitle": "La carpeta no está vacía",
|
||||
"notEmptyMessage": "Esta carpeta aún contiene modelos. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
|
||||
"notEmptyMessageCount": "[TODO: Translate] This folder still contains {count} model file(s). Delete or move them first — deleting a folder never cascades over model files.",
|
||||
"notEmptyMessageExcluded": "[TODO: Translate] This folder still contains {count} model file(s), {excluded} of them excluded from the library. Un-exclude them in Manage Excluded Models and delete them first — deleting a folder never cascades over model files.",
|
||||
"busyTitle": "[TODO: Translate] A deletion is still pending",
|
||||
"checking": "[TODO: Translate] Checking the folder contents...",
|
||||
"confirm": "Eliminar carpeta"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1362,7 @@
|
||||
"restored": "Carpeta restaurada",
|
||||
"failed": "Error al eliminar la carpeta: {message}",
|
||||
"notEmpty": "Esta carpeta aún contiene modelos. Actualiza la barra lateral e inténtalo de nuevo.",
|
||||
"notEmptyWithCount": "[TODO: Translate] This folder still contains {count} model file(s). Refresh the sidebar and try again.",
|
||||
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.",
|
||||
"unsupported": "La eliminación de carpetas no es compatible con esta página",
|
||||
"noRoot": "No hay ninguna raíz de modelo configurada"
|
||||
|
||||
@@ -1350,6 +1350,10 @@
|
||||
"emptyNote": "Ce dossier ne contient aucun modèle. Les autres fichiers qu’il contient seront également supprimés.",
|
||||
"notEmptyTitle": "Le dossier n’est pas vide",
|
||||
"notEmptyMessage": "Ce dossier contient encore des modèles. Supprimez-les ou déplacez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
|
||||
"notEmptyMessageCount": "[TODO: Translate] This folder still contains {count} model file(s). Delete or move them first — deleting a folder never cascades over model files.",
|
||||
"notEmptyMessageExcluded": "[TODO: Translate] This folder still contains {count} model file(s), {excluded} of them excluded from the library. Un-exclude them in Manage Excluded Models and delete them first — deleting a folder never cascades over model files.",
|
||||
"busyTitle": "[TODO: Translate] A deletion is still pending",
|
||||
"checking": "[TODO: Translate] Checking the folder contents...",
|
||||
"confirm": "Supprimer le dossier"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1362,7 @@
|
||||
"restored": "Dossier restauré",
|
||||
"failed": "Échec de la suppression du dossier : {message}",
|
||||
"notEmpty": "Ce dossier contient encore des modèles. Actualisez la barre latérale et réessayez.",
|
||||
"notEmptyWithCount": "[TODO: Translate] This folder still contains {count} model file(s). Refresh the sidebar and try again.",
|
||||
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre d’annulation.",
|
||||
"unsupported": "La suppression de dossiers n’est pas prise en charge sur cette page",
|
||||
"noRoot": "Aucune racine de modèle n’est configurée"
|
||||
|
||||
@@ -1350,6 +1350,10 @@
|
||||
"emptyNote": "אין מודלים בתיקייה זו. קבצים אחרים שבה יימחקו גם הם.",
|
||||
"notEmptyTitle": "התיקייה אינה ריקה",
|
||||
"notEmptyMessage": "בתיקייה זו עדיין יש מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
|
||||
"notEmptyMessageCount": "[TODO: Translate] This folder still contains {count} model file(s). Delete or move them first — deleting a folder never cascades over model files.",
|
||||
"notEmptyMessageExcluded": "[TODO: Translate] This folder still contains {count} model file(s), {excluded} of them excluded from the library. Un-exclude them in Manage Excluded Models and delete them first — deleting a folder never cascades over model files.",
|
||||
"busyTitle": "[TODO: Translate] A deletion is still pending",
|
||||
"checking": "[TODO: Translate] Checking the folder contents...",
|
||||
"confirm": "מחק תיקייה"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1362,7 @@
|
||||
"restored": "התיקייה שוחזרה",
|
||||
"failed": "מחיקת התיקייה נכשלה: {message}",
|
||||
"notEmpty": "בתיקייה זו עדיין יש מודלים. רענן את סרגל הצד ונסה שוב.",
|
||||
"notEmptyWithCount": "[TODO: Translate] This folder still contains {count} model file(s). Refresh the sidebar and try again.",
|
||||
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
|
||||
"unsupported": "מחיקת תיקיות אינה נתמכת בדף זה",
|
||||
"noRoot": "לא הוגדר שורש מודלים"
|
||||
|
||||
@@ -1350,6 +1350,10 @@
|
||||
"emptyNote": "このフォルダにはモデルがありません。他のファイルもすべて削除されます。",
|
||||
"notEmptyTitle": "フォルダが空ではありません",
|
||||
"notEmptyMessage": "このフォルダにはまだモデルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
|
||||
"notEmptyMessageCount": "[TODO: Translate] This folder still contains {count} model file(s). Delete or move them first — deleting a folder never cascades over model files.",
|
||||
"notEmptyMessageExcluded": "[TODO: Translate] This folder still contains {count} model file(s), {excluded} of them excluded from the library. Un-exclude them in Manage Excluded Models and delete them first — deleting a folder never cascades over model files.",
|
||||
"busyTitle": "[TODO: Translate] A deletion is still pending",
|
||||
"checking": "[TODO: Translate] Checking the folder contents...",
|
||||
"confirm": "フォルダを削除"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1362,7 @@
|
||||
"restored": "フォルダを復元しました",
|
||||
"failed": "フォルダの削除に失敗しました: {message}",
|
||||
"notEmpty": "このフォルダにはまだモデルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
|
||||
"notEmptyWithCount": "[TODO: Translate] This folder still contains {count} model file(s). Refresh the sidebar and try again.",
|
||||
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
|
||||
"unsupported": "このページではフォルダを削除できません",
|
||||
"noRoot": "モデルルートが設定されていません"
|
||||
|
||||
@@ -1350,6 +1350,10 @@
|
||||
"emptyNote": "이 폴더에는 모델이 없습니다. 폴더 안의 다른 파일도 함께 삭제됩니다.",
|
||||
"notEmptyTitle": "폴더가 비어 있지 않습니다",
|
||||
"notEmptyMessage": "이 폴더에는 아직 모델이 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
|
||||
"notEmptyMessageCount": "[TODO: Translate] This folder still contains {count} model file(s). Delete or move them first — deleting a folder never cascades over model files.",
|
||||
"notEmptyMessageExcluded": "[TODO: Translate] This folder still contains {count} model file(s), {excluded} of them excluded from the library. Un-exclude them in Manage Excluded Models and delete them first — deleting a folder never cascades over model files.",
|
||||
"busyTitle": "[TODO: Translate] A deletion is still pending",
|
||||
"checking": "[TODO: Translate] Checking the folder contents...",
|
||||
"confirm": "폴더 삭제"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1362,7 @@
|
||||
"restored": "폴더를 복원했습니다",
|
||||
"failed": "폴더 삭제 실패: {message}",
|
||||
"notEmpty": "이 폴더에는 아직 모델이 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
|
||||
"notEmptyWithCount": "[TODO: Translate] This folder still contains {count} model file(s). Refresh the sidebar and try again.",
|
||||
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
|
||||
"unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다",
|
||||
"noRoot": "모델 루트가 설정되지 않았습니다"
|
||||
|
||||
@@ -1350,6 +1350,10 @@
|
||||
"emptyNote": "В этой папке нет моделей. Остальные файлы в ней тоже будут удалены.",
|
||||
"notEmptyTitle": "Папка не пуста",
|
||||
"notEmptyMessage": "В этой папке ещё есть модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
|
||||
"notEmptyMessageCount": "[TODO: Translate] This folder still contains {count} model file(s). Delete or move them first — deleting a folder never cascades over model files.",
|
||||
"notEmptyMessageExcluded": "[TODO: Translate] This folder still contains {count} model file(s), {excluded} of them excluded from the library. Un-exclude them in Manage Excluded Models and delete them first — deleting a folder never cascades over model files.",
|
||||
"busyTitle": "[TODO: Translate] A deletion is still pending",
|
||||
"checking": "[TODO: Translate] Checking the folder contents...",
|
||||
"confirm": "Удалить папку"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1362,7 @@
|
||||
"restored": "Папка восстановлена",
|
||||
"failed": "Не удалось удалить папку: {message}",
|
||||
"notEmpty": "В этой папке ещё есть модели. Обновите боковую панель и повторите попытку.",
|
||||
"notEmptyWithCount": "[TODO: Translate] This folder still contains {count} model file(s). Refresh the sidebar and try again.",
|
||||
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
|
||||
"unsupported": "Удаление папок не поддерживается на этой странице",
|
||||
"noRoot": "Корневая папка моделей не настроена"
|
||||
|
||||
@@ -1350,6 +1350,10 @@
|
||||
"emptyNote": "该文件夹中没有模型,其中的其他文件也会一并删除。",
|
||||
"notEmptyTitle": "文件夹不为空",
|
||||
"notEmptyMessage": "该文件夹中仍有模型,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
|
||||
"notEmptyMessageCount": "[TODO: Translate] This folder still contains {count} model file(s). Delete or move them first — deleting a folder never cascades over model files.",
|
||||
"notEmptyMessageExcluded": "[TODO: Translate] This folder still contains {count} model file(s), {excluded} of them excluded from the library. Un-exclude them in Manage Excluded Models and delete them first — deleting a folder never cascades over model files.",
|
||||
"busyTitle": "[TODO: Translate] A deletion is still pending",
|
||||
"checking": "[TODO: Translate] Checking the folder contents...",
|
||||
"confirm": "删除文件夹"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1362,7 @@
|
||||
"restored": "文件夹已恢复",
|
||||
"failed": "删除文件夹失败: {message}",
|
||||
"notEmpty": "该文件夹中仍有模型。请刷新侧边栏后重试。",
|
||||
"notEmptyWithCount": "[TODO: Translate] This folder still contains {count} model file(s). Refresh the sidebar and try again.",
|
||||
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
|
||||
"unsupported": "此页面不支持删除文件夹",
|
||||
"noRoot": "未配置模型根目录"
|
||||
|
||||
@@ -1350,6 +1350,10 @@
|
||||
"emptyNote": "該資料夾中沒有模型,其中的其他檔案也會一併刪除。",
|
||||
"notEmptyTitle": "資料夾不是空的",
|
||||
"notEmptyMessage": "該資料夾中仍有模型,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
|
||||
"notEmptyMessageCount": "[TODO: Translate] This folder still contains {count} model file(s). Delete or move them first — deleting a folder never cascades over model files.",
|
||||
"notEmptyMessageExcluded": "[TODO: Translate] This folder still contains {count} model file(s), {excluded} of them excluded from the library. Un-exclude them in Manage Excluded Models and delete them first — deleting a folder never cascades over model files.",
|
||||
"busyTitle": "[TODO: Translate] A deletion is still pending",
|
||||
"checking": "[TODO: Translate] Checking the folder contents...",
|
||||
"confirm": "刪除資料夾"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1362,7 @@
|
||||
"restored": "資料夾已還原",
|
||||
"failed": "刪除資料夾失敗: {message}",
|
||||
"notEmpty": "該資料夾中仍有模型。請重新整理側邊欄後再試。",
|
||||
"notEmptyWithCount": "[TODO: Translate] This folder still contains {count} model file(s). Refresh the sidebar and try again.",
|
||||
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
|
||||
"unsupported": "此頁面不支援刪除資料夾",
|
||||
"noRoot": "未設定模型根目錄"
|
||||
|
||||
@@ -15,6 +15,16 @@ from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_match_path(path: Any) -> str:
|
||||
"""Normalize a path for set membership tests.
|
||||
|
||||
Business paths only — symlinks are never resolved here, matching how the
|
||||
scanner stores ``excluded_models``. The forward-slash form keeps Windows
|
||||
comparisons working with the scanner's normalized entries.
|
||||
"""
|
||||
return os.path.normpath(os.path.abspath(str(path))).replace(os.sep, "/")
|
||||
|
||||
|
||||
class ProgressCallback(ABC):
|
||||
"""Abstract callback interface for progress reporting"""
|
||||
|
||||
@@ -567,8 +577,9 @@ class ModelMoveService:
|
||||
|
||||
Returns:
|
||||
Dictionary with the success flag plus a removal manifest
|
||||
(``model_count``/``file_count``/``dir_count``/``symlink_count``/
|
||||
``total_bytes``/``restorable``) on success.
|
||||
(``model_count``/``excluded_model_count``/``file_count``/
|
||||
``dir_count``/``symlink_count``/``total_bytes``/``restorable``)
|
||||
on success.
|
||||
"""
|
||||
try:
|
||||
if not folder_path or not str(folder_path).strip():
|
||||
@@ -608,13 +619,34 @@ class ModelMoveService:
|
||||
}
|
||||
|
||||
if manifest["model_count"] > 0:
|
||||
model_count = manifest["model_count"]
|
||||
excluded_count = manifest["excluded_model_count"]
|
||||
# Excluded models are hidden from the library lists but are
|
||||
# still real weight files, so they block the cascade just like
|
||||
# any other model. Naming them is what makes the refusal
|
||||
# actionable: the folder looks empty in the sidebar precisely
|
||||
# because everything in it is excluded.
|
||||
if excluded_count == model_count:
|
||||
error = (
|
||||
f"Folder still contains {model_count} model file(s), "
|
||||
"all excluded from the library; un-exclude or delete "
|
||||
"them first"
|
||||
)
|
||||
elif excluded_count:
|
||||
error = (
|
||||
f"Folder still contains {model_count} model file(s), "
|
||||
f"{excluded_count} of them excluded from the library; "
|
||||
"delete or move them first"
|
||||
)
|
||||
else:
|
||||
error = (
|
||||
f"Folder still contains {model_count} model "
|
||||
"file(s); delete or move them first"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"code": "not_empty",
|
||||
"error": (
|
||||
f"Folder still contains {manifest['model_count']} model "
|
||||
"file(s); delete or move them first"
|
||||
),
|
||||
"error": error,
|
||||
"manifest": manifest,
|
||||
}
|
||||
|
||||
@@ -667,13 +699,20 @@ class ModelMoveService:
|
||||
Symbolic links are never followed (``os.walk`` default) and are counted
|
||||
separately — ``shutil.rmtree`` unlinks them without touching their
|
||||
targets.
|
||||
|
||||
``excluded_model_count`` splits the subset of ``model_count`` that the
|
||||
library hides behind the ``exclude`` flag: those files still block the
|
||||
delete, yet they are invisible to the model lists (and therefore to the
|
||||
folder tree, which derives "empty" from them).
|
||||
"""
|
||||
model_count = 0
|
||||
excluded_model_count = 0
|
||||
file_count = 0
|
||||
dir_count = 0
|
||||
symlink_count = 0
|
||||
total_bytes = 0
|
||||
pending_delete_job = False
|
||||
excluded_paths = self._excluded_model_paths()
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(absolute_path):
|
||||
if PENDING_DELETE_DIR_NAME in dirnames:
|
||||
@@ -692,6 +731,8 @@ class ModelMoveService:
|
||||
continue
|
||||
if self._is_model_file(name):
|
||||
model_count += 1
|
||||
if _normalize_match_path(full_path) in excluded_paths:
|
||||
excluded_model_count += 1
|
||||
else:
|
||||
file_count += 1
|
||||
try:
|
||||
@@ -701,6 +742,7 @@ class ModelMoveService:
|
||||
|
||||
return {
|
||||
"model_count": model_count,
|
||||
"excluded_model_count": excluded_model_count,
|
||||
"file_count": file_count,
|
||||
"dir_count": dir_count,
|
||||
"symlink_count": symlink_count,
|
||||
@@ -716,6 +758,21 @@ class ModelMoveService:
|
||||
),
|
||||
}
|
||||
|
||||
def _excluded_model_paths(self) -> Set[str]:
|
||||
"""Absolute paths of the models the library hides behind ``exclude``.
|
||||
|
||||
Best-effort: scanner stand-ins that do not expose the accessor simply
|
||||
report no excluded models.
|
||||
"""
|
||||
get_excluded = getattr(self.scanner, "get_excluded_models", None)
|
||||
if not callable(get_excluded):
|
||||
return set()
|
||||
try:
|
||||
paths = get_excluded() or []
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return set()
|
||||
return {_normalize_match_path(path) for path in paths if path}
|
||||
|
||||
async def _forget_folder(self, relative_folder: str) -> None:
|
||||
"""Drop a removed directory from the scanner's folder/cache records."""
|
||||
if not relative_folder:
|
||||
|
||||
@@ -42,6 +42,18 @@
|
||||
animation: modalFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* The folder delete confirm button is held disabled while the folder contents
|
||||
are checked against the backend, so it must not look clickable. */
|
||||
#deleteFolderModal .delete-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
#deleteFolderModal .delete-btn:disabled:hover {
|
||||
background: var(--lora-error);
|
||||
}
|
||||
|
||||
#resolveFilenameConflictsModal .confirmation-message {
|
||||
color: var(--text-color);
|
||||
margin: var(--space-2) 0;
|
||||
|
||||
@@ -52,6 +52,9 @@ export class SidebarManager {
|
||||
this._renameFolderNode = null;
|
||||
this._pendingDeleteFolderPath = null;
|
||||
this._deleteFolderModalWired = false;
|
||||
// Bumped on every modal open/close so a late dry-run answer can never
|
||||
// repaint a modal the user has already dismissed or retargeted.
|
||||
this._deleteFolderProbeToken = 0;
|
||||
|
||||
// Bind methods
|
||||
this.handleTreeClick = this.handleTreeClick.bind(this);
|
||||
@@ -982,42 +985,71 @@ export class SidebarManager {
|
||||
/**
|
||||
* 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.
|
||||
* The models-only set that dims empty nodes is only a prediction: it is
|
||||
* built from the scanned, non-excluded models, while the delete guard walks
|
||||
* the folder on disk and refuses on any weight file — excluded ones
|
||||
* included. So the modal opens on the prediction for an instant answer and
|
||||
* is then corrected by a dry run of the very delete the user is about to
|
||||
* confirm, which is the only way the button can never contradict the
|
||||
* backend (see `_verifyFolderContents`).
|
||||
*/
|
||||
showDeleteFolderModal(path) {
|
||||
async showDeleteFolderModal(path) {
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
if (!modal) return;
|
||||
|
||||
// Defensive: the modal may have been absent when listeners were wired.
|
||||
this._wireDeleteFolderModal();
|
||||
|
||||
// Opening the modal — or targeting another folder — retires any
|
||||
// in-flight check from a previous open.
|
||||
const token = ++this._deleteFolderProbeToken;
|
||||
|
||||
const holdsModels = this.nonEmptyFolders ? this.nonEmptyFolders.has(path) : false;
|
||||
const prediction = holdsModels
|
||||
? {
|
||||
state: 'blocked',
|
||||
messageKey: 'sidebar.deleteFolderModal.notEmptyMessage',
|
||||
messageFallback: 'This folder still contains models. Delete or move them first.',
|
||||
}
|
||||
: { state: 'confirm' };
|
||||
|
||||
const probePending = this._supportsFolderManagement()
|
||||
&& typeof this.apiClient.deleteFolder === 'function';
|
||||
|
||||
this._renderDeleteFolderModal(path, prediction.state, {
|
||||
...prediction,
|
||||
checking: probePending,
|
||||
});
|
||||
|
||||
modalManager.showModal('deleteFolderModal');
|
||||
|
||||
if (!probePending) return;
|
||||
|
||||
await this._verifyFolderContents(path, token, prediction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint one state of the folder delete modal.
|
||||
*
|
||||
* `state` is 'confirm' (deletion may proceed), 'blocked' (models would be
|
||||
* cascaded over, which the backend refuses) or 'busy' (a staged delete is
|
||||
* still pending inside the folder). `checking` keeps the confirm button
|
||||
* disabled while the authoritative server-side check runs.
|
||||
*/
|
||||
_renderDeleteFolderModal(path, state, options = {}) {
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
if (!modal) return;
|
||||
|
||||
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 checking = Boolean(options.checking);
|
||||
|
||||
const pathLine = `<strong>${escapeHtml(translate('sidebar.deleteFolderModal.folderLabel', {}, 'Folder'))}:</strong> ${escapeHtml(path)}`;
|
||||
const extraLines = [];
|
||||
|
||||
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 {
|
||||
if (state === 'confirm') {
|
||||
this._pendingDeleteFolderPath = path;
|
||||
title.textContent = translate(
|
||||
'sidebar.deleteFolderModal.title', {}, 'Delete folder?'
|
||||
@@ -1026,18 +1058,146 @@ export class SidebarManager {
|
||||
'sidebar.deleteFolderModal.message', {},
|
||||
'The folder and everything inside it will be permanently removed from disk.'
|
||||
);
|
||||
info.innerHTML = `${pathLine}<br>${escapeHtml(translate(
|
||||
'sidebar.deleteFolderModal.emptyNote', {}, 'This folder contains no models.'
|
||||
))}`;
|
||||
if (!checking) {
|
||||
// While the check runs the "no models" claim is still only the
|
||||
// sidebar's prediction, so it is not repeated as a fact.
|
||||
extraLines.push(escapeHtml(translate(
|
||||
'sidebar.deleteFolderModal.emptyNote', {}, 'This folder contains no models.'
|
||||
)));
|
||||
}
|
||||
confirmBtn.style.display = '';
|
||||
modal.dataset.state = 'confirm';
|
||||
confirmBtn.disabled = checking;
|
||||
} else {
|
||||
this._pendingDeleteFolderPath = null;
|
||||
if (state === 'busy') {
|
||||
title.textContent = translate(
|
||||
'sidebar.deleteFolderModal.busyTitle', {}, 'A deletion is still pending'
|
||||
);
|
||||
message.textContent = translate(
|
||||
'sidebar.deleteFolderResult.busy', {},
|
||||
'A deletion is still pending inside this folder. Wait for the undo window to expire.'
|
||||
);
|
||||
} else {
|
||||
title.textContent = translate(
|
||||
'sidebar.deleteFolderModal.notEmptyTitle', {}, 'Folder is not empty'
|
||||
);
|
||||
message.textContent = translate(
|
||||
options.messageKey || 'sidebar.deleteFolderModal.notEmptyMessage',
|
||||
options.messageParams || {},
|
||||
options.messageFallback
|
||||
|| 'This folder still contains models. Delete or move them first.'
|
||||
);
|
||||
}
|
||||
confirmBtn.style.display = 'none';
|
||||
confirmBtn.disabled = true;
|
||||
}
|
||||
|
||||
modalManager.showModal('deleteFolderModal');
|
||||
if (checking) {
|
||||
extraLines.push(escapeHtml(translate(
|
||||
'sidebar.deleteFolderModal.checking', {}, 'Checking the folder contents...'
|
||||
)));
|
||||
}
|
||||
|
||||
info.innerHTML = [pathLine, ...extraLines].join('<br>');
|
||||
modal.dataset.state = state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the backend what deleting *relativePath* would actually remove.
|
||||
*
|
||||
* The dry run is authoritative: it walks the folder on disk and applies the
|
||||
* same guard the real delete uses, so it catches everything the sidebar
|
||||
* prediction cannot know — excluded models, weight files no scanner indexes
|
||||
* (a lora folder holding only a `.gguf`, say) and files added after the
|
||||
* last scan. A check that fails for any other reason falls back to the
|
||||
* prediction, leaving the real delete to report its own error.
|
||||
*/
|
||||
async _verifyFolderContents(relativePath, token, prediction) {
|
||||
let resolved = null;
|
||||
try {
|
||||
resolved = await this._resolveFolderAbsolutePath(relativePath);
|
||||
} catch (error) {
|
||||
console.error('[SidebarManager] Failed to resolve the folder path:', error);
|
||||
}
|
||||
|
||||
if (token !== this._deleteFolderProbeToken) return;
|
||||
|
||||
if (!resolved) {
|
||||
this._renderDeleteFolderModal(relativePath, prediction.state, prediction);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.apiClient.deleteFolder(resolved.absolutePath, { dryRun: true });
|
||||
if (token !== this._deleteFolderProbeToken) return;
|
||||
this._renderDeleteFolderModal(relativePath, 'confirm');
|
||||
} catch (error) {
|
||||
if (token !== this._deleteFolderProbeToken) return;
|
||||
if (error?.code === 'not_empty') {
|
||||
this._renderDeleteFolderModal(
|
||||
relativePath, 'blocked', this._notEmptyBlocker(error?.manifest)
|
||||
);
|
||||
} else if (error?.code === 'busy') {
|
||||
this._renderDeleteFolderModal(relativePath, 'busy');
|
||||
} else {
|
||||
this._renderDeleteFolderModal(relativePath, prediction.state, prediction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Message for a refused delete, split by whether the blocking models are
|
||||
* excluded from the library — the case where the sidebar legitimately shows
|
||||
* the folder as empty, which is exactly what used to be unexplained.
|
||||
*/
|
||||
_notEmptyBlocker(manifest) {
|
||||
const modelCount = Number(manifest?.model_count) || 0;
|
||||
const excludedCount = Number(manifest?.excluded_model_count) || 0;
|
||||
|
||||
if (modelCount > 0 && excludedCount > 0) {
|
||||
return {
|
||||
messageKey: 'sidebar.deleteFolderModal.notEmptyMessageExcluded',
|
||||
messageParams: { count: modelCount, excluded: excludedCount },
|
||||
messageFallback: `This folder still contains ${modelCount} model file(s), `
|
||||
+ `${excludedCount} of them excluded from the library. Un-exclude and `
|
||||
+ 'delete them first — deleting a folder never cascades over model files.',
|
||||
};
|
||||
}
|
||||
if (modelCount > 0) {
|
||||
return {
|
||||
messageKey: 'sidebar.deleteFolderModal.notEmptyMessageCount',
|
||||
messageParams: { count: modelCount },
|
||||
messageFallback: `This folder still contains ${modelCount} model file(s). `
|
||||
+ 'Delete or move them first — deleting a folder never cascades over model files.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
messageKey: 'sidebar.deleteFolderModal.notEmptyMessage',
|
||||
messageFallback: 'This folder still contains models. Delete or move them first.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tree-relative folder path to the absolute business path the
|
||||
* folder APIs expect, or null when no model root is configured.
|
||||
*/
|
||||
async _resolveFolderAbsolutePath(relativePath) {
|
||||
const rootsData = await this.apiClient.fetchModelRoots();
|
||||
const roots = rootsData?.roots || [];
|
||||
const root = this._resolveDefaultRoot(roots);
|
||||
if (!root) return null;
|
||||
|
||||
return {
|
||||
root,
|
||||
absolutePath: this.combineRootAndRelativePath(root, relativePath),
|
||||
};
|
||||
}
|
||||
|
||||
hideDeleteFolderModal() {
|
||||
this._pendingDeleteFolderPath = null;
|
||||
// Retire any in-flight check so a late answer cannot repaint a modal
|
||||
// the user already dismissed.
|
||||
this._deleteFolderProbeToken += 1;
|
||||
modalManager.closeModal('deleteFolderModal');
|
||||
}
|
||||
|
||||
@@ -1057,16 +1217,13 @@ export class SidebarManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const rootsData = await this.apiClient.fetchModelRoots();
|
||||
const roots = rootsData?.roots || [];
|
||||
const root = this._resolveDefaultRoot(roots);
|
||||
if (!root) {
|
||||
const resolved = await this._resolveFolderAbsolutePath(relativePath);
|
||||
if (!resolved) {
|
||||
showToast('sidebar.deleteFolderResult.noRoot', {}, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
const absolutePath = this.combineRootAndRelativePath(root, relativePath);
|
||||
const result = await this.apiClient.deleteFolder(absolutePath);
|
||||
const result = await this.apiClient.deleteFolder(resolved.absolutePath);
|
||||
|
||||
// Drop the node (and its subtree) from the persisted expand state
|
||||
// before refreshing, otherwise stale keys accumulate forever. A
|
||||
@@ -1083,7 +1240,7 @@ export class SidebarManager {
|
||||
// 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),
|
||||
onAction: () => this._restoreDeletedFolder(resolved.absolutePath, relativePath),
|
||||
});
|
||||
} else {
|
||||
showToast(
|
||||
@@ -1097,7 +1254,18 @@ export class SidebarManager {
|
||||
} catch (error) {
|
||||
console.error('[SidebarManager] Error deleting folder:', error);
|
||||
if (error?.code === 'not_empty') {
|
||||
showToast('sidebar.deleteFolderResult.notEmpty', {}, 'warning');
|
||||
// The dry run normally catches this before the user can
|
||||
// confirm; reaching here means the folder changed in between.
|
||||
const modelCount = Number(error?.manifest?.model_count) || 0;
|
||||
if (modelCount > 0) {
|
||||
showToast(
|
||||
'sidebar.deleteFolderResult.notEmptyWithCount',
|
||||
{ count: modelCount },
|
||||
'warning'
|
||||
);
|
||||
} else {
|
||||
showToast('sidebar.deleteFolderResult.notEmpty', {}, 'warning');
|
||||
}
|
||||
} else if (error?.code === 'busy') {
|
||||
showToast('sidebar.deleteFolderResult.busy', {}, 'warning');
|
||||
} else {
|
||||
|
||||
@@ -631,24 +631,38 @@ describe('SidebarManager folder deletion', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens the confirm state for a folder whose subtree holds no models', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
it('opens the confirm state for a folder whose subtree holds no models', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
manager.showDeleteFolderModal('empty');
|
||||
await manager.showDeleteFolderModal('empty');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('confirm');
|
||||
expect(confirmBtn().style.display).toBe('');
|
||||
expect(confirmBtn().disabled).toBe(false);
|
||||
expect(manager._pendingDeleteFolderPath).toBe('empty');
|
||||
expect(modalManager.showModal).toHaveBeenCalledWith('deleteFolderModal');
|
||||
// The prediction is confirmed against the real guard before the user can
|
||||
// act on it.
|
||||
expect(apiClient.deleteFolder).toHaveBeenCalledWith(
|
||||
'/models/loras/empty', { dryRun: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('explains the refusal when the subtree still holds models', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
it('explains the refusal when the subtree still holds models', async () => {
|
||||
const conflict = Object.assign(new Error('still contains models'), {
|
||||
code: 'not_empty',
|
||||
manifest: { model_count: 2, excluded_model_count: 0 },
|
||||
});
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(conflict),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
manager.showDeleteFolderModal('full');
|
||||
await manager.showDeleteFolderModal('full');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('blocked');
|
||||
@@ -656,17 +670,134 @@ describe('SidebarManager folder deletion', () => {
|
||||
expect(manager._pendingDeleteFolderPath).toBeNull();
|
||||
});
|
||||
|
||||
it('treats an unknown folder as model-free when the models-only set is missing', () => {
|
||||
// nonEmptyFolders is null outside the include-empty tree; the server still
|
||||
// refuses a non-empty folder, so the client falls back to the confirm state.
|
||||
it('treats an unknown folder as model-free when the models-only set is missing', async () => {
|
||||
// nonEmptyFolders is null outside the include-empty tree; the dry run is
|
||||
// what actually decides, so the prediction is only a starting point.
|
||||
const manager = createManager(createApiClient());
|
||||
manager.nonEmptyFolders = null;
|
||||
|
||||
manager.showDeleteFolderModal('empty');
|
||||
await manager.showDeleteFolderModal('empty');
|
||||
|
||||
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('confirm');
|
||||
});
|
||||
|
||||
it('blocks a folder the tree shows as empty when only excluded models live there', async () => {
|
||||
// The reported mismatch: excluded models are absent from the models-only
|
||||
// set (so the node dims as empty), yet they are real weight files on disk
|
||||
// and the delete guard refuses to cascade over them.
|
||||
const conflict = Object.assign(
|
||||
new Error('Folder still contains 3 model file(s), all excluded from the library'),
|
||||
{ code: 'not_empty', manifest: { model_count: 3, excluded_model_count: 3 } }
|
||||
);
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(conflict),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
await manager.showDeleteFolderModal('Flux.1 D/test');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('blocked');
|
||||
expect(confirmBtn().style.display).toBe('none');
|
||||
expect(manager._pendingDeleteFolderPath).toBeNull();
|
||||
// The message names the excluded models instead of contradicting the tree.
|
||||
expect(modal.querySelector('[data-role="message"]').textContent)
|
||||
.toContain('excluded from the library');
|
||||
expect(apiClient.deleteFolder).toHaveBeenCalledWith(
|
||||
'/models/loras/Flux.1 D/test', { dryRun: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('reports how many model files block the delete when some are excluded', async () => {
|
||||
const conflict = Object.assign(new Error('still contains models'), {
|
||||
code: 'not_empty',
|
||||
manifest: { model_count: 4, excluded_model_count: 1 },
|
||||
});
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(conflict),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
await manager.showDeleteFolderModal('mixed');
|
||||
|
||||
expect(
|
||||
document.getElementById('deleteFolderModal')
|
||||
.querySelector('[data-role="message"]').textContent
|
||||
).toContain('4 model file(s)');
|
||||
});
|
||||
|
||||
it('blocks the delete while a staged delete is still pending', async () => {
|
||||
const busy = Object.assign(new Error('staged delete pending'), { code: 'busy' });
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(busy),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
await manager.showDeleteFolderModal('empty');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('busy');
|
||||
expect(confirmBtn().style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('keeps the confirm button disabled until the check settles', async () => {
|
||||
let release;
|
||||
const apiClient = createApiClient({
|
||||
fetchModelRoots: vi.fn(() => new Promise((resolve) => { release = resolve; })),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
const pending = manager.showDeleteFolderModal('empty');
|
||||
expect(confirmBtn().disabled).toBe(true);
|
||||
|
||||
release({ roots: ['/models/loras'] });
|
||||
await pending;
|
||||
|
||||
expect(confirmBtn().disabled).toBe(false);
|
||||
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('confirm');
|
||||
});
|
||||
|
||||
it('ignores a dry-run answer that lands after the modal was dismissed', async () => {
|
||||
let rejectProbe;
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn(() => new Promise((_resolve, reject) => { rejectProbe = reject; })),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
const pending = manager.showDeleteFolderModal('empty');
|
||||
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('confirm');
|
||||
|
||||
await vi.waitFor(() => expect(rejectProbe).toBeTypeOf('function'));
|
||||
|
||||
manager.hideDeleteFolderModal();
|
||||
rejectProbe(Object.assign(new Error('still contains models'), {
|
||||
code: 'not_empty',
|
||||
manifest: { model_count: 1, excluded_model_count: 0 },
|
||||
}));
|
||||
await pending;
|
||||
|
||||
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('confirm');
|
||||
});
|
||||
|
||||
it('falls back to the tree prediction when the check fails for another reason', async () => {
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(new Error('network down')),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
await manager.showDeleteFolderModal('empty');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('confirm');
|
||||
expect(confirmBtn().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('deletes the folder and offers the undo affordance for an empty one', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
@@ -732,6 +863,25 @@ describe('SidebarManager folder deletion', () => {
|
||||
expect(manager.refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes the model count in the stale-tree toast when the manifest has one', async () => {
|
||||
const conflict = Object.assign(new Error('still contains models'), {
|
||||
code: 'not_empty',
|
||||
manifest: { model_count: 3, excluded_model_count: 3 },
|
||||
});
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(conflict),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const success = await manager._deleteFolder('full');
|
||||
|
||||
expect(success).toBe(false);
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'sidebar.deleteFolderResult.notEmptyWithCount', { count: 3 }, 'warning'
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces a busy folder with a staged delete', async () => {
|
||||
const busy = Object.assign(new Error('staged delete pending'), { code: 'busy' });
|
||||
const apiClient = createApiClient({
|
||||
|
||||
@@ -11,15 +11,19 @@ from py.services.model_file_service import ModelMoveService
|
||||
|
||||
|
||||
class FakeScanner:
|
||||
def __init__(self, roots: List[Path]) -> None:
|
||||
def __init__(self, roots: List[Path], excluded: List[str] | None = None) -> None:
|
||||
self._roots = [str(root) for root in roots]
|
||||
self.known_folders: List[str] = []
|
||||
self.removed_folders: List[str] = []
|
||||
self.renamed_folders: List[tuple] = []
|
||||
self._excluded = list(excluded or [])
|
||||
|
||||
def get_model_roots(self) -> List[str]:
|
||||
return list(self._roots)
|
||||
|
||||
def get_excluded_models(self) -> List[str]:
|
||||
return list(self._excluded)
|
||||
|
||||
async def add_known_folder(self, folder: str) -> None:
|
||||
self.known_folders.append(folder)
|
||||
|
||||
@@ -30,6 +34,12 @@ class FakeScanner:
|
||||
self.renamed_folders.append((previous, current, kwargs))
|
||||
|
||||
|
||||
class ScannerWithoutExcludedAccessor(FakeScanner):
|
||||
"""Scanner stand-in predating ``get_excluded_models()``."""
|
||||
|
||||
get_excluded_models = None # type: ignore[assignment]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_folder_creates_directory_and_registers_it(tmp_path: Path):
|
||||
scanner = FakeScanner([tmp_path])
|
||||
@@ -149,11 +159,90 @@ async def test_delete_folder_refuses_when_models_live_below(tmp_path: Path):
|
||||
assert result["success"] is False
|
||||
assert result["code"] == "not_empty"
|
||||
assert result["manifest"]["model_count"] == 1
|
||||
assert result["manifest"]["excluded_model_count"] == 0
|
||||
assert target.exists()
|
||||
assert model_file.exists()
|
||||
assert scanner.removed_folders == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_manifest_marks_models_excluded_from_the_library(tmp_path: Path):
|
||||
"""Excluded models are invisible to the model lists but still block the
|
||||
cascade, so the manifest has to say so — the folder sidebar otherwise shows
|
||||
the folder as empty and the refusal reads as a bug."""
|
||||
target = _make_nested(tmp_path)
|
||||
model_file = target / "hidden.safetensors"
|
||||
model_file.write_text("weights", encoding="utf-8")
|
||||
scanner = FakeScanner([tmp_path], excluded=[str(model_file)])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.delete_folder(str(target))
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["code"] == "not_empty"
|
||||
assert result["manifest"]["model_count"] == 1
|
||||
assert result["manifest"]["excluded_model_count"] == 1
|
||||
assert "excluded" in result["error"]
|
||||
assert model_file.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_error_splits_excluded_from_visible_models(tmp_path: Path):
|
||||
target = _make_nested(tmp_path)
|
||||
visible = target / "visible.safetensors"
|
||||
hidden = target / "hidden.safetensors"
|
||||
visible.write_text("weights", encoding="utf-8")
|
||||
hidden.write_text("weights", encoding="utf-8")
|
||||
scanner = FakeScanner([tmp_path], excluded=[str(hidden)])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.delete_folder(str(target))
|
||||
|
||||
assert result["manifest"]["model_count"] == 2
|
||||
assert result["manifest"]["excluded_model_count"] == 1
|
||||
assert "1 of them excluded" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_does_not_claim_excluded_for_visible_models(tmp_path: Path):
|
||||
target = _make_nested(tmp_path)
|
||||
(target / "model.safetensors").write_text("weights", encoding="utf-8")
|
||||
# An excluded model elsewhere in the library must not be attributed here.
|
||||
scanner = FakeScanner([tmp_path], excluded=[str(tmp_path / "other" / "other.safetensors")])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.delete_folder(str(target))
|
||||
|
||||
assert result["manifest"]["excluded_model_count"] == 0
|
||||
assert "excluded" not in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_works_without_the_excluded_accessor(tmp_path: Path):
|
||||
target = _make_nested(tmp_path)
|
||||
(target / "model.safetensors").write_text("weights", encoding="utf-8")
|
||||
scanner = ScannerWithoutExcludedAccessor([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.delete_folder(str(target))
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["manifest"]["excluded_model_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_success_manifest_carries_excluded_model_count(tmp_path: Path):
|
||||
target = _make_nested(tmp_path)
|
||||
(target / "leftover.webp").write_text("preview", encoding="utf-8")
|
||||
scanner = FakeScanner([tmp_path], excluded=[str(tmp_path / "elsewhere.safetensors")])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.delete_folder(str(target), dry_run=True)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["excluded_model_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
|
||||
target = _make_nested(tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user