diff --git a/locales/de.json b/locales/de.json index c2a4a3bd..45109ad7 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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" diff --git a/locales/en.json b/locales/en.json index de13e421..fd430e1b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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" diff --git a/locales/es.json b/locales/es.json index b8a2a25b..5b5f0647 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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" diff --git a/locales/fr.json b/locales/fr.json index adabe9a7..b341428a 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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" diff --git a/locales/he.json b/locales/he.json index 203e0ca4..e838be52 100644 --- a/locales/he.json +++ b/locales/he.json @@ -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": "לא הוגדר שורש מודלים" diff --git a/locales/ja.json b/locales/ja.json index 8f99755c..277fbc5f 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -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": "モデルルートが設定されていません" diff --git a/locales/ko.json b/locales/ko.json index 35e558f9..7a9e0886 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -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": "모델 루트가 설정되지 않았습니다" diff --git a/locales/ru.json b/locales/ru.json index 2d8e8552..45ce745f 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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": "Корневая папка моделей не настроена" diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 84a730b7..4950a991 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -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": "未配置模型根目录" diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 3e536578..35a37191 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -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": "未設定模型根目錄" diff --git a/py/services/model_file_service.py b/py/services/model_file_service.py index 659805cd..c9412565 100644 --- a/py/services/model_file_service.py +++ b/py/services/model_file_service.py @@ -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: diff --git a/static/css/components/modal/delete-modal.css b/static/css/components/modal/delete-modal.css index 98486c25..08e6821d 100644 --- a/static/css/components/modal/delete-modal.css +++ b/static/css/components/modal/delete-modal.css @@ -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; diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index ae7c347e..4e31b97c 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -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 = `${escapeHtml(translate('sidebar.deleteFolderModal.folderLabel', {}, 'Folder'))}: ${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}
${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('
'); + 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 { diff --git a/tests/frontend/components/sidebarManager.folderManagement.test.js b/tests/frontend/components/sidebarManager.folderManagement.test.js index bef9e5a4..fc46f632 100644 --- a/tests/frontend/components/sidebarManager.folderManagement.test.js +++ b/tests/frontend/components/sidebarManager.folderManagement.test.js @@ -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({ diff --git a/tests/services/test_model_file_service.py b/tests/services/test_model_file_service.py index 02422575..3fbfb78c 100644 --- a/tests/services/test_model_file_service.py +++ b/tests/services/test_model_file_service.py @@ -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)