Compare commits

...
3 Commits
Author SHA1 Message Date
Will Miao 5e4462822d i18n(sidebar): translate the folder delete verification strings
Fills in the 9 locale renderings for the 5 keys added with the folder delete
verification (`sidebar.deleteFolderModal.notEmptyMessageCount`,
`.notEmptyMessageExcluded`, `.busyTitle`, `.checking` and
`sidebar.deleteFolderResult.notEmptyWithCount`), so no `[TODO: Translate]`
placeholder remains anywhere.

Each locale reuses its existing `notEmptyMessage` cascade clause verbatim
(punctuation included) and its help-text style for quoting the "Manage
Excluded Models" label; `{count}` / `{excluded}` match en.json. The Hebrew
model-file noun stays `קובצי מודלים` across all four keys.
docs/i18n-translation-guidelines.md records the new copy in §2 and refreshes
its stale `{count}` and leaf-key counts.
2026-09-26 12:07:21 +08:00
Will Miao 6e45ef566c 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.
2026-09-26 12:07:03 +08:00
willmiao 20d8c22390 docs: auto-update supporters list in README 2026-09-26 00:53:58 +00:00
17 changed files with 616 additions and 60 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+36 -6
View File
@@ -4,7 +4,7 @@ This document is the canonical set of conventions for translating LoRA Manager U
It applies to **human translators and AI agents** alike. Read it before editing anything in
`locales/`.
Source of truth: `locales/en.json` (10 locales, 2025 leaf keys; all locales share the exact
Source of truth: `locales/en.json` (10 locales, 2105 leaf keys; all locales share the exact
same key structure).
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
@@ -78,6 +78,15 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
> the `toast.loras.filenameTemplate*` / `toast.settings.filenameTemplates*` toasts. All 9
> locales are translated (terminology in §2, "Filename Templates feature").
> **Status (2026-09, folder delete verification):** the folder delete modal no longer trusts the
> sidebar's "empty folder" prediction — it dry-runs the delete against the backend and renders
> the answer, so a folder whose models are all *excluded* (invisible to the model lists, still
> real weight files on disk) is refused with an explanation instead of contradicting itself.
> That added 5 keys (`sidebar.deleteFolderModal.notEmptyMessageCount`,
> `.notEmptyMessageExcluded`, `.busyTitle`, `.checking`, `sidebar.deleteFolderResult.notEmptyWithCount`);
> all 9 locales are translated (terminology in §2, "Folder sidebar feature"), so the
> "no remaining placeholders" claim holds again.
---
## 1. Hard rules (do not violate)
@@ -391,11 +400,32 @@ The model-root sidebar manages on-disk folders. "Folder" reuses the noun already
| tree view / list view | zh-CN 树形视图 / 列表视图 · zh-TW 樹狀檢視 / 清單檢視 · ja ツリー表示 / リスト表示 · ko 트리 보기 / 목록 보기 · fr Vue arborescente / Vue liste · de Baumansicht / Listenansicht · es Vista de árbol / Vista de lista · ru Дерево / Список · he תצוגת עץ / תצוגת רשימה |
| sidebar | reuse each locale's `sidebar.hideOnThisPage` noun: zh-CN 侧边栏 · zh-TW 側邊欄 · ja サイドバー · ko 사이드바 · fr barre latérale · de Seitenleiste · es barra lateral · ru боковая панель · he סרגל צד |
Deleting a folder **never cascades over model files** — the backend refuses it and
`sidebar.deleteFolderModal.notEmptyMessage` states the rule in every locale, so keep that
clause (and its `—`) when the copy is edited. The `{name}` / `{count}` / `{message}` tokens in
`sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*`
are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{count}`.
Deleting a folder **never cascades over model files** — the backend refuses it and the
`sidebar.deleteFolderModal.notEmptyMessage*` keys state the rule in every locale, so keep that
clause (and its `—`) when the copy is edited. The three variants split by what the modal knows:
`notEmptyMessage` (no counts), `notEmptyMessageCount` (`{count}`, the blocking models are all
listed) and `notEmptyMessageExcluded` (`{count}` + `{excluded}`, at least one is hidden by the
`exclude` flag — the case where the folder legitimately looks empty). `checking` ("Checking the
folder contents...", ASCII ellipsis) shows while the backend dry run is pending, `busyTitle`
titles the already-pending-staged-delete state, and `notEmptyWithCount` mirrors
`deleteFolderResult.notEmpty` with the count for the stale-tree toast.
| Term | Rendering |
|---|---|
| excluded from the library | zh-CN 已从模型库中排除 · zh-TW 已從模型庫中排除 · ja ライブラリから除外 · ko 라이브러리에서 제외 · fr exclu de la bibliothèque · de von der Bibliothek ausgeschlossen · es excluido de la biblioteca · ru исключены из библиотеки · he מוחרגים מהספרייה |
| un-exclude (verb) | zh-CN 取消排除 · zh-TW 取消排除 · ja 除外を解除 · ko 제외를 해제 · fr annuler l'exclusion · de den Ausschluss aufheben · es anular la exclusión · ru снять исключение · he לבטל את ההחרגה |
| "Manage Excluded Models" quoted in prose | zh-CN “管理已排除的模型” · zh-TW 「管理已排除的模型」 · ja 「除外モデルを管理」 · ko '제외된 모델 관리' · fr « Gérer les modèles exclus » · de „Ausgeschlossene Modelle verwalten“ · es «Gestionar modelos excluidos» · ru «Управление исключёнными моделями» · he «ניהול מודלים מוחרגים» |
A UI label quoted inside prose follows each locale's existing help-text style (zh-CN “ ”,
zh-TW/ja 「 」, ko ASCII `' '`, fr/ru/es/he « », de „ “) — see `settings.hideEarlyAccessUpdates.help`
/ `settings.civitaiHost.help` as the precedent. `קובצי מודלים` is the Hebrew model-file noun
(`notEmptyMessage`); keep it identical in all four Hebrew keys.
The `{name}` / `{count}` / `{excluded}` / `{message}` tokens in `sidebar.createFolderResult.*`,
`sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*` are verbatim §1-R2
placeholders. The keys carrying `{count}` are `successWithFiles`, `notEmptyMessageCount`,
`notEmptyMessageExcluded` and `notEmptyWithCount`; `notEmptyMessageExcluded` is the only key
carrying `{excluded}`.
### Settings Organization tab
+5
View File
@@ -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": "Dieser Ordner enthält noch {count} Modelldatei(en). Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
"notEmptyMessageExcluded": "Dieser Ordner enthält noch {count} Modelldatei(en), davon {excluded} von der Bibliothek ausgeschlossen. Heben Sie den Ausschluss unter „Ausgeschlossene Modelle verwalten“ auf und löschen Sie sie zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
"busyTitle": "Eine Löschung steht noch aus",
"checking": "Ordnerinhalt wird geprüft...",
"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": "Dieser Ordner enthält noch {count} Modelldatei(en). Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
"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"
+5
View File
@@ -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"
+5
View File
@@ -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": "Esta carpeta aún contiene {count} archivo(s) de modelo. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
"notEmptyMessageExcluded": "Esta carpeta aún contiene {count} archivo(s) de modelo, de los cuales {excluded} están excluidos de la biblioteca. Anula su exclusión en «Gestionar modelos excluidos» y elimínalos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
"busyTitle": "Todavía hay una eliminación pendiente",
"checking": "Comprobando el contenido de la carpeta...",
"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": "Esta carpeta aún contiene {count} archivo(s) de modelo. Actualiza la barra lateral e inténtalo de nuevo.",
"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"
+5
View File
@@ -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": "Ce dossier contient encore {count} fichier(s) de modèle. Supprimez-les ou déplacez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
"notEmptyMessageExcluded": "Ce dossier contient encore {count} fichier(s) de modèle, dont {excluded} sont exclus de la bibliothèque. Annulez leur exclusion dans « Gérer les modèles exclus » et supprimez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
"busyTitle": "Une suppression est encore en attente",
"checking": "Vérification du contenu du dossier...",
"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": "Ce dossier contient encore {count} fichier(s) de modèle. Actualisez la barre latérale et réessayez.",
"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"
+5
View File
@@ -1350,6 +1350,10 @@
"emptyNote": "אין מודלים בתיקייה זו. קבצים אחרים שבה יימחקו גם הם.",
"notEmptyTitle": "התיקייה אינה ריקה",
"notEmptyMessage": "בתיקייה זו עדיין יש מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"notEmptyMessageCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"notEmptyMessageExcluded": "בתיקייה זו עדיין יש {count} קובצי מודלים, שמהם {excluded} מוחרגים מהספרייה. בטל את ההחרגה ב«ניהול מודלים מוחרגים» ומחק אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"busyTitle": "מחיקה עדיין ממתינה",
"checking": "בודק את תוכן התיקייה...",
"confirm": "מחק תיקייה"
},
"deleteFolderResult": {
@@ -1358,6 +1362,7 @@
"restored": "התיקייה שוחזרה",
"failed": "מחיקת התיקייה נכשלה: {message}",
"notEmpty": "בתיקייה זו עדיין יש מודלים. רענן את סרגל הצד ונסה שוב.",
"notEmptyWithCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. רענן את סרגל הצד ונסה שוב.",
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
"unsupported": "מחיקת תיקיות אינה נתמכת בדף זה",
"noRoot": "לא הוגדר שורש מודלים"
+5
View File
@@ -1350,6 +1350,10 @@
"emptyNote": "このフォルダにはモデルがありません。他のファイルもすべて削除されます。",
"notEmptyTitle": "フォルダが空ではありません",
"notEmptyMessage": "このフォルダにはまだモデルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"notEmptyMessageCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"notEmptyMessageExcluded": "このフォルダにはまだ {count} 個のモデルファイルがあり、そのうち {excluded} 個はライブラリから除外されています。「除外モデルを管理」で除外を解除してから削除してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"busyTitle": "保留中の削除があります",
"checking": "フォルダの内容を確認しています...",
"confirm": "フォルダを削除"
},
"deleteFolderResult": {
@@ -1358,6 +1362,7 @@
"restored": "フォルダを復元しました",
"failed": "フォルダの削除に失敗しました: {message}",
"notEmpty": "このフォルダにはまだモデルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
"notEmptyWithCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
"unsupported": "このページではフォルダを削除できません",
"noRoot": "モデルルートが設定されていません"
+5
View File
@@ -1350,6 +1350,10 @@
"emptyNote": "이 폴더에는 모델이 없습니다. 폴더 안의 다른 파일도 함께 삭제됩니다.",
"notEmptyTitle": "폴더가 비어 있지 않습니다",
"notEmptyMessage": "이 폴더에는 아직 모델이 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"notEmptyMessageCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"notEmptyMessageExcluded": "이 폴더에는 아직 모델 파일이 {count}개 있으며, 그중 {excluded}개는 라이브러리에서 제외되어 있습니다. '제외된 모델 관리'에서 제외를 해제한 뒤 먼저 삭제하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"busyTitle": "대기 중인 삭제 작업이 있습니다",
"checking": "폴더 내용을 확인하는 중...",
"confirm": "폴더 삭제"
},
"deleteFolderResult": {
@@ -1358,6 +1362,7 @@
"restored": "폴더를 복원했습니다",
"failed": "폴더 삭제 실패: {message}",
"notEmpty": "이 폴더에는 아직 모델이 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
"notEmptyWithCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
"unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다",
"noRoot": "모델 루트가 설정되지 않았습니다"
+5
View File
@@ -1350,6 +1350,10 @@
"emptyNote": "В этой папке нет моделей. Остальные файлы в ней тоже будут удалены.",
"notEmptyTitle": "Папка не пуста",
"notEmptyMessage": "В этой папке ещё есть модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
"notEmptyMessageCount": "В этой папке ещё есть {count} файл(ов) модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
"notEmptyMessageExcluded": "В этой папке ещё есть {count} файл(ов) модели, из них {excluded} исключены из библиотеки. Снимите исключение в разделе «Управление исключёнными моделями» и удалите их — удаление папки никогда не затрагивает файлы моделей.",
"busyTitle": "Удаление всё ещё отложено",
"checking": "Проверка содержимого папки...",
"confirm": "Удалить папку"
},
"deleteFolderResult": {
@@ -1358,6 +1362,7 @@
"restored": "Папка восстановлена",
"failed": "Не удалось удалить папку: {message}",
"notEmpty": "В этой папке ещё есть модели. Обновите боковую панель и повторите попытку.",
"notEmptyWithCount": "В этой папке ещё есть {count} файл(ов) модели. Обновите боковую панель и повторите попытку.",
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
"unsupported": "Удаление папок не поддерживается на этой странице",
"noRoot": "Корневая папка моделей не настроена"
+5
View File
@@ -1350,6 +1350,10 @@
"emptyNote": "该文件夹中没有模型,其中的其他文件也会一并删除。",
"notEmptyTitle": "文件夹不为空",
"notEmptyMessage": "该文件夹中仍有模型,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
"notEmptyMessageCount": "该文件夹中仍有 {count} 个模型文件,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
"notEmptyMessageExcluded": "该文件夹中仍有 {count} 个模型文件,其中 {excluded} 个已从模型库中排除。请先在“管理已排除的模型”中取消排除并删除它们 —— 删除文件夹不会级联删除模型文件。",
"busyTitle": "仍有删除操作待处理",
"checking": "正在检查文件夹内容...",
"confirm": "删除文件夹"
},
"deleteFolderResult": {
@@ -1358,6 +1362,7 @@
"restored": "文件夹已恢复",
"failed": "删除文件夹失败: {message}",
"notEmpty": "该文件夹中仍有模型。请刷新侧边栏后重试。",
"notEmptyWithCount": "该文件夹中仍有 {count} 个模型文件。请刷新侧边栏后重试。",
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
"unsupported": "此页面不支持删除文件夹",
"noRoot": "未配置模型根目录"
+5
View File
@@ -1350,6 +1350,10 @@
"emptyNote": "該資料夾中沒有模型,其中的其他檔案也會一併刪除。",
"notEmptyTitle": "資料夾不是空的",
"notEmptyMessage": "該資料夾中仍有模型,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
"notEmptyMessageCount": "該資料夾中仍有 {count} 個模型檔案,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
"notEmptyMessageExcluded": "該資料夾中仍有 {count} 個模型檔案,其中 {excluded} 個已從模型庫中排除。請先在「管理已排除的模型」中取消排除並刪除它們 —— 刪除資料夾不會串聯刪除模型檔案。",
"busyTitle": "仍有刪除操作待處理",
"checking": "正在檢查資料夾內容...",
"confirm": "刪除資料夾"
},
"deleteFolderResult": {
@@ -1358,6 +1362,7 @@
"restored": "資料夾已還原",
"failed": "刪除資料夾失敗: {message}",
"notEmpty": "該資料夾中仍有模型。請重新整理側邊欄後再試。",
"notEmptyWithCount": "該資料夾中仍有 {count} 個模型檔案。請重新整理側邊欄後再試。",
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
"unsupported": "此頁面不支援刪除資料夾",
"noRoot": "未設定模型根目錄"
+63 -6
View File
@@ -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;
+203 -35
View File
@@ -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({
+90 -1
View File
@@ -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)