fix(sidebar): let a ghost folder be cleared from the UI (#1145)

Deleting a folder a file manager had already moved away answered "Folder no longer
exists" and stopped there: `remove_known_folder()` only ran after `shutil.rmtree()`
succeeded, so the records keeping the node in the tree could never be dropped, and
the modal's missing state hid its confirm button. The sidebar was a dead end for
exactly the case the folder list cannot self-heal.

* `ModelMoveService.forget_folder()` drops a folder's records (the recorded entry
  plus the cached models under it) and refuses with `code: "exists"` while any root
  still holds the directory, because then the caller wants a delete.
* `delete_folder()` forgets the records instead of failing when the directory is
  already gone. The dry run stays a pure read and keeps answering `missing`, which
  is what makes the dialog offer the removal. `POST`/`GET
  /api/lm/{prefix}/forget-folder` is the endpoint for a node that maps to no
  directory at all (the GET form is for the browser extension, which is GET-only).
* The sidebar falls back to it from the delete flow, offers "Remove from list" in
  the missing state, and reports the folder as removed from the sidebar rather than
  as a delete that never happened. An `exists` conflict says the directory is back
  and should be deleted instead.

Verified: 3811 backend tests, 1543 JS tests, 96 Vue tests, 20 i18n tests.
This commit is contained in:
Will Miao
2026-10-12 09:31:29 +08:00
parent 0508daa0fb
commit 3a718ce2c3
19 changed files with 432 additions and 18 deletions
+3
View File
@@ -1419,6 +1419,7 @@
"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.", "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.",
"missingTitle": "Ordner nicht gefunden", "missingTitle": "Ordner nicht gefunden",
"missingMessage": "Dieser Ordner existiert nicht mehr auf dem Datenträger. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.", "missingMessage": "Dieser Ordner existiert nicht mehr auf dem Datenträger. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
"removeFromList": "[TODO: Translate] Remove from list",
"symlinkTitle": "Ordner ist ein symbolischer Link", "symlinkTitle": "Ordner ist ein symbolischer Link",
"symlinkMessage": "Alle Kopien dieses Ordners sind symbolische Links; entfernen Sie den Link oder sein Ziel außerhalb von LoRA Manager.", "symlinkMessage": "Alle Kopien dieses Ordners sind symbolische Links; entfernen Sie den Link oder sein Ziel außerhalb von LoRA Manager.",
"protectedTitle": "Ordner ist geschützt", "protectedTitle": "Ordner ist geschützt",
@@ -1440,6 +1441,8 @@
"busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.", "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", "unsupported": "Das Löschen von Ordnern wird auf dieser Seite nicht unterstützt",
"missing": "Dieser Ordner existiert nicht mehr auf dem Datenträger. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.", "missing": "Dieser Ordner existiert nicht mehr auf dem Datenträger. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the sidebar",
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
"symlink": "Dieser Ordner ist ein symbolischer Link; er kann nicht über die Seitenleiste gelöscht werden.", "symlink": "Dieser Ordner ist ein symbolischer Link; er kann nicht über die Seitenleiste gelöscht werden.",
"successMulti": "Ordner \"{name}\" aus {count} Modell-Stammverzeichnissen gelöscht", "successMulti": "Ordner \"{name}\" aus {count} Modell-Stammverzeichnissen gelöscht",
"partial": "{count} von {total} Kopien gelöscht — {failed} konnten nicht gelöscht werden. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut." "partial": "{count} von {total} Kopien gelöscht — {failed} konnten nicht gelöscht werden. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut."
+4 -1
View File
@@ -1418,7 +1418,8 @@
"notEmptyMessageCount": "This folder still contains {count} model file(s). 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.", "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.",
"missingTitle": "Folder not found", "missingTitle": "Folder not found",
"missingMessage": "This folder no longer exists on disk. Refresh the sidebar and try again.", "missingMessage": "This folder no longer exists on disk. Its entries can still be removed from the sidebar.",
"removeFromList": "Remove from list",
"symlinkTitle": "Folder is a symbolic link", "symlinkTitle": "Folder is a symbolic link",
"symlinkMessage": "Every copy of this folder is a symbolic link; remove the link or its target outside LoRA Manager.", "symlinkMessage": "Every copy of this folder is a symbolic link; remove the link or its target outside LoRA Manager.",
"protectedTitle": "Folder is protected", "protectedTitle": "Folder is protected",
@@ -1440,6 +1441,8 @@
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.", "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", "unsupported": "Folder deletion is not supported on this page",
"missing": "This folder no longer exists on disk. Refresh the sidebar and try again.", "missing": "This folder no longer exists on disk. Refresh the sidebar and try again.",
"forgotten": "Folder \"{name}\" removed from the sidebar",
"exists": "The folder is back on disk. Delete it instead.",
"symlink": "This folder is a symbolic link; it cannot be deleted from the sidebar.", "symlink": "This folder is a symbolic link; it cannot be deleted from the sidebar.",
"successMulti": "Folder \"{name}\" deleted from {count} model roots", "successMulti": "Folder \"{name}\" deleted from {count} model roots",
"partial": "Deleted {count} of {total} copies — {failed} could not be deleted. Refresh the sidebar and retry." "partial": "Deleted {count} of {total} copies — {failed} could not be deleted. Refresh the sidebar and retry."
+3
View File
@@ -1419,6 +1419,7 @@
"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.", "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.",
"missingTitle": "Carpeta no encontrada", "missingTitle": "Carpeta no encontrada",
"missingMessage": "Esta carpeta ya no existe en el disco. Actualiza la barra lateral e inténtalo de nuevo.", "missingMessage": "Esta carpeta ya no existe en el disco. Actualiza la barra lateral e inténtalo de nuevo.",
"removeFromList": "[TODO: Translate] Remove from list",
"symlinkTitle": "La carpeta es un enlace simbólico", "symlinkTitle": "La carpeta es un enlace simbólico",
"symlinkMessage": "Todas las copias de esta carpeta son enlaces simbólicos; elimina el enlace o su destino fuera de LoRA Manager.", "symlinkMessage": "Todas las copias de esta carpeta son enlaces simbólicos; elimina el enlace o su destino fuera de LoRA Manager.",
"protectedTitle": "Carpeta protegida", "protectedTitle": "Carpeta protegida",
@@ -1440,6 +1441,8 @@
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.", "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", "unsupported": "La eliminación de carpetas no es compatible con esta página",
"missing": "Esta carpeta ya no existe en el disco. Actualiza la barra lateral e inténtalo de nuevo.", "missing": "Esta carpeta ya no existe en el disco. Actualiza la barra lateral e inténtalo de nuevo.",
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the sidebar",
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
"symlink": "Esta carpeta es un enlace simbólico; no se puede eliminar desde la barra lateral.", "symlink": "Esta carpeta es un enlace simbólico; no se puede eliminar desde la barra lateral.",
"successMulti": "Carpeta \"{name}\" eliminada de {count} raíces de modelo", "successMulti": "Carpeta \"{name}\" eliminada de {count} raíces de modelo",
"partial": "Se eliminaron {count} de {total} copias — {failed} no se pudieron eliminar. Actualiza la barra lateral e inténtalo de nuevo." "partial": "Se eliminaron {count} de {total} copias — {failed} no se pudieron eliminar. Actualiza la barra lateral e inténtalo de nuevo."
+3
View File
@@ -1419,6 +1419,7 @@
"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.", "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.",
"missingTitle": "Dossier introuvable", "missingTitle": "Dossier introuvable",
"missingMessage": "Ce dossier n’existe plus sur le disque. Actualisez la barre latérale et réessayez.", "missingMessage": "Ce dossier n’existe plus sur le disque. Actualisez la barre latérale et réessayez.",
"removeFromList": "[TODO: Translate] Remove from list",
"symlinkTitle": "Le dossier est un lien symbolique", "symlinkTitle": "Le dossier est un lien symbolique",
"symlinkMessage": "Toutes les copies de ce dossier sont des liens symboliques ; supprimez le lien ou sa cible en dehors de LoRA Manager.", "symlinkMessage": "Toutes les copies de ce dossier sont des liens symboliques ; supprimez le lien ou sa cible en dehors de LoRA Manager.",
"protectedTitle": "Dossier protégé", "protectedTitle": "Dossier protégé",
@@ -1440,6 +1441,8 @@
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre d’annulation.", "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", "unsupported": "La suppression de dossiers n’est pas prise en charge sur cette page",
"missing": "Ce dossier n’existe plus sur le disque. Actualisez la barre latérale et réessayez.", "missing": "Ce dossier n’existe plus sur le disque. Actualisez la barre latérale et réessayez.",
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the sidebar",
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
"symlink": "Ce dossier est un lien symbolique ; il ne peut pas être supprimé depuis la barre latérale.", "symlink": "Ce dossier est un lien symbolique ; il ne peut pas être supprimé depuis la barre latérale.",
"successMulti": "Dossier \"{name}\" supprimé de {count} racines de modèle", "successMulti": "Dossier \"{name}\" supprimé de {count} racines de modèle",
"partial": "{count} copie(s) sur {total} supprimée(s) — {failed} en échec. Actualisez la barre latérale et réessayez." "partial": "{count} copie(s) sur {total} supprimée(s) — {failed} en échec. Actualisez la barre latérale et réessayez."
+3
View File
@@ -1419,6 +1419,7 @@
"notEmptyMessageExcluded": "בתיקייה זו עדיין יש {count} קובצי מודלים, שמהם {excluded} מוחרגים מהספרייה. בטל את ההחרגה ב«ניהול מודלים מוחרגים» ומחק אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.", "notEmptyMessageExcluded": "בתיקייה זו עדיין יש {count} קובצי מודלים, שמהם {excluded} מוחרגים מהספרייה. בטל את ההחרגה ב«ניהול מודלים מוחרגים» ומחק אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"missingTitle": "התיקייה לא נמצאה", "missingTitle": "התיקייה לא נמצאה",
"missingMessage": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.", "missingMessage": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.",
"removeFromList": "[TODO: Translate] Remove from list",
"symlinkTitle": "התיקייה היא קישור סמלי", "symlinkTitle": "התיקייה היא קישור סמלי",
"symlinkMessage": "כל העותקים של תיקייה זו הם קישורים סמליים; מחק את הקישור או את היעד שלו מחוץ ל-LoRA Manager.", "symlinkMessage": "כל העותקים של תיקייה זו הם קישורים סמליים; מחק את הקישור או את היעד שלו מחוץ ל-LoRA Manager.",
"protectedTitle": "תיקייה מוגנת", "protectedTitle": "תיקייה מוגנת",
@@ -1440,6 +1441,8 @@
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.", "busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
"unsupported": "מחיקת תיקיות אינה נתמכת בדף זה", "unsupported": "מחיקת תיקיות אינה נתמכת בדף זה",
"missing": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.", "missing": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.",
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the sidebar",
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
"symlink": "תיקייה זו היא קישור סמלי; לא ניתן למחוק אותה מסרגל הצד.", "symlink": "תיקייה זו היא קישור סמלי; לא ניתן למחוק אותה מסרגל הצד.",
"successMulti": "התיקייה \"{name}\" נמחקה מ-{count} שורשי מודלים", "successMulti": "התיקייה \"{name}\" נמחקה מ-{count} שורשי מודלים",
"partial": "נמחקו {count} מתוך {total} עותקים — {failed} לא נמחקו. רענן את סרגל הצד ונסה שוב." "partial": "נמחקו {count} מתוך {total} עותקים — {failed} לא נמחקו. רענן את סרגל הצד ונסה שוב."
+3
View File
@@ -1419,6 +1419,7 @@
"notEmptyMessageExcluded": "このフォルダにはまだ {count} 個のモデルファイルがあり、そのうち {excluded} 個はライブラリから除外されています。「除外モデルを管理」で除外を解除してから削除してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。", "notEmptyMessageExcluded": "このフォルダにはまだ {count} 個のモデルファイルがあり、そのうち {excluded} 個はライブラリから除外されています。「除外モデルを管理」で除外を解除してから削除してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"missingTitle": "フォルダが見つかりません", "missingTitle": "フォルダが見つかりません",
"missingMessage": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。", "missingMessage": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。",
"removeFromList": "[TODO: Translate] Remove from list",
"symlinkTitle": "フォルダがシンボリックリンクです", "symlinkTitle": "フォルダがシンボリックリンクです",
"symlinkMessage": "このフォルダのすべてのコピーがシンボリックリンクです。LoRA Manager の外でリンクまたはそのリンク先を削除してください。", "symlinkMessage": "このフォルダのすべてのコピーがシンボリックリンクです。LoRA Manager の外でリンクまたはそのリンク先を削除してください。",
"protectedTitle": "フォルダは保護されています", "protectedTitle": "フォルダは保護されています",
@@ -1440,6 +1441,8 @@
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。", "busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
"unsupported": "このページではフォルダを削除できません", "unsupported": "このページではフォルダを削除できません",
"missing": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。", "missing": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。",
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the sidebar",
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
"symlink": "このフォルダはシンボリックリンクです。サイドバーからは削除できません。", "symlink": "このフォルダはシンボリックリンクです。サイドバーからは削除できません。",
"successMulti": "{count} 個のモデルルートからフォルダ \"{name}\" を削除しました", "successMulti": "{count} 個のモデルルートからフォルダ \"{name}\" を削除しました",
"partial": "{total} 個のコピーのうち {count} 個を削除しました —— {failed} 個は削除できませんでした。サイドバーを再読み込みしてからもう一度お試しください。" "partial": "{total} 個のコピーのうち {count} 個を削除しました —— {failed} 個は削除できませんでした。サイドバーを再読み込みしてからもう一度お試しください。"
+3
View File
@@ -1419,6 +1419,7 @@
"notEmptyMessageExcluded": "이 폴더에는 아직 모델 파일이 {count}개 있으며, 그중 {excluded}개는 라이브러리에서 제외되어 있습니다. '제외된 모델 관리'에서 제외를 해제한 뒤 먼저 삭제하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.", "notEmptyMessageExcluded": "이 폴더에는 아직 모델 파일이 {count}개 있으며, 그중 {excluded}개는 라이브러리에서 제외되어 있습니다. '제외된 모델 관리'에서 제외를 해제한 뒤 먼저 삭제하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"missingTitle": "폴더를 찾을 수 없습니다", "missingTitle": "폴더를 찾을 수 없습니다",
"missingMessage": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.", "missingMessage": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
"removeFromList": "[TODO: Translate] Remove from list",
"symlinkTitle": "폴더가 심볼릭 링크입니다", "symlinkTitle": "폴더가 심볼릭 링크입니다",
"symlinkMessage": "이 폴더의 모든 복사본이 심볼릭 링크입니다. LoRA Manager 외부에서 링크 또는 그 대상을 삭제하세요.", "symlinkMessage": "이 폴더의 모든 복사본이 심볼릭 링크입니다. LoRA Manager 외부에서 링크 또는 그 대상을 삭제하세요.",
"protectedTitle": "보호된 폴더", "protectedTitle": "보호된 폴더",
@@ -1440,6 +1441,8 @@
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.", "busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
"unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다", "unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다",
"missing": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.", "missing": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the sidebar",
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
"symlink": "이 폴더는 심볼릭 링크이므로 사이드바에서 삭제할 수 없습니다.", "symlink": "이 폴더는 심볼릭 링크이므로 사이드바에서 삭제할 수 없습니다.",
"successMulti": "모델 루트 {count}개에서 \"{name}\" 폴더를 삭제했습니다", "successMulti": "모델 루트 {count}개에서 \"{name}\" 폴더를 삭제했습니다",
"partial": "복사본 {total}개 중 {count}개를 삭제했습니다 —— {failed}개는 삭제하지 못했습니다. 사이드바를 새로 고친 후 다시 시도하세요." "partial": "복사본 {total}개 중 {count}개를 삭제했습니다 —— {failed}개는 삭제하지 못했습니다. 사이드바를 새로 고친 후 다시 시도하세요."
+3
View File
@@ -1419,6 +1419,7 @@
"notEmptyMessageExcluded": "В этой папке ещё есть {count} файл(ов) модели, из них {excluded} исключены из библиотеки. Снимите исключение в разделе «Управление исключёнными моделями» и удалите их — удаление папки никогда не затрагивает файлы моделей.", "notEmptyMessageExcluded": "В этой папке ещё есть {count} файл(ов) модели, из них {excluded} исключены из библиотеки. Снимите исключение в разделе «Управление исключёнными моделями» и удалите их — удаление папки никогда не затрагивает файлы моделей.",
"missingTitle": "Папка не найдена", "missingTitle": "Папка не найдена",
"missingMessage": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.", "missingMessage": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.",
"removeFromList": "[TODO: Translate] Remove from list",
"symlinkTitle": "Папка является символической ссылкой", "symlinkTitle": "Папка является символической ссылкой",
"symlinkMessage": "Все копии этой папки являются символическими ссылками; удалите ссылку или её цель вне LoRA Manager.", "symlinkMessage": "Все копии этой папки являются символическими ссылками; удалите ссылку или её цель вне LoRA Manager.",
"protectedTitle": "Папка защищена", "protectedTitle": "Папка защищена",
@@ -1440,6 +1441,8 @@
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.", "busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
"unsupported": "Удаление папок не поддерживается на этой странице", "unsupported": "Удаление папок не поддерживается на этой странице",
"missing": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.", "missing": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.",
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the sidebar",
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
"symlink": "Эта папка является символической ссылкой; её нельзя удалить из боковой панели.", "symlink": "Эта папка является символической ссылкой; её нельзя удалить из боковой панели.",
"successMulti": "Папка \"{name}\" удалена из {count} корневых папок моделей", "successMulti": "Папка \"{name}\" удалена из {count} корневых папок моделей",
"partial": "Удалено {count} из {total} копий — не удалось удалить {failed}. Обновите боковую панель и повторите попытку." "partial": "Удалено {count} из {total} копий — не удалось удалить {failed}. Обновите боковую панель и повторите попытку."
+3
View File
@@ -1419,6 +1419,7 @@
"notEmptyMessageExcluded": "该文件夹中仍有 {count} 个模型文件,其中 {excluded} 个已从模型库中排除。请先在“管理已排除的模型”中取消排除并删除它们 —— 删除文件夹不会级联删除模型文件。", "notEmptyMessageExcluded": "该文件夹中仍有 {count} 个模型文件,其中 {excluded} 个已从模型库中排除。请先在“管理已排除的模型”中取消排除并删除它们 —— 删除文件夹不会级联删除模型文件。",
"missingTitle": "未找到文件夹", "missingTitle": "未找到文件夹",
"missingMessage": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。", "missingMessage": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。",
"removeFromList": "[TODO: Translate] Remove from list",
"symlinkTitle": "文件夹是符号链接", "symlinkTitle": "文件夹是符号链接",
"symlinkMessage": "该文件夹的每一份副本都是符号链接;请在 LoRA Manager 之外删除该链接或其指向的目标。", "symlinkMessage": "该文件夹的每一份副本都是符号链接;请在 LoRA Manager 之外删除该链接或其指向的目标。",
"protectedTitle": "文件夹受保护", "protectedTitle": "文件夹受保护",
@@ -1440,6 +1441,8 @@
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。", "busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
"unsupported": "此页面不支持删除文件夹", "unsupported": "此页面不支持删除文件夹",
"missing": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。", "missing": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。",
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the sidebar",
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
"symlink": "该文件夹是符号链接,无法从侧边栏中删除。", "symlink": "该文件夹是符号链接,无法从侧边栏中删除。",
"successMulti": "已从 {count} 个模型根目录中删除文件夹 \"{name}\"", "successMulti": "已从 {count} 个模型根目录中删除文件夹 \"{name}\"",
"partial": "已删除 {total} 份副本中的 {count} 份 —— {failed} 份无法删除。请刷新侧边栏后重试。" "partial": "已删除 {total} 份副本中的 {count} 份 —— {failed} 份无法删除。请刷新侧边栏后重试。"
+3
View File
@@ -1419,6 +1419,7 @@
"notEmptyMessageExcluded": "該資料夾中仍有 {count} 個模型檔案,其中 {excluded} 個已從模型庫中排除。請先在「管理已排除的模型」中取消排除並刪除它們 —— 刪除資料夾不會串聯刪除模型檔案。", "notEmptyMessageExcluded": "該資料夾中仍有 {count} 個模型檔案,其中 {excluded} 個已從模型庫中排除。請先在「管理已排除的模型」中取消排除並刪除它們 —— 刪除資料夾不會串聯刪除模型檔案。",
"missingTitle": "未找到資料夾", "missingTitle": "未找到資料夾",
"missingMessage": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。", "missingMessage": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。",
"removeFromList": "[TODO: Translate] Remove from list",
"symlinkTitle": "資料夾是符號連結", "symlinkTitle": "資料夾是符號連結",
"symlinkMessage": "該資料夾的每一份副本都是符號連結;請在 LoRA Manager 之外刪除該連結或其指向的目標。", "symlinkMessage": "該資料夾的每一份副本都是符號連結;請在 LoRA Manager 之外刪除該連結或其指向的目標。",
"protectedTitle": "資料夾受保護", "protectedTitle": "資料夾受保護",
@@ -1440,6 +1441,8 @@
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。", "busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
"unsupported": "此頁面不支援刪除資料夾", "unsupported": "此頁面不支援刪除資料夾",
"missing": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。", "missing": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。",
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the sidebar",
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
"symlink": "該資料夾是符號連結,無法從側邊欄中刪除。", "symlink": "該資料夾是符號連結,無法從側邊欄中刪除。",
"successMulti": "已從 {count} 個模型根目錄中刪除資料夾 \"{name}\"", "successMulti": "已從 {count} 個模型根目錄中刪除資料夾 \"{name}\"",
"partial": "已刪除 {total} 份副本中的 {count} 份 —— {failed} 份無法刪除。請重新整理側邊欄後再試。" "partial": "已刪除 {total} 份副本中的 {count} 份 —— {failed} 份無法刪除。請重新整理側邊欄後再試。"
+34
View File
@@ -2629,6 +2629,39 @@ class ModelMoveHandler:
self._logger.error("Error deleting folder: %s", exc, exc_info=True) self._logger.error("Error deleting folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def forget_folder(self, request: web.Request) -> web.Response:
"""Drop the records of a folder that no longer exists on disk (#1145).
The sidebar's delete flow reaches this when the node it holds maps to no
directory any more: there is nothing to delete, so the only cleanup left
is forgetting the folder entry and the cached models under it.
"""
data: Dict[str, Any] = {}
if request.method == "POST":
try:
data = await request.json()
except Exception:
data = {}
folder = str(data.get("folder") or request.query.get("folder") or "").strip()
if not folder:
return web.json_response(
{"success": False, "error": "Folder is required"}, status=400
)
try:
result = await self._move_service.forget_folder(folder)
if result.get("success"):
_broadcast_models_changed()
return web.json_response(result, status=200)
# `exists` is a conflict between the tree the client rendered and the
# on-disk truth: the folder is still there, so it must be deleted.
status = 409 if result.get("code") == "exists" else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error forgetting folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def rename_folder(self, request: web.Request) -> web.Response: async def rename_folder(self, request: web.Request) -> web.Response:
try: try:
data = await request.json() data = await request.json()
@@ -3701,6 +3734,7 @@ class ModelHandlerSet:
"create_folder": self.move.create_folder, "create_folder": self.move.create_folder,
"resolve_folder": self.move.resolve_folder, "resolve_folder": self.move.resolve_folder,
"delete_folder": self.move.delete_folder, "delete_folder": self.move.delete_folder,
"forget_folder": self.move.forget_folder,
"rename_folder": self.move.rename_folder, "rename_folder": self.move.rename_folder,
"auto_organize_models": self.auto_organize.auto_organize_models, "auto_organize_models": self.auto_organize.auto_organize_models,
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress, "get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
+4
View File
@@ -42,6 +42,10 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"), RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"),
RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"), RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"),
RouteDefinition("POST", "/api/lm/{prefix}/delete-folder", "delete_folder"), RouteDefinition("POST", "/api/lm/{prefix}/delete-folder", "delete_folder"),
# Forgetting a folder that is gone from disk is a state change, so it takes
# GET with query params too (the browser extension is GET-only by convention).
RouteDefinition("POST", "/api/lm/{prefix}/forget-folder", "forget_folder"),
RouteDefinition("GET", "/api/lm/{prefix}/forget-folder", "forget_folder"),
RouteDefinition("POST", "/api/lm/{prefix}/rename-folder", "rename_folder"), RouteDefinition("POST", "/api/lm/{prefix}/rename-folder", "rename_folder"),
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
+46 -1
View File
@@ -589,6 +589,33 @@ class ModelMoveService:
return rel.replace(os.sep, "/") return rel.replace(os.sep, "/")
return "" return ""
async def forget_folder(self, folder: str) -> Dict[str, Any]:
"""Drop the records of a folder that is on disk under no root (#1145).
A directory moved or deleted with a file manager can leave the sidebar
holding a node no delete can clear: the folder is gone, so there is
nothing to remove and every attempt answered "Folder no longer exists".
This drops what remains — the recorded folder entry and the cached model
rows under it — and is refused while any root still holds the directory,
because then the caller wants a delete, not a cleanup.
"""
resolution = self.resolve_folder(folder)
if not resolution.get("success"):
return resolution
normalized = resolution.get("folder") or ""
if not normalized:
return {"success": False, "error": "Folder is required"}
if resolution.get("candidates"):
return {
"success": False,
"code": "exists",
"error": "The folder still exists on disk",
}
await self._forget_folder(normalized)
return {"success": True, "folder": normalized}
def resolve_folder(self, folder: str) -> Dict[str, Any]: def resolve_folder(self, folder: str) -> Dict[str, Any]:
"""Map a library-relative folder onto the directories it stands for. """Map a library-relative folder onto the directories it stands for.
@@ -685,13 +712,31 @@ class ModelMoveService:
} }
if not os.path.isdir(absolute_path): if not os.path.isdir(absolute_path):
# `missing` is its own code so the sidebar can tell "this node's # `missing` is its own code so the sidebar can tell "this node's
# directory is gone, refresh" apart from a failed operation. # directory is gone" apart from a failed operation. There is
# nothing left to delete, but the sidebar can still be holding the
# node: a folder moved or removed with a file manager leaves its
# records behind, and "Folder no longer exists" was a dead end --
# the ghost could not be cleared from the UI at all (issue #1145).
# The real delete therefore forgets those records and reports
# success; the dry run stays a pure read and keeps answering
# `missing`, which is what makes the dialog offer the removal.
if dry_run:
return { return {
"success": False, "success": False,
"code": "missing", "code": "missing",
"error": "Folder no longer exists", "error": "Folder no longer exists",
} }
relative_folder = self._calculate_relative_folder(absolute_path)
await self._forget_folder(relative_folder, absolute_path)
return {
"success": True,
"code": "missing",
"already_missing": True,
"folder": relative_folder,
"folder_path": absolute_path.replace(os.sep, "/"),
}
if self._is_model_root(absolute_path): if self._is_model_root(absolute_path):
return { return {
"success": False, "success": False,
+4
View File
@@ -85,6 +85,10 @@ export function getApiEndpoints(modelType) {
moveBulk: `/api/lm/${modelType}/move_models_bulk`, moveBulk: `/api/lm/${modelType}/move_models_bulk`,
createFolder: `/api/lm/${modelType}/create-folder`, createFolder: `/api/lm/${modelType}/create-folder`,
deleteFolder: `/api/lm/${modelType}/delete-folder`, deleteFolder: `/api/lm/${modelType}/delete-folder`,
// Drops the sidebar records of a folder that no longer exists on disk:
// the only cleanup left for a node whose directory was moved or deleted
// outside LoRA Manager.
forgetFolder: `/api/lm/${modelType}/forget-folder`,
renameFolder: `/api/lm/${modelType}/rename-folder`, renameFolder: `/api/lm/${modelType}/rename-folder`,
// Resolves a tree-relative folder onto the root(s) that hold it: the // Resolves a tree-relative folder onto the root(s) that hold it: the
// unified folder tree merges every root, so a node does not carry its // unified folder tree merges every root, so a node does not carry its
+35 -1
View File
@@ -1440,7 +1440,9 @@ export class BaseModelApiClient {
* Only model-free folders can be removed; the backend answers with a 409 * Only model-free folders can be removed; the backend answers with a 409
* `not_empty`/`busy` conflict otherwise. Those codes are attached to the * `not_empty`/`busy` conflict otherwise. Those codes are attached to the
* thrown Error (`code`, `manifest`) so callers can explain the refusal * thrown Error (`code`, `manifest`) so callers can explain the refusal
* instead of showing a bare message. * instead of showing a bare message. A folder that is already gone from disk
* is forgotten instead (`code: 'missing'`, success), which is what lets the
* sidebar clear a node whose directory was moved outside LoRA Manager.
* *
* @param {string} folderPath Absolute business path of the folder * @param {string} folderPath Absolute business path of the folder
* @param {{dryRun?: boolean}} [options] * @param {{dryRun?: boolean}} [options]
@@ -1468,6 +1470,38 @@ export class BaseModelApiClient {
return result; return result;
} }
/**
* Drop the records of a folder that no longer exists on disk.
*
* Deleting such a folder cannot work -- there is nothing left to remove --
* so a node whose directory was moved or deleted outside LoRA Manager is
* cleared with this instead. The backend refuses while any root still holds
* the directory (`code: 'exists'`), because then it is a delete.
*
* @param {string} folder - Library-relative folder path (e.g. "Krea2/Women")
* @returns {Promise<Object>} `{success: true, folder}` on success
*/
async forgetFolder(folder) {
const response = await fetch(this.apiConfig.endpoints.forgetFolder, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ folder })
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.success === false) {
const error = new Error(result.error || 'Failed to remove folder from the sidebar');
error.code = result.code || null;
throw error;
}
return result;
}
/** /**
* Rename a folder inside the library roots. * Rename a folder inside the library roots.
* *
+65 -7
View File
@@ -1734,6 +1734,8 @@ export class SidebarManager {
showToast('sidebar.deleteFolderResult.busy', {}, 'warning'); showToast('sidebar.deleteFolderResult.busy', {}, 'warning');
} else if (error?.code === 'missing') { } else if (error?.code === 'missing') {
showToast('sidebar.deleteFolderResult.missing', {}, 'warning'); showToast('sidebar.deleteFolderResult.missing', {}, 'warning');
} else if (error?.code === 'exists') {
showToast('sidebar.deleteFolderResult.exists', {}, 'warning');
} else if (error?.code === 'unresolved') { } else if (error?.code === 'unresolved') {
showToast('sidebar.folderResult.unresolved', {}, 'error'); showToast('sidebar.folderResult.unresolved', {}, 'error');
} else { } else {
@@ -1806,6 +1808,8 @@ export class SidebarManager {
); );
} else { } else {
this._pendingDeleteFolderPath = null; this._pendingDeleteFolderPath = null;
confirmBtn.style.display = 'none';
confirmBtn.disabled = true;
if (state === 'busy') { if (state === 'busy') {
title.textContent = translate( title.textContent = translate(
'sidebar.deleteFolderModal.busyTitle', {}, 'A deletion is still pending' 'sidebar.deleteFolderModal.busyTitle', {}, 'A deletion is still pending'
@@ -1820,7 +1824,16 @@ export class SidebarManager {
); );
message.textContent = translate( message.textContent = translate(
'sidebar.deleteFolderModal.missingMessage', {}, 'sidebar.deleteFolderModal.missingMessage', {},
'This folder no longer exists on disk. Refresh the sidebar and try again.' 'This folder no longer exists on disk. Its entries can still be removed from the sidebar.'
);
// There is nothing left to delete, but the records are what keeps
// the ghost node in the tree: offer to drop them (issue #1145).
// The confirm re-resolves the node, finds no directory and takes
// the forget path.
confirmBtn.style.display = '';
confirmBtn.disabled = false;
confirmBtn.textContent = translate(
'sidebar.deleteFolderModal.removeFromList', {}, 'Remove from list'
); );
} else if (state === 'symlink') { } else if (state === 'symlink') {
title.textContent = translate( title.textContent = translate(
@@ -1849,8 +1862,6 @@ export class SidebarManager {
|| 'This folder still contains models. Delete or move them first.' || 'This folder still contains models. Delete or move them first.'
); );
} }
confirmBtn.style.display = 'none';
confirmBtn.disabled = true;
} }
if (checking) { if (checking) {
@@ -1943,10 +1954,14 @@ export class SidebarManager {
); );
if (!selected) { if (!selected) {
const empty = (resolution?.candidates || []).length === 0; const empty = (resolution?.candidates || []).length === 0;
if (empty) {
// No root holds the directory any more: there is nothing
// to delete, so drop the records that keep the node in
// the tree (issue #1145).
return this._forgetFolder(relativePath);
}
showToast( showToast(
empty 'sidebar.deleteFolderResult.symlink',
? 'sidebar.deleteFolderResult.missing'
: 'sidebar.deleteFolderResult.symlink',
{}, {},
'warning' 'warning'
); );
@@ -1967,7 +1982,15 @@ export class SidebarManager {
await this.refresh(); await this.refresh();
const name = result.folder || relativePath; const name = result.folder || relativePath;
if (result.restorable) { if (result.code === 'missing') {
// The directory was already gone when the delete ran (a race with
// the file manager, or a row in the multi-root list): only its
// records were dropped, so nothing was deleted and there is
// nothing to undo.
showToast(
'sidebar.deleteFolderResult.forgotten', { name }, 'success'
);
} else if (result.restorable) {
// A truly empty folder is reproducible one-for-one, so offer // A truly empty folder is reproducible one-for-one, so offer
// the same 20s undo affordance the model delete flow uses. // the same 20s undo affordance the model delete flow uses.
showActionToast('sidebar.deleteFolderResult.success', { name }, 'success', { showActionToast('sidebar.deleteFolderResult.success', { name }, 'success', {
@@ -1992,6 +2015,41 @@ export class SidebarManager {
} }
} }
/**
* Clear a folder node whose directory is gone from every root (issue #1145).
*
* A folder moved or deleted with a file manager leaves the sidebar holding a
* node that no delete can clear, because there is nothing left to remove. The
* delete affordance falls back to this instead of refusing: the backend drops
* the folder record and the cached models under it, and answers `exists` if
* the directory turned out to be back on disk (then it is a delete again).
*/
async _forgetFolder(relativePath) {
if (
!this._supportsFolderManagement()
|| typeof this.apiClient.forgetFolder !== 'function'
) {
showToast('sidebar.deleteFolderResult.unsupported', {}, 'error');
return false;
}
try {
const result = await this.apiClient.forgetFolder(relativePath);
this._forgetRemovedFolder(relativePath);
await this.refresh();
showToast(
'sidebar.deleteFolderResult.forgotten',
{ name: result.folder || relativePath },
'success'
);
return true;
} catch (error) {
console.error('[SidebarManager] Error forgetting folder:', error);
this._reportDeleteFolderFailure(error);
return false;
}
}
/** /**
* Recreate removed directories through the create-folder API. * Recreate removed directories through the create-folder API.
* *
@@ -70,6 +70,7 @@ function createApiClient(overrides = {}) {
dir_count: 0, dir_count: 0,
restorable: true, restorable: true,
}), }),
forgetFolder: vi.fn().mockResolvedValue({ success: true, folder: 'empty' }),
renameFolder: vi.fn().mockResolvedValue({ renameFolder: vi.fn().mockResolvedValue({
success: true, success: true,
renamed: true, renamed: true,
@@ -1294,7 +1295,7 @@ describe('SidebarManager folder deletion', () => {
expect(manager.refresh).toHaveBeenCalledTimes(1); expect(manager.refresh).toHaveBeenCalledTimes(1);
}); });
it('reports a stale tree node as missing instead of fabricating a path', async () => { it('offers to clear a stale tree node instead of fabricating a path', async () => {
const apiClient = createApiClient({ const apiClient = createApiClient({
resolveFolder: vi.fn().mockResolvedValue({ resolveFolder: vi.fn().mockResolvedValue({
success: true, folder: 'removed', candidates: [], success: true, folder: 'removed', candidates: [],
@@ -1307,9 +1308,85 @@ describe('SidebarManager folder deletion', () => {
const modal = document.getElementById('deleteFolderModal'); const modal = document.getElementById('deleteFolderModal');
expect(modal.dataset.state).toBe('missing'); expect(modal.dataset.state).toBe('missing');
expect(confirmBtn().style.display).toBe('none'); // Nothing to delete, so the delete probe never runs and no path is guessed...
expect(manager._pendingDeleteFolderPath).toBeNull();
expect(apiClient.deleteFolder).not.toHaveBeenCalled(); expect(apiClient.deleteFolder).not.toHaveBeenCalled();
expect(manager._pendingDeleteFolderPath).toBeNull();
// ...but the records are all that keeps the node alive, so they can be
// dropped from here (issue #1145).
expect(confirmBtn().style.display).toBe('');
expect(confirmBtn().disabled).toBe(false);
expect(confirmBtn().textContent).toBe('Remove from list');
});
it('clears the node through the forget endpoint when no root holds it', async () => {
const apiClient = createApiClient({
resolveFolder: vi.fn().mockResolvedValue({
success: true, folder: 'removed', candidates: [],
}),
forgetFolder: vi.fn().mockResolvedValue({ success: true, folder: 'removed' }),
});
const manager = createManager(apiClient);
manager.nonEmptyFolders = new Set(['', 'full']);
manager.refresh = vi.fn().mockResolvedValue(undefined);
await manager.showDeleteFolderModal('removed');
const cleared = await manager.handleDeleteFolderConfirm();
expect(cleared).toBe(true);
expect(apiClient.forgetFolder).toHaveBeenCalledWith('removed');
expect(apiClient.deleteFolder).not.toHaveBeenCalled();
expect(manager.refresh).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith(
'sidebar.deleteFolderResult.forgotten',
{ name: 'removed' },
'success'
);
});
it('reports a delete that found the folder gone as cleared, not deleted', async () => {
// The probe can pass and the directory still be gone by the time the real
// delete runs (a file manager move in between, or another row of the
// multi-root list): the records are dropped and nothing was deleted.
const apiClient = createApiClient({
deleteFolder: vi.fn().mockResolvedValue({
success: true,
code: 'missing',
already_missing: true,
folder: 'empty',
}),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
const deleted = await manager._deleteFolder('empty', '/models/loras/empty');
expect(deleted).toBe(true);
expect(showToast).toHaveBeenCalledWith(
'sidebar.deleteFolderResult.forgotten',
{ name: 'empty' },
'success'
);
expect(showActionToast).not.toHaveBeenCalled();
});
it('warns when the folder turned out to be back on disk', async () => {
const apiClient = createApiClient({
resolveFolder: vi.fn().mockResolvedValue({
success: true, folder: 'back', candidates: [],
}),
forgetFolder: vi.fn().mockRejectedValue(
Object.assign(new Error('The folder still exists on disk'), { code: 'exists' })
),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
const cleared = await manager._deleteFolder('back');
expect(cleared).toBe(false);
expect(showToast).toHaveBeenCalledWith(
'sidebar.deleteFolderResult.exists', {}, 'warning'
);
}); });
it('refuses a node whose every copy is a symbolic link', async () => { it('refuses a node whose every copy is a symbolic link', async () => {
+68
View File
@@ -23,6 +23,10 @@ class FakeMoveService:
self.received_dry_run = dry_run self.received_dry_run = dry_run
return self._result return self._result
async def forget_folder(self, folder):
self.received_folder = folder
return self._result
async def rename_folder(self, folder_path, new_name): async def rename_folder(self, folder_path, new_name):
self.received_path = folder_path self.received_path = folder_path
self.received_new_name = new_name self.received_new_name = new_name
@@ -34,13 +38,26 @@ class FakeMoveService:
class FakeRequest: class FakeRequest:
method = "POST"
def __init__(self, payload): def __init__(self, payload):
self._payload = payload self._payload = payload
# A real aiohttp request always carries a query string too.
self.query = {}
async def json(self): async def json(self):
return self._payload return self._payload
class FakeGetRequest:
"""GET stand-in carrying only a query string, as the extension sends it."""
method = "GET"
def __init__(self, query):
self.query = query
class FakeQueryRequest: class FakeQueryRequest:
"""Request stand-in exposing only the query string.""" """Request stand-in exposing only the query string."""
@@ -394,3 +411,54 @@ async def test_rename_folder_invalid_json_body():
assert response.status == 400 assert response.status == 400
assert json.loads(response.text)["success"] is False assert json.loads(response.text)["success"] is False
@pytest.mark.asyncio
async def test_forget_folder_success():
handler, service = _make_handler({"success": True, "folder": "Krea2/Women"})
response = await handler.forget_folder(FakeRequest({"folder": "Krea2/Women"}))
assert response.status == 200
assert json.loads(response.text)["folder"] == "Krea2/Women"
assert service.received_folder == "Krea2/Women"
@pytest.mark.asyncio
async def test_forget_folder_accepts_get_with_query_params():
"""Forgetting is a state change, but the extension is GET-only by convention."""
handler, service = _make_handler({"success": True, "folder": "Krea2/Women"})
response = await handler.forget_folder(
FakeGetRequest({"folder": "Krea2/Women"})
)
assert response.status == 200
assert service.received_folder == "Krea2/Women"
@pytest.mark.asyncio
async def test_forget_folder_requires_a_folder():
handler, service = _make_handler({"success": True})
response = await handler.forget_folder(FakeRequest({}))
assert response.status == 400
assert json.loads(response.text)["success"] is False
assert service.received_folder is None
@pytest.mark.asyncio
async def test_forget_folder_existing_directory_maps_to_409():
"""The tree the client rendered is stale: the folder must be deleted."""
handler, _service = _make_handler(
{
"success": False,
"code": "exists",
"error": "The folder still exists on disk",
}
)
response = await handler.forget_folder(FakeRequest({"folder": "Krea2/Women"}))
assert response.status == 409
+63 -3
View File
@@ -400,14 +400,74 @@ async def test_delete_folder_rejects_paths_outside_roots(tmp_path: Path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_folder_reports_missing_directory(tmp_path: Path): async def test_delete_folder_forgets_a_directory_that_is_already_gone(tmp_path: Path):
"""There is nothing to delete, but the sidebar node can still be cleared.
A folder moved with a file manager leaves its records behind; answering
"Folder no longer exists" left the ghost with no way out of the UI (#1145).
"""
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
gone = tmp_path / "gone"
result = await service.delete_folder(str(gone))
assert result["success"] is True
assert result["code"] == "missing"
assert result["already_missing"] is True
assert result["folder"] == "gone"
assert scanner.removed_folders == ["gone"]
assert scanner.removed_folder_paths == [str(gone)]
@pytest.mark.asyncio
async def test_delete_folder_dry_run_still_reports_a_missing_directory(tmp_path: Path):
"""The probe stays a pure read — it is what makes the dialog offer the removal."""
scanner = FakeScanner([tmp_path]) scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora") service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(tmp_path / "gone")) result = await service.delete_folder(str(tmp_path / "gone"), dry_run=True)
assert result["success"] is False assert result["success"] is False
assert "no longer exists" in result["error"] assert result["code"] == "missing"
assert scanner.removed_folders == []
@pytest.mark.asyncio
async def test_forget_folder_drops_the_records_of_a_gone_directory(tmp_path: Path):
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.forget_folder("Krea2/Women")
assert result["success"] is True
assert result["folder"] == "Krea2/Women"
# No absolute path: the same relative folder may exist under several roots,
# and the scanner keeps an entry another root still holds.
assert scanner.removed_folders == ["Krea2/Women"]
assert scanner.removed_folder_paths == [None]
@pytest.mark.asyncio
async def test_forget_folder_refuses_while_the_directory_still_exists(tmp_path: Path):
"""Then the caller wants a delete, not a cleanup."""
(tmp_path / "Krea2" / "Women").mkdir(parents=True)
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.forget_folder("Krea2/Women")
assert result["success"] is False
assert result["code"] == "exists"
assert scanner.removed_folders == []
@pytest.mark.asyncio
async def test_forget_folder_requires_a_folder(tmp_path: Path):
service = ModelMoveService(FakeScanner([tmp_path]), "lora")
assert (await service.forget_folder(""))["success"] is False
assert (await service.forget_folder("../outside"))["success"] is False
@pytest.mark.asyncio @pytest.mark.asyncio