diff --git a/locales/de.json b/locales/de.json index 03fa89ff..27b97ab8 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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.", "missingTitle": "Ordner nicht gefunden", "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", "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", @@ -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.", "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.", + "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.", "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." diff --git a/locales/en.json b/locales/en.json index 968e7594..d2517d4a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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.", "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", - "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", "symlinkMessage": "Every copy of this folder is a symbolic link; remove the link or its target outside LoRA Manager.", "protectedTitle": "Folder is protected", @@ -1440,6 +1441,8 @@ "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", "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.", "successMulti": "Folder \"{name}\" deleted from {count} model roots", "partial": "Deleted {count} of {total} copies — {failed} could not be deleted. Refresh the sidebar and retry." diff --git a/locales/es.json b/locales/es.json index a5350f26..f4bb4b5c 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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.", "missingTitle": "Carpeta no encontrada", "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", "symlinkMessage": "Todas las copias de esta carpeta son enlaces simbólicos; elimina el enlace o su destino fuera de LoRA Manager.", "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.", "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.", + "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.", "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." diff --git a/locales/fr.json b/locales/fr.json index 8421fd05..5dfc1098 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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.", "missingTitle": "Dossier introuvable", "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", "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é", @@ -1440,6 +1441,8 @@ "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", "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.", "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." diff --git a/locales/he.json b/locales/he.json index b3e0fba4..aa48ee5b 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1419,6 +1419,7 @@ "notEmptyMessageExcluded": "בתיקייה זו עדיין יש {count} קובצי מודלים, שמהם {excluded} מוחרגים מהספרייה. בטל את ההחרגה ב«ניהול מודלים מוחרגים» ומחק אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.", "missingTitle": "התיקייה לא נמצאה", "missingMessage": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.", + "removeFromList": "[TODO: Translate] Remove from list", "symlinkTitle": "התיקייה היא קישור סמלי", "symlinkMessage": "כל העותקים של תיקייה זו הם קישורים סמליים; מחק את הקישור או את היעד שלו מחוץ ל-LoRA Manager.", "protectedTitle": "תיקייה מוגנת", @@ -1440,6 +1441,8 @@ "busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.", "unsupported": "מחיקת תיקיות אינה נתמכת בדף זה", "missing": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.", + "forgotten": "[TODO: Translate] Folder \"{name}\" removed from the sidebar", + "exists": "[TODO: Translate] The folder is back on disk. Delete it instead.", "symlink": "תיקייה זו היא קישור סמלי; לא ניתן למחוק אותה מסרגל הצד.", "successMulti": "התיקייה \"{name}\" נמחקה מ-{count} שורשי מודלים", "partial": "נמחקו {count} מתוך {total} עותקים — {failed} לא נמחקו. רענן את סרגל הצד ונסה שוב." diff --git a/locales/ja.json b/locales/ja.json index bd26aa41..c78a06bd 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1419,6 +1419,7 @@ "notEmptyMessageExcluded": "このフォルダにはまだ {count} 個のモデルファイルがあり、そのうち {excluded} 個はライブラリから除外されています。「除外モデルを管理」で除外を解除してから削除してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。", "missingTitle": "フォルダが見つかりません", "missingMessage": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。", + "removeFromList": "[TODO: Translate] Remove from list", "symlinkTitle": "フォルダがシンボリックリンクです", "symlinkMessage": "このフォルダのすべてのコピーがシンボリックリンクです。LoRA Manager の外でリンクまたはそのリンク先を削除してください。", "protectedTitle": "フォルダは保護されています", @@ -1440,6 +1441,8 @@ "busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。", "unsupported": "このページではフォルダを削除できません", "missing": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。", + "forgotten": "[TODO: Translate] Folder \"{name}\" removed from the sidebar", + "exists": "[TODO: Translate] The folder is back on disk. Delete it instead.", "symlink": "このフォルダはシンボリックリンクです。サイドバーからは削除できません。", "successMulti": "{count} 個のモデルルートからフォルダ \"{name}\" を削除しました", "partial": "{total} 個のコピーのうち {count} 個を削除しました —— {failed} 個は削除できませんでした。サイドバーを再読み込みしてからもう一度お試しください。" diff --git a/locales/ko.json b/locales/ko.json index cc9d47c4..148f1cd3 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1419,6 +1419,7 @@ "notEmptyMessageExcluded": "이 폴더에는 아직 모델 파일이 {count}개 있으며, 그중 {excluded}개는 라이브러리에서 제외되어 있습니다. '제외된 모델 관리'에서 제외를 해제한 뒤 먼저 삭제하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.", "missingTitle": "폴더를 찾을 수 없습니다", "missingMessage": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.", + "removeFromList": "[TODO: Translate] Remove from list", "symlinkTitle": "폴더가 심볼릭 링크입니다", "symlinkMessage": "이 폴더의 모든 복사본이 심볼릭 링크입니다. LoRA Manager 외부에서 링크 또는 그 대상을 삭제하세요.", "protectedTitle": "보호된 폴더", @@ -1440,6 +1441,8 @@ "busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.", "unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다", "missing": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.", + "forgotten": "[TODO: Translate] Folder \"{name}\" removed from the sidebar", + "exists": "[TODO: Translate] The folder is back on disk. Delete it instead.", "symlink": "이 폴더는 심볼릭 링크이므로 사이드바에서 삭제할 수 없습니다.", "successMulti": "모델 루트 {count}개에서 \"{name}\" 폴더를 삭제했습니다", "partial": "복사본 {total}개 중 {count}개를 삭제했습니다 —— {failed}개는 삭제하지 못했습니다. 사이드바를 새로 고친 후 다시 시도하세요." diff --git a/locales/ru.json b/locales/ru.json index 5feceecf..c9d6d0a2 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1419,6 +1419,7 @@ "notEmptyMessageExcluded": "В этой папке ещё есть {count} файл(ов) модели, из них {excluded} исключены из библиотеки. Снимите исключение в разделе «Управление исключёнными моделями» и удалите их — удаление папки никогда не затрагивает файлы моделей.", "missingTitle": "Папка не найдена", "missingMessage": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.", + "removeFromList": "[TODO: Translate] Remove from list", "symlinkTitle": "Папка является символической ссылкой", "symlinkMessage": "Все копии этой папки являются символическими ссылками; удалите ссылку или её цель вне LoRA Manager.", "protectedTitle": "Папка защищена", @@ -1440,6 +1441,8 @@ "busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.", "unsupported": "Удаление папок не поддерживается на этой странице", "missing": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.", + "forgotten": "[TODO: Translate] Folder \"{name}\" removed from the sidebar", + "exists": "[TODO: Translate] The folder is back on disk. Delete it instead.", "symlink": "Эта папка является символической ссылкой; её нельзя удалить из боковой панели.", "successMulti": "Папка \"{name}\" удалена из {count} корневых папок моделей", "partial": "Удалено {count} из {total} копий — не удалось удалить {failed}. Обновите боковую панель и повторите попытку." diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 5d802ca7..ccdfa41c 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1419,6 +1419,7 @@ "notEmptyMessageExcluded": "该文件夹中仍有 {count} 个模型文件,其中 {excluded} 个已从模型库中排除。请先在“管理已排除的模型”中取消排除并删除它们 —— 删除文件夹不会级联删除模型文件。", "missingTitle": "未找到文件夹", "missingMessage": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。", + "removeFromList": "[TODO: Translate] Remove from list", "symlinkTitle": "文件夹是符号链接", "symlinkMessage": "该文件夹的每一份副本都是符号链接;请在 LoRA Manager 之外删除该链接或其指向的目标。", "protectedTitle": "文件夹受保护", @@ -1440,6 +1441,8 @@ "busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。", "unsupported": "此页面不支持删除文件夹", "missing": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。", + "forgotten": "[TODO: Translate] Folder \"{name}\" removed from the sidebar", + "exists": "[TODO: Translate] The folder is back on disk. Delete it instead.", "symlink": "该文件夹是符号链接,无法从侧边栏中删除。", "successMulti": "已从 {count} 个模型根目录中删除文件夹 \"{name}\"", "partial": "已删除 {total} 份副本中的 {count} 份 —— {failed} 份无法删除。请刷新侧边栏后重试。" diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 93127c59..f150cf9e 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1419,6 +1419,7 @@ "notEmptyMessageExcluded": "該資料夾中仍有 {count} 個模型檔案,其中 {excluded} 個已從模型庫中排除。請先在「管理已排除的模型」中取消排除並刪除它們 —— 刪除資料夾不會串聯刪除模型檔案。", "missingTitle": "未找到資料夾", "missingMessage": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。", + "removeFromList": "[TODO: Translate] Remove from list", "symlinkTitle": "資料夾是符號連結", "symlinkMessage": "該資料夾的每一份副本都是符號連結;請在 LoRA Manager 之外刪除該連結或其指向的目標。", "protectedTitle": "資料夾受保護", @@ -1440,6 +1441,8 @@ "busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。", "unsupported": "此頁面不支援刪除資料夾", "missing": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。", + "forgotten": "[TODO: Translate] Folder \"{name}\" removed from the sidebar", + "exists": "[TODO: Translate] The folder is back on disk. Delete it instead.", "symlink": "該資料夾是符號連結,無法從側邊欄中刪除。", "successMulti": "已從 {count} 個模型根目錄中刪除資料夾 \"{name}\"", "partial": "已刪除 {total} 份副本中的 {count} 份 —— {failed} 份無法刪除。請重新整理側邊欄後再試。" diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index f7a81633..b1cf1923 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -2629,6 +2629,39 @@ class ModelMoveHandler: self._logger.error("Error deleting folder: %s", exc, exc_info=True) 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: try: data = await request.json() @@ -3701,6 +3734,7 @@ class ModelHandlerSet: "create_folder": self.move.create_folder, "resolve_folder": self.move.resolve_folder, "delete_folder": self.move.delete_folder, + "forget_folder": self.move.forget_folder, "rename_folder": self.move.rename_folder, "auto_organize_models": self.auto_organize.auto_organize_models, "get_auto_organize_progress": self.auto_organize.get_auto_organize_progress, diff --git a/py/routes/model_route_registrar.py b/py/routes/model_route_registrar.py index 8955d9d9..09e408b7 100644 --- a/py/routes/model_route_registrar.py +++ b/py/routes/model_route_registrar.py @@ -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}/create-folder", "create_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("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), diff --git a/py/services/model_file_service.py b/py/services/model_file_service.py index d25edbb6..fc5fec27 100644 --- a/py/services/model_file_service.py +++ b/py/services/model_file_service.py @@ -589,6 +589,33 @@ class ModelMoveService: return rel.replace(os.sep, "/") 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]: """Map a library-relative folder onto the directories it stands for. @@ -685,11 +712,29 @@ class ModelMoveService: } if not os.path.isdir(absolute_path): # `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 { + "success": False, + "code": "missing", + "error": "Folder no longer exists", + } + + relative_folder = self._calculate_relative_folder(absolute_path) + await self._forget_folder(relative_folder, absolute_path) return { - "success": False, + "success": True, "code": "missing", - "error": "Folder no longer exists", + "already_missing": True, + "folder": relative_folder, + "folder_path": absolute_path.replace(os.sep, "/"), } if self._is_model_root(absolute_path): diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index cba60a6e..ee7400c1 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -85,6 +85,10 @@ export function getApiEndpoints(modelType) { moveBulk: `/api/lm/${modelType}/move_models_bulk`, createFolder: `/api/lm/${modelType}/create-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`, // 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 diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index a5757831..f8d176a7 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1440,7 +1440,9 @@ export class BaseModelApiClient { * Only model-free folders can be removed; the backend answers with a 409 * `not_empty`/`busy` conflict otherwise. Those codes are attached to the * 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 {{dryRun?: boolean}} [options] @@ -1468,6 +1470,38 @@ export class BaseModelApiClient { 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} `{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. * diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index 34da5d2a..0ce9b6e8 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -1734,6 +1734,8 @@ export class SidebarManager { showToast('sidebar.deleteFolderResult.busy', {}, 'warning'); } else if (error?.code === 'missing') { showToast('sidebar.deleteFolderResult.missing', {}, 'warning'); + } else if (error?.code === 'exists') { + showToast('sidebar.deleteFolderResult.exists', {}, 'warning'); } else if (error?.code === 'unresolved') { showToast('sidebar.folderResult.unresolved', {}, 'error'); } else { @@ -1806,6 +1808,8 @@ export class SidebarManager { ); } else { this._pendingDeleteFolderPath = null; + confirmBtn.style.display = 'none'; + confirmBtn.disabled = true; if (state === 'busy') { title.textContent = translate( 'sidebar.deleteFolderModal.busyTitle', {}, 'A deletion is still pending' @@ -1820,7 +1824,16 @@ export class SidebarManager { ); message.textContent = translate( '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') { title.textContent = translate( @@ -1849,8 +1862,6 @@ export class SidebarManager { || 'This folder still contains models. Delete or move them first.' ); } - confirmBtn.style.display = 'none'; - confirmBtn.disabled = true; } if (checking) { @@ -1943,10 +1954,14 @@ export class SidebarManager { ); if (!selected) { 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( - empty - ? 'sidebar.deleteFolderResult.missing' - : 'sidebar.deleteFolderResult.symlink', + 'sidebar.deleteFolderResult.symlink', {}, 'warning' ); @@ -1967,7 +1982,15 @@ export class SidebarManager { await this.refresh(); 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 // the same 20s undo affordance the model delete flow uses. 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. * diff --git a/tests/frontend/components/sidebarManager.folderManagement.test.js b/tests/frontend/components/sidebarManager.folderManagement.test.js index b7098cc3..b1389ca6 100644 --- a/tests/frontend/components/sidebarManager.folderManagement.test.js +++ b/tests/frontend/components/sidebarManager.folderManagement.test.js @@ -70,6 +70,7 @@ function createApiClient(overrides = {}) { dir_count: 0, restorable: true, }), + forgetFolder: vi.fn().mockResolvedValue({ success: true, folder: 'empty' }), renameFolder: vi.fn().mockResolvedValue({ success: true, renamed: true, @@ -1294,7 +1295,7 @@ describe('SidebarManager folder deletion', () => { 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({ resolveFolder: vi.fn().mockResolvedValue({ success: true, folder: 'removed', candidates: [], @@ -1307,9 +1308,85 @@ describe('SidebarManager folder deletion', () => { const modal = document.getElementById('deleteFolderModal'); expect(modal.dataset.state).toBe('missing'); - expect(confirmBtn().style.display).toBe('none'); - expect(manager._pendingDeleteFolderPath).toBeNull(); + // Nothing to delete, so the delete probe never runs and no path is guessed... 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 () => { diff --git a/tests/routes/test_model_move_handler.py b/tests/routes/test_model_move_handler.py index 39e3371a..6c41a952 100644 --- a/tests/routes/test_model_move_handler.py +++ b/tests/routes/test_model_move_handler.py @@ -23,6 +23,10 @@ class FakeMoveService: self.received_dry_run = dry_run 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): self.received_path = folder_path self.received_new_name = new_name @@ -34,13 +38,26 @@ class FakeMoveService: class FakeRequest: + method = "POST" + def __init__(self, payload): self._payload = payload + # A real aiohttp request always carries a query string too. + self.query = {} async def json(self): 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: """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 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 diff --git a/tests/services/test_model_file_service.py b/tests/services/test_model_file_service.py index 0e25b2c4..47291a5b 100644 --- a/tests/services/test_model_file_service.py +++ b/tests/services/test_model_file_service.py @@ -400,14 +400,74 @@ async def test_delete_folder_rejects_paths_outside_roots(tmp_path: Path): @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]) 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 "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