mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
feat(sidebar): delete folders from the sidebar (#999)
Folders created from the sidebar had no in-app way back out: the only
removal path was to leave ComfyUI, delete the directory by hand and
rescan. A typo'd folder also polluted the move/download destination
picker permanently, since it reads the same all_folders source.
Adds POST /api/lm/{prefix}/delete-folder, restricted to directories
whose subtree holds no model weight files — a folder-level cascade would
bypass the per-model lifecycle bookkeeping (metadata sidecars, previews,
cache entries, pending-delete staging, recipe references). The service
walks the directory itself instead of trusting the possibly stale cache,
reports what it would remove (models / files / subfolders / symlinks),
and refuses library roots, top-level symlinks (shutil.rmtree rejects
those) and folders holding a staged delete, whose manifest would be
invalidated by the move. Symbolic links inside the subtree are counted
but never followed.
ModelScanner.remove_known_folder mirrors add_known_folder: the removed
subtree leaves all_folders while ancestors are kept (every recorded
ancestor exists on disk in its own right), stale cache entries under the
prefix are purged and the folder list recomputed. The handler broadcasts
models_changed so destination pickers drop the folder too.
The sidebar entry is a destructive context-menu item. The modal opens in
a confirm state for model-free folders and an explanatory one when the
subtree still holds models, decided from the models-only set that already
dims empty nodes; a stale tree is caught by the 409 not_empty/busy
conflict. Truly empty folders get the existing 20s undo affordance,
implemented by re-creating the directory.
This commit is contained in:
@@ -1263,6 +1263,26 @@
|
|||||||
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
|
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
|
||||||
"noRoot": "[TODO: Translate] No model root is configured"
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
},
|
},
|
||||||
|
"deleteFolder": "[TODO: Translate] Delete folder",
|
||||||
|
"deleteFolderModal": {
|
||||||
|
"title": "[TODO: Translate] Delete folder?",
|
||||||
|
"message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.",
|
||||||
|
"folderLabel": "[TODO: Translate] Folder",
|
||||||
|
"emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.",
|
||||||
|
"notEmptyTitle": "[TODO: Translate] Folder is not empty",
|
||||||
|
"notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.",
|
||||||
|
"confirm": "[TODO: Translate] Delete folder"
|
||||||
|
},
|
||||||
|
"deleteFolderResult": {
|
||||||
|
"success": "[TODO: Translate] Folder \"{name}\" deleted",
|
||||||
|
"successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)",
|
||||||
|
"restored": "[TODO: Translate] Folder restored",
|
||||||
|
"failed": "[TODO: Translate] Failed to delete folder: {message}",
|
||||||
|
"notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.",
|
||||||
|
"busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
||||||
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
|
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
|
||||||
"moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.",
|
"moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.",
|
||||||
|
|||||||
@@ -1263,6 +1263,26 @@
|
|||||||
"unsupported": "Folder creation is not supported on this page",
|
"unsupported": "Folder creation is not supported on this page",
|
||||||
"noRoot": "No model root is configured"
|
"noRoot": "No model root is configured"
|
||||||
},
|
},
|
||||||
|
"deleteFolder": "Delete folder",
|
||||||
|
"deleteFolderModal": {
|
||||||
|
"title": "Delete folder?",
|
||||||
|
"message": "The folder and everything inside it will be permanently removed from disk.",
|
||||||
|
"folderLabel": "Folder",
|
||||||
|
"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.",
|
||||||
|
"confirm": "Delete folder"
|
||||||
|
},
|
||||||
|
"deleteFolderResult": {
|
||||||
|
"success": "Folder \"{name}\" deleted",
|
||||||
|
"successWithFiles": "Folder \"{name}\" deleted along with {count} other item(s)",
|
||||||
|
"restored": "Folder restored",
|
||||||
|
"failed": "Failed to delete folder: {message}",
|
||||||
|
"notEmpty": "This folder still contains models. 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"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "Unable to determine destination path for move.",
|
"unableToResolveRoot": "Unable to determine destination path for move.",
|
||||||
"moveUnsupported": "Move is not supported for this item.",
|
"moveUnsupported": "Move is not supported for this item.",
|
||||||
|
|||||||
@@ -1263,6 +1263,26 @@
|
|||||||
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
|
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
|
||||||
"noRoot": "[TODO: Translate] No model root is configured"
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
},
|
},
|
||||||
|
"deleteFolder": "[TODO: Translate] Delete folder",
|
||||||
|
"deleteFolderModal": {
|
||||||
|
"title": "[TODO: Translate] Delete folder?",
|
||||||
|
"message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.",
|
||||||
|
"folderLabel": "[TODO: Translate] Folder",
|
||||||
|
"emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.",
|
||||||
|
"notEmptyTitle": "[TODO: Translate] Folder is not empty",
|
||||||
|
"notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.",
|
||||||
|
"confirm": "[TODO: Translate] Delete folder"
|
||||||
|
},
|
||||||
|
"deleteFolderResult": {
|
||||||
|
"success": "[TODO: Translate] Folder \"{name}\" deleted",
|
||||||
|
"successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)",
|
||||||
|
"restored": "[TODO: Translate] Folder restored",
|
||||||
|
"failed": "[TODO: Translate] Failed to delete folder: {message}",
|
||||||
|
"notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.",
|
||||||
|
"busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
||||||
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
|
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
|
||||||
"moveUnsupported": "El movimiento no es compatible con este elemento.",
|
"moveUnsupported": "El movimiento no es compatible con este elemento.",
|
||||||
|
|||||||
@@ -1263,6 +1263,26 @@
|
|||||||
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
|
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
|
||||||
"noRoot": "[TODO: Translate] No model root is configured"
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
},
|
},
|
||||||
|
"deleteFolder": "[TODO: Translate] Delete folder",
|
||||||
|
"deleteFolderModal": {
|
||||||
|
"title": "[TODO: Translate] Delete folder?",
|
||||||
|
"message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.",
|
||||||
|
"folderLabel": "[TODO: Translate] Folder",
|
||||||
|
"emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.",
|
||||||
|
"notEmptyTitle": "[TODO: Translate] Folder is not empty",
|
||||||
|
"notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.",
|
||||||
|
"confirm": "[TODO: Translate] Delete folder"
|
||||||
|
},
|
||||||
|
"deleteFolderResult": {
|
||||||
|
"success": "[TODO: Translate] Folder \"{name}\" deleted",
|
||||||
|
"successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)",
|
||||||
|
"restored": "[TODO: Translate] Folder restored",
|
||||||
|
"failed": "[TODO: Translate] Failed to delete folder: {message}",
|
||||||
|
"notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.",
|
||||||
|
"busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
||||||
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
|
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
|
||||||
"moveUnsupported": "Le déplacement n'est pas pris en charge pour cet élément.",
|
"moveUnsupported": "Le déplacement n'est pas pris en charge pour cet élément.",
|
||||||
|
|||||||
@@ -1263,6 +1263,26 @@
|
|||||||
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
|
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
|
||||||
"noRoot": "[TODO: Translate] No model root is configured"
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
},
|
},
|
||||||
|
"deleteFolder": "[TODO: Translate] Delete folder",
|
||||||
|
"deleteFolderModal": {
|
||||||
|
"title": "[TODO: Translate] Delete folder?",
|
||||||
|
"message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.",
|
||||||
|
"folderLabel": "[TODO: Translate] Folder",
|
||||||
|
"emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.",
|
||||||
|
"notEmptyTitle": "[TODO: Translate] Folder is not empty",
|
||||||
|
"notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.",
|
||||||
|
"confirm": "[TODO: Translate] Delete folder"
|
||||||
|
},
|
||||||
|
"deleteFolderResult": {
|
||||||
|
"success": "[TODO: Translate] Folder \"{name}\" deleted",
|
||||||
|
"successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)",
|
||||||
|
"restored": "[TODO: Translate] Folder restored",
|
||||||
|
"failed": "[TODO: Translate] Failed to delete folder: {message}",
|
||||||
|
"notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.",
|
||||||
|
"busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
||||||
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
|
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
|
||||||
"moveUnsupported": "העברה אינה נתמכת עבור פריט זה.",
|
"moveUnsupported": "העברה אינה נתמכת עבור פריט זה.",
|
||||||
|
|||||||
@@ -1263,6 +1263,26 @@
|
|||||||
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
|
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
|
||||||
"noRoot": "[TODO: Translate] No model root is configured"
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
},
|
},
|
||||||
|
"deleteFolder": "[TODO: Translate] Delete folder",
|
||||||
|
"deleteFolderModal": {
|
||||||
|
"title": "[TODO: Translate] Delete folder?",
|
||||||
|
"message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.",
|
||||||
|
"folderLabel": "[TODO: Translate] Folder",
|
||||||
|
"emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.",
|
||||||
|
"notEmptyTitle": "[TODO: Translate] Folder is not empty",
|
||||||
|
"notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.",
|
||||||
|
"confirm": "[TODO: Translate] Delete folder"
|
||||||
|
},
|
||||||
|
"deleteFolderResult": {
|
||||||
|
"success": "[TODO: Translate] Folder \"{name}\" deleted",
|
||||||
|
"successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)",
|
||||||
|
"restored": "[TODO: Translate] Folder restored",
|
||||||
|
"failed": "[TODO: Translate] Failed to delete folder: {message}",
|
||||||
|
"notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.",
|
||||||
|
"busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
||||||
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "移動先のパスを特定できません。",
|
"unableToResolveRoot": "移動先のパスを特定できません。",
|
||||||
"moveUnsupported": "この項目の移動はサポートされていません。",
|
"moveUnsupported": "この項目の移動はサポートされていません。",
|
||||||
|
|||||||
@@ -1263,6 +1263,26 @@
|
|||||||
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
|
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
|
||||||
"noRoot": "[TODO: Translate] No model root is configured"
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
},
|
},
|
||||||
|
"deleteFolder": "[TODO: Translate] Delete folder",
|
||||||
|
"deleteFolderModal": {
|
||||||
|
"title": "[TODO: Translate] Delete folder?",
|
||||||
|
"message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.",
|
||||||
|
"folderLabel": "[TODO: Translate] Folder",
|
||||||
|
"emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.",
|
||||||
|
"notEmptyTitle": "[TODO: Translate] Folder is not empty",
|
||||||
|
"notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.",
|
||||||
|
"confirm": "[TODO: Translate] Delete folder"
|
||||||
|
},
|
||||||
|
"deleteFolderResult": {
|
||||||
|
"success": "[TODO: Translate] Folder \"{name}\" deleted",
|
||||||
|
"successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)",
|
||||||
|
"restored": "[TODO: Translate] Folder restored",
|
||||||
|
"failed": "[TODO: Translate] Failed to delete folder: {message}",
|
||||||
|
"notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.",
|
||||||
|
"busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
||||||
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
|
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
|
||||||
"moveUnsupported": "이 항목은 이동을 지원하지 않습니다.",
|
"moveUnsupported": "이 항목은 이동을 지원하지 않습니다.",
|
||||||
|
|||||||
@@ -1263,6 +1263,26 @@
|
|||||||
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
|
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
|
||||||
"noRoot": "[TODO: Translate] No model root is configured"
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
},
|
},
|
||||||
|
"deleteFolder": "[TODO: Translate] Delete folder",
|
||||||
|
"deleteFolderModal": {
|
||||||
|
"title": "[TODO: Translate] Delete folder?",
|
||||||
|
"message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.",
|
||||||
|
"folderLabel": "[TODO: Translate] Folder",
|
||||||
|
"emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.",
|
||||||
|
"notEmptyTitle": "[TODO: Translate] Folder is not empty",
|
||||||
|
"notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.",
|
||||||
|
"confirm": "[TODO: Translate] Delete folder"
|
||||||
|
},
|
||||||
|
"deleteFolderResult": {
|
||||||
|
"success": "[TODO: Translate] Folder \"{name}\" deleted",
|
||||||
|
"successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)",
|
||||||
|
"restored": "[TODO: Translate] Folder restored",
|
||||||
|
"failed": "[TODO: Translate] Failed to delete folder: {message}",
|
||||||
|
"notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.",
|
||||||
|
"busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
||||||
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
|
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
|
||||||
"moveUnsupported": "Перемещение этого элемента не поддерживается.",
|
"moveUnsupported": "Перемещение этого элемента не поддерживается.",
|
||||||
|
|||||||
@@ -1263,6 +1263,26 @@
|
|||||||
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
|
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
|
||||||
"noRoot": "[TODO: Translate] No model root is configured"
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
},
|
},
|
||||||
|
"deleteFolder": "[TODO: Translate] Delete folder",
|
||||||
|
"deleteFolderModal": {
|
||||||
|
"title": "[TODO: Translate] Delete folder?",
|
||||||
|
"message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.",
|
||||||
|
"folderLabel": "[TODO: Translate] Folder",
|
||||||
|
"emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.",
|
||||||
|
"notEmptyTitle": "[TODO: Translate] Folder is not empty",
|
||||||
|
"notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.",
|
||||||
|
"confirm": "[TODO: Translate] Delete folder"
|
||||||
|
},
|
||||||
|
"deleteFolderResult": {
|
||||||
|
"success": "[TODO: Translate] Folder \"{name}\" deleted",
|
||||||
|
"successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)",
|
||||||
|
"restored": "[TODO: Translate] Folder restored",
|
||||||
|
"failed": "[TODO: Translate] Failed to delete folder: {message}",
|
||||||
|
"notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.",
|
||||||
|
"busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
||||||
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "无法确定移动的目标路径。",
|
"unableToResolveRoot": "无法确定移动的目标路径。",
|
||||||
"moveUnsupported": "此条目不支持移动。",
|
"moveUnsupported": "此条目不支持移动。",
|
||||||
|
|||||||
@@ -1263,6 +1263,26 @@
|
|||||||
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
|
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
|
||||||
"noRoot": "[TODO: Translate] No model root is configured"
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
},
|
},
|
||||||
|
"deleteFolder": "[TODO: Translate] Delete folder",
|
||||||
|
"deleteFolderModal": {
|
||||||
|
"title": "[TODO: Translate] Delete folder?",
|
||||||
|
"message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.",
|
||||||
|
"folderLabel": "[TODO: Translate] Folder",
|
||||||
|
"emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.",
|
||||||
|
"notEmptyTitle": "[TODO: Translate] Folder is not empty",
|
||||||
|
"notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.",
|
||||||
|
"confirm": "[TODO: Translate] Delete folder"
|
||||||
|
},
|
||||||
|
"deleteFolderResult": {
|
||||||
|
"success": "[TODO: Translate] Folder \"{name}\" deleted",
|
||||||
|
"successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)",
|
||||||
|
"restored": "[TODO: Translate] Folder restored",
|
||||||
|
"failed": "[TODO: Translate] Failed to delete folder: {message}",
|
||||||
|
"notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.",
|
||||||
|
"busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
|
"unsupported": "[TODO: Translate] Folder deletion is not supported on this page",
|
||||||
|
"noRoot": "[TODO: Translate] No model root is configured"
|
||||||
|
},
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "無法確定移動的目標路徑。",
|
"unableToResolveRoot": "無法確定移動的目標路徑。",
|
||||||
"moveUnsupported": "此項目不支援移動。",
|
"moveUnsupported": "此項目不支援移動。",
|
||||||
|
|||||||
@@ -2499,6 +2499,37 @@ class ModelMoveHandler:
|
|||||||
self._logger.error("Error creating folder: %s", exc, exc_info=True)
|
self._logger.error("Error creating 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 delete_folder(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
folder_path = data.get("folder_path")
|
||||||
|
if not folder_path:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Folder path is required"}, status=400
|
||||||
|
)
|
||||||
|
dry_run = bool(data.get("dry_run"))
|
||||||
|
result = await self._move_service.delete_folder(
|
||||||
|
folder_path, dry_run=dry_run
|
||||||
|
)
|
||||||
|
if result.get("success"):
|
||||||
|
if not dry_run:
|
||||||
|
_broadcast_models_changed()
|
||||||
|
return web.json_response(result, status=200)
|
||||||
|
|
||||||
|
# "not_empty" / "busy" are conflicts between the tree the client
|
||||||
|
# rendered and the on-disk truth; everything else is a bad request.
|
||||||
|
code = result.get("code")
|
||||||
|
status = 409 if code in ("not_empty", "busy") else 400
|
||||||
|
return web.json_response(result, status=status)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error deleting folder: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def move_model(self, request: web.Request) -> web.Response:
|
async def move_model(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
@@ -3450,6 +3481,7 @@ class ModelHandlerSet:
|
|||||||
"move_model": self.move.move_model,
|
"move_model": self.move.move_model,
|
||||||
"move_models_bulk": self.move.move_models_bulk,
|
"move_models_bulk": self.move.move_models_bulk,
|
||||||
"create_folder": self.move.create_folder,
|
"create_folder": self.move.create_folder,
|
||||||
|
"delete_folder": self.move.delete_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,
|
||||||
"get_model_notes": self.query.get_model_notes,
|
"get_model_notes": self.query.get_model_notes,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/{prefix}/move_model", "move_model"),
|
RouteDefinition("POST", "/api/lm/{prefix}/move_model", "move_model"),
|
||||||
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("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"),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ import asyncio
|
|||||||
import fnmatch
|
import fnmatch
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
import shutil
|
||||||
from typing import Any, Dict, List, Optional, Sequence, Set
|
from typing import Any, Dict, List, Optional, Sequence, Set
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
|
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
|
||||||
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE, MODEL_FILE_EXTENSIONS
|
||||||
from ..services.settings_manager import get_settings_manager
|
from ..services.settings_manager import get_settings_manager
|
||||||
from ..services.model_lifecycle_service import _require_path_in_library_roots
|
from ..services.model_lifecycle_service import _require_path_in_library_roots
|
||||||
|
from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -536,6 +538,180 @@ class ModelMoveService:
|
|||||||
return rel.replace(os.sep, "/")
|
return rel.replace(os.sep, "/")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
async def delete_folder(self, folder_path: str, dry_run: bool = False) -> Dict[str, Any]:
|
||||||
|
"""Delete a model-free directory inside the model library roots.
|
||||||
|
|
||||||
|
Only directories whose subtree holds no model weight files can be
|
||||||
|
removed: a folder-level cascade would bypass the per-model lifecycle
|
||||||
|
bookkeeping (metadata sidecars, previews, cache entries, pending-delete
|
||||||
|
staging and recipe references), so it is deliberately refused. Leftover
|
||||||
|
non-model files (stray previews, sidecars, ``.bak`` files) are reported
|
||||||
|
in the manifest before they are removed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_path: Absolute path of the directory to remove (business
|
||||||
|
path — symlinks are not resolved)
|
||||||
|
dry_run: When true, only report what would be removed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with the success flag plus a removal manifest
|
||||||
|
(``model_count``/``file_count``/``dir_count``/``symlink_count``/
|
||||||
|
``total_bytes``/``restorable``) on success.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not folder_path or not str(folder_path).strip():
|
||||||
|
return {"success": False, "error": "Folder path is required"}
|
||||||
|
|
||||||
|
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
|
||||||
|
|
||||||
|
absolute_path = os.path.abspath(folder_path)
|
||||||
|
if os.path.islink(absolute_path):
|
||||||
|
# shutil.rmtree refuses symlinked roots, and silently deleting
|
||||||
|
# the link (leaving the real directory behind) is a separate
|
||||||
|
# decision we do not make here.
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Symlinked folders cannot be deleted",
|
||||||
|
}
|
||||||
|
if not os.path.isdir(absolute_path):
|
||||||
|
return {"success": False, "error": "Folder no longer exists"}
|
||||||
|
|
||||||
|
if self._is_model_root(absolute_path):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "The library root itself cannot be deleted",
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest = self._collect_folder_manifest(absolute_path)
|
||||||
|
|
||||||
|
if manifest["pending_delete_job"]:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"code": "busy",
|
||||||
|
"error": (
|
||||||
|
"A staged delete is still pending inside this folder; "
|
||||||
|
"wait for the undo window to expire"
|
||||||
|
),
|
||||||
|
"manifest": manifest,
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifest["model_count"] > 0:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"code": "not_empty",
|
||||||
|
"error": (
|
||||||
|
f"Folder still contains {manifest['model_count']} model "
|
||||||
|
"file(s); delete or move them first"
|
||||||
|
),
|
||||||
|
"manifest": manifest,
|
||||||
|
}
|
||||||
|
|
||||||
|
relative_folder = self._calculate_relative_folder(absolute_path)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"dry_run": True,
|
||||||
|
"folder_path": absolute_path.replace(os.sep, "/"),
|
||||||
|
"folder": relative_folder,
|
||||||
|
**manifest,
|
||||||
|
}
|
||||||
|
|
||||||
|
shutil.rmtree(absolute_path)
|
||||||
|
|
||||||
|
await self._forget_folder(relative_folder)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"dry_run": False,
|
||||||
|
"folder_path": absolute_path.replace(os.sep, "/"),
|
||||||
|
"folder": relative_folder,
|
||||||
|
**manifest,
|
||||||
|
}
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"success": False, "error": str(exc)}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Error deleting folder: {exc}", exc_info=True)
|
||||||
|
return {"success": False, "error": str(exc)}
|
||||||
|
|
||||||
|
def _is_model_root(self, absolute_path: str) -> bool:
|
||||||
|
"""Return True when the path *is* one of the configured library roots."""
|
||||||
|
normalized = os.path.normpath(absolute_path)
|
||||||
|
for root in self.scanner.get_model_roots():
|
||||||
|
if os.path.normpath(os.path.abspath(root)) == normalized:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_model_file(file_name: str) -> bool:
|
||||||
|
"""Return True when the file name carries a model weight extension."""
|
||||||
|
return os.path.splitext(file_name)[1].lower() in MODEL_FILE_EXTENSIONS
|
||||||
|
|
||||||
|
def _collect_folder_manifest(self, absolute_path: str) -> Dict[str, Any]:
|
||||||
|
"""Describe everything a recursive delete of *absolute_path* removes.
|
||||||
|
|
||||||
|
Walking is intentional: the scanner cache can be stale, and a model file
|
||||||
|
that appeared on disk since the last scan must still block the delete.
|
||||||
|
Symbolic links are never followed (``os.walk`` default) and are counted
|
||||||
|
separately — ``shutil.rmtree`` unlinks them without touching their
|
||||||
|
targets.
|
||||||
|
"""
|
||||||
|
model_count = 0
|
||||||
|
file_count = 0
|
||||||
|
dir_count = 0
|
||||||
|
symlink_count = 0
|
||||||
|
total_bytes = 0
|
||||||
|
pending_delete_job = False
|
||||||
|
|
||||||
|
for dirpath, dirnames, filenames in os.walk(absolute_path):
|
||||||
|
if PENDING_DELETE_DIR_NAME in dirnames:
|
||||||
|
pending_delete_job = True
|
||||||
|
|
||||||
|
for name in dirnames:
|
||||||
|
if os.path.islink(os.path.join(dirpath, name)):
|
||||||
|
symlink_count += 1
|
||||||
|
else:
|
||||||
|
dir_count += 1
|
||||||
|
|
||||||
|
for name in filenames:
|
||||||
|
full_path = os.path.join(dirpath, name)
|
||||||
|
if os.path.islink(full_path):
|
||||||
|
symlink_count += 1
|
||||||
|
continue
|
||||||
|
if self._is_model_file(name):
|
||||||
|
model_count += 1
|
||||||
|
else:
|
||||||
|
file_count += 1
|
||||||
|
try:
|
||||||
|
total_bytes += os.path.getsize(full_path)
|
||||||
|
except OSError: # pragma: no cover - defensive
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"model_count": model_count,
|
||||||
|
"file_count": file_count,
|
||||||
|
"dir_count": dir_count,
|
||||||
|
"symlink_count": symlink_count,
|
||||||
|
"total_bytes": total_bytes,
|
||||||
|
"pending_delete_job": pending_delete_job,
|
||||||
|
# A truly empty directory is the only case an "undo" can restore by
|
||||||
|
# simply recreating it; a folder holding stray files is gone for good.
|
||||||
|
"restorable": (
|
||||||
|
model_count == 0
|
||||||
|
and file_count == 0
|
||||||
|
and dir_count == 0
|
||||||
|
and symlink_count == 0
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _forget_folder(self, relative_folder: str) -> None:
|
||||||
|
"""Drop a removed directory from the scanner's folder/cache records."""
|
||||||
|
if not relative_folder:
|
||||||
|
return
|
||||||
|
remove_known_folder = getattr(self.scanner, "remove_known_folder", None)
|
||||||
|
if callable(remove_known_folder):
|
||||||
|
await remove_known_folder(relative_folder)
|
||||||
|
|
||||||
async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
|
async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
|
||||||
"""Move a single model file
|
"""Move a single model file
|
||||||
|
|
||||||
|
|||||||
@@ -1505,6 +1505,63 @@ class ModelScanner:
|
|||||||
await self._persist_current_cache()
|
await self._persist_current_cache()
|
||||||
self.bump_cache_version()
|
self.bump_cache_version()
|
||||||
|
|
||||||
|
async def remove_known_folder(self, folder: str) -> None:
|
||||||
|
"""Forget a folder (and its subtree) that no longer exists on disk.
|
||||||
|
|
||||||
|
Counterpart of :meth:`add_known_folder`, called after a directory is
|
||||||
|
removed between scans (e.g. via the delete-folder API) so folder trees
|
||||||
|
and the move/download destination pickers stop offering it without a
|
||||||
|
full rescan. Ancestors are kept on purpose: every recorded ancestor
|
||||||
|
exists on disk in its own right, so only the removed subtree is dropped.
|
||||||
|
|
||||||
|
Cache entries that referenced the now-missing directory are purged as
|
||||||
|
well, which keeps a stale (phantom) model card from surviving the
|
||||||
|
deletion. When ``all_folders`` has not been recorded yet (legacy
|
||||||
|
snapshot) only the cache purge runs — the scheduled backfill walk
|
||||||
|
rebuilds the folder list from disk.
|
||||||
|
"""
|
||||||
|
normalized = folder.replace("\\", "/").strip("/")
|
||||||
|
if not normalized:
|
||||||
|
return
|
||||||
|
cache = self._cache
|
||||||
|
if cache is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
prefix = f"{normalized}/"
|
||||||
|
|
||||||
|
folders_changed = False
|
||||||
|
recorded = getattr(cache, "all_folders", None)
|
||||||
|
if recorded is not None:
|
||||||
|
updated = [
|
||||||
|
entry
|
||||||
|
for entry in recorded
|
||||||
|
if entry != normalized and not entry.startswith(prefix)
|
||||||
|
]
|
||||||
|
if updated != list(recorded):
|
||||||
|
cache.all_folders = updated
|
||||||
|
folders_changed = True
|
||||||
|
|
||||||
|
stale_paths = [
|
||||||
|
item.get("file_path")
|
||||||
|
for item in (cache.raw_data or [])
|
||||||
|
if self._folder_within(item.get("folder", ""), normalized)
|
||||||
|
]
|
||||||
|
if stale_paths:
|
||||||
|
# The purge persists the cache — including the already updated
|
||||||
|
# all_folders list — and bumps the version itself.
|
||||||
|
await self._batch_update_cache_for_deleted_models(stale_paths)
|
||||||
|
folders = set(item.get("folder", "") for item in cache.raw_data)
|
||||||
|
cache.folders = sorted(folders, key=lambda x: x.lower())
|
||||||
|
elif folders_changed:
|
||||||
|
await self._persist_current_cache()
|
||||||
|
|
||||||
|
self.bump_cache_version()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _folder_within(candidate: str, target: str) -> bool:
|
||||||
|
"""Return True when *candidate* is *target* or lives below it."""
|
||||||
|
return candidate == target or candidate.startswith(f"{target}/")
|
||||||
|
|
||||||
def _schedule_all_folders_backfill(self) -> None:
|
def _schedule_all_folders_backfill(self) -> None:
|
||||||
"""Kick off a one-shot background folder walk if none is running."""
|
"""Kick off a one-shot background folder walk if none is running."""
|
||||||
if self._all_folders_backfill_running:
|
if self._all_folders_backfill_running:
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export function getApiEndpoints(modelType) {
|
|||||||
moveModel: `/api/lm/${modelType}/move_model`,
|
moveModel: `/api/lm/${modelType}/move_model`,
|
||||||
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`,
|
||||||
|
|
||||||
// CivitAI integration
|
// CivitAI integration
|
||||||
fetchCivitai: `/api/lm/${modelType}/fetch-civitai`,
|
fetchCivitai: `/api/lm/${modelType}/fetch-civitai`,
|
||||||
|
|||||||
@@ -1330,6 +1330,40 @@ export class BaseModelApiClient {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a model-free folder inside the library roots.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* @param {string} folderPath Absolute business path of the folder
|
||||||
|
* @param {{dryRun?: boolean}} [options]
|
||||||
|
*/
|
||||||
|
async deleteFolder(folderPath, options = {}) {
|
||||||
|
const { dryRun = false } = options || {};
|
||||||
|
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.deleteFolder, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ folder_path: folderPath, dry_run: dryRun })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok || result.success === false) {
|
||||||
|
const error = new Error(result.error || `Failed to delete folder`);
|
||||||
|
error.code = result.code || null;
|
||||||
|
error.manifest = result.manifest || null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async fetchUnifiedFolderTree(options = {}) {
|
async fetchUnifiedFolderTree(options = {}) {
|
||||||
try {
|
try {
|
||||||
const { includeEmpty = false } = options;
|
const { includeEmpty = false } = options;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { getModelApiClient } from '../api/modelApiFactory.js';
|
|||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { bulkManager } from '../managers/BulkManager.js';
|
import { bulkManager } from '../managers/BulkManager.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { modalManager } from '../managers/ModalManager.js';
|
||||||
|
import { showToast, showActionToast } from '../utils/uiHelpers.js';
|
||||||
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
||||||
import { escapeHtml, escapeAttribute } from './shared/utils.js';
|
import { escapeHtml, escapeAttribute } from './shared/utils.js';
|
||||||
import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js';
|
import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js';
|
||||||
@@ -46,6 +47,8 @@ export class SidebarManager {
|
|||||||
this.nonEmptyFolders = null; // models-only folder set used to dim empty nodes
|
this.nonEmptyFolders = null; // models-only folder set used to dim empty nodes
|
||||||
this._createFolderBasePath = null;
|
this._createFolderBasePath = null;
|
||||||
this._createFolderTempChildren = null; // children container added for a leaf parent during inline creation
|
this._createFolderTempChildren = null; // children container added for a leaf parent during inline creation
|
||||||
|
this._pendingDeleteFolderPath = null;
|
||||||
|
this._deleteFolderModalWired = false;
|
||||||
|
|
||||||
// Bind methods
|
// Bind methods
|
||||||
this.handleTreeClick = this.handleTreeClick.bind(this);
|
this.handleTreeClick = this.handleTreeClick.bind(this);
|
||||||
@@ -133,6 +136,7 @@ export class SidebarManager {
|
|||||||
this.nonEmptyFolders = null;
|
this.nonEmptyFolders = null;
|
||||||
this._createFolderBasePath = null;
|
this._createFolderBasePath = null;
|
||||||
this._createFolderTempChildren = null;
|
this._createFolderTempChildren = null;
|
||||||
|
this._pendingDeleteFolderPath = null;
|
||||||
|
|
||||||
// Reset container margin
|
// Reset container margin
|
||||||
const container = document.querySelector('.container');
|
const container = document.querySelector('.container');
|
||||||
@@ -759,6 +763,188 @@ export class SidebarManager {
|
|||||||
this.hideCreateFolderInput();
|
this.hideCreateFolderInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
showDeleteFolderModal(path) {
|
||||||
|
const modal = document.getElementById('deleteFolderModal');
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
// Defensive: the modal may have been absent when listeners were wired.
|
||||||
|
this._wireDeleteFolderModal();
|
||||||
|
|
||||||
|
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 pathLine = `<strong>${escapeHtml(translate('sidebar.deleteFolderModal.folderLabel', {}, 'Folder'))}:</strong> ${escapeHtml(path)}`;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
this._pendingDeleteFolderPath = path;
|
||||||
|
title.textContent = translate(
|
||||||
|
'sidebar.deleteFolderModal.title', {}, 'Delete folder?'
|
||||||
|
);
|
||||||
|
message.textContent = translate(
|
||||||
|
'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.'
|
||||||
|
))}`;
|
||||||
|
confirmBtn.style.display = '';
|
||||||
|
modal.dataset.state = 'confirm';
|
||||||
|
}
|
||||||
|
|
||||||
|
modalManager.showModal('deleteFolderModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
hideDeleteFolderModal() {
|
||||||
|
this._pendingDeleteFolderPath = null;
|
||||||
|
modalManager.closeModal('deleteFolderModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDeleteFolderConfirm() {
|
||||||
|
const path = this._pendingDeleteFolderPath;
|
||||||
|
this.hideDeleteFolderModal();
|
||||||
|
|
||||||
|
if (!path) return false;
|
||||||
|
|
||||||
|
return this._deleteFolder(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _deleteFolder(relativePath) {
|
||||||
|
if (!this._supportsFolderManagement() || typeof this.apiClient.deleteFolder !== 'function') {
|
||||||
|
showToast('sidebar.deleteFolderResult.unsupported', {}, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rootsData = await this.apiClient.fetchModelRoots();
|
||||||
|
const roots = rootsData?.roots || [];
|
||||||
|
const root = this._resolveDefaultRoot(roots);
|
||||||
|
if (!root) {
|
||||||
|
showToast('sidebar.deleteFolderResult.noRoot', {}, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = this.combineRootAndRelativePath(root, relativePath);
|
||||||
|
const result = await this.apiClient.deleteFolder(absolutePath);
|
||||||
|
|
||||||
|
// Drop the node (and its subtree) from the persisted expand state
|
||||||
|
// before refreshing, otherwise stale keys accumulate forever. A
|
||||||
|
// selection inside the removed subtree is left to
|
||||||
|
// restoreSelectedFolder(), which falls back to the root and
|
||||||
|
// reloads the grid when the folder is gone from the fresh tree.
|
||||||
|
this._forgetRemovedFolder(relativePath);
|
||||||
|
|
||||||
|
await this.refresh();
|
||||||
|
|
||||||
|
const name = result.folder || relativePath;
|
||||||
|
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', {
|
||||||
|
actionText: translate('toast.undo.action', {}, 'Undo'),
|
||||||
|
onAction: () => this._restoreDeletedFolder(absolutePath, relativePath),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showToast(
|
||||||
|
'sidebar.deleteFolderResult.successWithFiles',
|
||||||
|
{ name, count: (result.file_count || 0) + (result.dir_count || 0) },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SidebarManager] Error deleting folder:', error);
|
||||||
|
if (error?.code === 'not_empty') {
|
||||||
|
showToast('sidebar.deleteFolderResult.notEmpty', {}, 'warning');
|
||||||
|
} else if (error?.code === 'busy') {
|
||||||
|
showToast('sidebar.deleteFolderResult.busy', {}, 'warning');
|
||||||
|
} else {
|
||||||
|
showToast(
|
||||||
|
'sidebar.deleteFolderResult.failed',
|
||||||
|
{ message: error?.message || 'Unknown error' },
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _restoreDeletedFolder(absolutePath, relativePath) {
|
||||||
|
try {
|
||||||
|
await this.apiClient.createFolder(absolutePath);
|
||||||
|
this._forgetRemovedFolder(relativePath);
|
||||||
|
await this.refresh();
|
||||||
|
showToast('sidebar.deleteFolderResult.restored', {}, 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SidebarManager] Error restoring deleted folder:', error);
|
||||||
|
showToast('toast.undo.failed', { error: error?.message || '' }, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_forgetRemovedFolder(folderPath) {
|
||||||
|
if (!folderPath) return;
|
||||||
|
|
||||||
|
const prefix = `${folderPath}/`;
|
||||||
|
let changed = false;
|
||||||
|
for (const node of Array.from(this.expandedNodes)) {
|
||||||
|
if (node === folderPath || node.startsWith(prefix)) {
|
||||||
|
this.expandedNodes.delete(node);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
this.saveExpandedState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_wireDeleteFolderModal() {
|
||||||
|
if (this._deleteFolderModalWired) return;
|
||||||
|
|
||||||
|
const modal = document.getElementById('deleteFolderModal');
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
modal.addEventListener('click', (event) => {
|
||||||
|
const item = event.target.closest('[data-action]');
|
||||||
|
if (!item) return;
|
||||||
|
const action = item.dataset.action;
|
||||||
|
if (action === 'cancel-delete-folder') {
|
||||||
|
this.hideDeleteFolderModal();
|
||||||
|
} else if (action === 'confirm-delete-folder') {
|
||||||
|
this.handleDeleteFolderConfirm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._deleteFolderModalWired = true;
|
||||||
|
}
|
||||||
|
|
||||||
saveSelectedFolder() {
|
saveSelectedFolder() {
|
||||||
setStorageItem(`${this.pageType}_activeFolder`, this.selectedPath);
|
setStorageItem(`${this.pageType}_activeFolder`, this.selectedPath);
|
||||||
}
|
}
|
||||||
@@ -866,6 +1052,9 @@ export class SidebarManager {
|
|||||||
if (hideToggle) {
|
if (hideToggle) {
|
||||||
hideToggle.addEventListener('click', this.handleHideToggle);
|
hideToggle.addEventListener('click', this.handleHideToggle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Folder delete confirmation modal buttons
|
||||||
|
this._wireDeleteFolderModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDocumentClick(event) {
|
handleDocumentClick(event) {
|
||||||
@@ -1240,6 +1429,12 @@ export class SidebarManager {
|
|||||||
createItem.style.display = this._supportsFolderManagement() ? '' : 'none';
|
createItem.style.display = this._supportsFolderManagement() ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deletion is gated the same way: recipes have virtual folders only.
|
||||||
|
const deleteItem = menu.querySelector('[data-action="delete-folder"]');
|
||||||
|
if (deleteItem) {
|
||||||
|
deleteItem.style.display = this._supportsFolderManagement() ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
menu.style.left = `${x}px`;
|
menu.style.left = `${x}px`;
|
||||||
menu.style.top = `${y}px`;
|
menu.style.top = `${y}px`;
|
||||||
menu.style.display = 'block';
|
menu.style.display = 'block';
|
||||||
@@ -1288,6 +1483,9 @@ export class SidebarManager {
|
|||||||
case 'create-subfolder':
|
case 'create-subfolder':
|
||||||
this.showCreateFolderInput(path);
|
this.showCreateFolderInput(path);
|
||||||
break;
|
break;
|
||||||
|
case 'delete-folder':
|
||||||
|
this.showDeleteFolderModal(path);
|
||||||
|
break;
|
||||||
case 'check-folder-updates':
|
case 'check-folder-updates':
|
||||||
try {
|
try {
|
||||||
await performFolderUpdateCheck(path);
|
await performFolderUpdateCheck(path);
|
||||||
|
|||||||
@@ -243,6 +243,18 @@ export class ModalManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add deleteFolderModal registration
|
||||||
|
const deleteFolderModal = document.getElementById('deleteFolderModal');
|
||||||
|
if (deleteFolderModal) {
|
||||||
|
this.registerModal('deleteFolderModal', {
|
||||||
|
element: deleteFolderModal,
|
||||||
|
onClose: () => {
|
||||||
|
this.getModal('deleteFolderModal').element.classList.remove('show');
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add helpModal registration
|
// Add helpModal registration
|
||||||
const helpModal = document.getElementById('helpModal');
|
const helpModal = document.getElementById('helpModal');
|
||||||
if (helpModal) {
|
if (helpModal) {
|
||||||
@@ -441,6 +453,7 @@ export class ModalManager {
|
|||||||
id === "clearCacheModal" ||
|
id === "clearCacheModal" ||
|
||||||
id === "bulkDeleteModal" ||
|
id === "bulkDeleteModal" ||
|
||||||
id === "checkUpdatesConfirmModal" ||
|
id === "checkUpdatesConfirmModal" ||
|
||||||
|
id === "deleteFolderModal" ||
|
||||||
id === "resolveFilenameConflictsModal"
|
id === "resolveFilenameConflictsModal"
|
||||||
) {
|
) {
|
||||||
modal.element.classList.add("show");
|
modal.element.classList.add("show");
|
||||||
|
|||||||
@@ -212,6 +212,10 @@
|
|||||||
<div class="context-menu-item" data-action="check-folder-updates">
|
<div class="context-menu-item" data-action="check-folder-updates">
|
||||||
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span>
|
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-separator"></div>
|
||||||
|
<div class="context-menu-item delete-item" data-action="delete-folder">
|
||||||
|
<i class="fas fa-trash"></i> <span>{{ t('sidebar.deleteFolder') }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar View Options Menu -->
|
<!-- Sidebar View Options Menu -->
|
||||||
|
|||||||
@@ -82,6 +82,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar Folder Delete Confirmation Modal -->
|
||||||
|
<!-- Shared by two states: 'confirm' (model-free folder) and 'blocked' (the
|
||||||
|
subtree still holds models, so a cascade delete is refused). -->
|
||||||
|
<div id="deleteFolderModal" class="modal delete-modal">
|
||||||
|
<div class="modal-content delete-modal-content">
|
||||||
|
<h2 data-role="title">{{ t('sidebar.deleteFolderModal.title') }}</h2>
|
||||||
|
<p class="delete-message" data-role="message">{{ t('sidebar.deleteFolderModal.message') }}</p>
|
||||||
|
<div class="delete-model-info" data-role="info"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="cancel-btn" data-action="cancel-delete-folder">{{ t('common.actions.cancel') }}</button>
|
||||||
|
<button class="delete-btn" data-action="confirm-delete-folder">{{ t('sidebar.deleteFolderModal.confirm') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Bulk Download Missing LoRAs Confirmation Modal -->
|
<!-- Bulk Download Missing LoRAs Confirmation Modal -->
|
||||||
<div id="bulkDownloadMissingLorasModal" class="modal">
|
<div id="bulkDownloadMissingLorasModal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const {
|
|||||||
UI_HELPERS_MODULE,
|
UI_HELPERS_MODULE,
|
||||||
UPDATE_CHECK_MODULE,
|
UPDATE_CHECK_MODULE,
|
||||||
STATE_MODULE,
|
STATE_MODULE,
|
||||||
|
MODAL_MANAGER_MODULE,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
|
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
|
||||||
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||||
@@ -18,17 +19,23 @@ const {
|
|||||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||||
UPDATE_CHECK_MODULE: new URL('../../../static/js/utils/updateCheckHelpers.js', import.meta.url).pathname,
|
UPDATE_CHECK_MODULE: new URL('../../../static/js/utils/updateCheckHelpers.js', import.meta.url).pathname,
|
||||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||||
|
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock(MODEL_API_FACTORY_MODULE, () => ({ getModelApiClient: vi.fn() }));
|
vi.mock(MODEL_API_FACTORY_MODULE, () => ({ getModelApiClient: vi.fn() }));
|
||||||
vi.mock(I18N_MODULE, () => ({ translate: (key, _args, fallback) => fallback || key }));
|
vi.mock(I18N_MODULE, () => ({ translate: (key, _args, fallback) => fallback || key }));
|
||||||
vi.mock(BULK_MANAGER_MODULE, () => ({ bulkManager: {} }));
|
vi.mock(BULK_MANAGER_MODULE, () => ({ bulkManager: {} }));
|
||||||
vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn() }));
|
vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn(), showActionToast: vi.fn() }));
|
||||||
vi.mock(UPDATE_CHECK_MODULE, () => ({ performFolderUpdateCheck: vi.fn() }));
|
vi.mock(UPDATE_CHECK_MODULE, () => ({ performFolderUpdateCheck: vi.fn() }));
|
||||||
|
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||||
|
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
const { SidebarManager } = await import(SIDEBAR_MANAGER_MODULE);
|
const { SidebarManager } = await import(SIDEBAR_MANAGER_MODULE);
|
||||||
const { state } = await import(STATE_MODULE);
|
const { state } = await import(STATE_MODULE);
|
||||||
const { setStorageItem, getStorageItem } = await import(STORAGE_HELPERS_MODULE);
|
const { setStorageItem, getStorageItem } = await import(STORAGE_HELPERS_MODULE);
|
||||||
|
const { showToast, showActionToast } = await import(UI_HELPERS_MODULE);
|
||||||
|
const { modalManager } = await import(MODAL_MANAGER_MODULE);
|
||||||
|
|
||||||
function createApiClient(overrides = {}) {
|
function createApiClient(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -44,6 +51,14 @@ function createApiClient(overrides = {}) {
|
|||||||
fetchModelFolders: vi.fn().mockResolvedValue({ folders: ['', 'full'] }),
|
fetchModelFolders: vi.fn().mockResolvedValue({ folders: ['', 'full'] }),
|
||||||
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/models/loras'] }),
|
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/models/loras'] }),
|
||||||
createFolder: vi.fn().mockResolvedValue({ success: true, folder: 'new-folder', created: true }),
|
createFolder: vi.fn().mockResolvedValue({ success: true, folder: 'new-folder', created: true }),
|
||||||
|
deleteFolder: vi.fn().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
folder: 'empty',
|
||||||
|
model_count: 0,
|
||||||
|
file_count: 0,
|
||||||
|
dir_count: 0,
|
||||||
|
restorable: true,
|
||||||
|
}),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -461,3 +476,204 @@ describe('SidebarManager folder creation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SidebarManager folder deletion', () => {
|
||||||
|
const MODAL_HTML = `
|
||||||
|
<div id="deleteFolderModal" class="modal delete-modal">
|
||||||
|
<div class="modal-content delete-modal-content">
|
||||||
|
<h2 data-role="title"></h2>
|
||||||
|
<p class="delete-message" data-role="message"></p>
|
||||||
|
<div class="delete-model-info" data-role="info"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="cancel-btn" data-action="cancel-delete-folder">Cancel</button>
|
||||||
|
<button class="delete-btn" data-action="confirm-delete-folder">Delete folder</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
function confirmBtn() {
|
||||||
|
return document.querySelector('#deleteFolderModal [data-action="confirm-delete-folder"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
document.body.innerHTML = MODAL_HTML;
|
||||||
|
state.global.settings = {};
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the confirm state for a folder whose subtree holds no models', () => {
|
||||||
|
const manager = createManager(createApiClient());
|
||||||
|
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||||
|
|
||||||
|
manager.showDeleteFolderModal('empty');
|
||||||
|
|
||||||
|
const modal = document.getElementById('deleteFolderModal');
|
||||||
|
expect(modal.dataset.state).toBe('confirm');
|
||||||
|
expect(confirmBtn().style.display).toBe('');
|
||||||
|
expect(manager._pendingDeleteFolderPath).toBe('empty');
|
||||||
|
expect(modalManager.showModal).toHaveBeenCalledWith('deleteFolderModal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('explains the refusal when the subtree still holds models', () => {
|
||||||
|
const manager = createManager(createApiClient());
|
||||||
|
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||||
|
|
||||||
|
manager.showDeleteFolderModal('full');
|
||||||
|
|
||||||
|
const modal = document.getElementById('deleteFolderModal');
|
||||||
|
expect(modal.dataset.state).toBe('blocked');
|
||||||
|
expect(confirmBtn().style.display).toBe('none');
|
||||||
|
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.
|
||||||
|
const manager = createManager(createApiClient());
|
||||||
|
manager.nonEmptyFolders = null;
|
||||||
|
|
||||||
|
manager.showDeleteFolderModal('empty');
|
||||||
|
|
||||||
|
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('confirm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes the folder and offers the undo affordance for an empty one', async () => {
|
||||||
|
const apiClient = createApiClient();
|
||||||
|
const manager = createManager(apiClient);
|
||||||
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const success = await manager._deleteFolder('empty');
|
||||||
|
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(apiClient.deleteFolder).toHaveBeenCalledWith('/models/loras/empty');
|
||||||
|
expect(manager.refresh).toHaveBeenCalledTimes(1);
|
||||||
|
expect(showActionToast).toHaveBeenCalledTimes(1);
|
||||||
|
expect(showToast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores a deleted empty folder through the create-folder API', async () => {
|
||||||
|
const apiClient = createApiClient();
|
||||||
|
const manager = createManager(apiClient);
|
||||||
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await manager._deleteFolder('empty');
|
||||||
|
const undo = showActionToast.mock.calls[0][3].onAction;
|
||||||
|
await undo();
|
||||||
|
|
||||||
|
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/empty');
|
||||||
|
expect(showToast).toHaveBeenCalledWith('sidebar.deleteFolderResult.restored', {}, 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips the undo affordance when non-model leftovers were removed', async () => {
|
||||||
|
const apiClient = createApiClient({
|
||||||
|
deleteFolder: vi.fn().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
folder: 'empty',
|
||||||
|
file_count: 2,
|
||||||
|
dir_count: 1,
|
||||||
|
restorable: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const manager = createManager(apiClient);
|
||||||
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await manager._deleteFolder('empty');
|
||||||
|
|
||||||
|
expect(showActionToast).not.toHaveBeenCalled();
|
||||||
|
expect(showToast).toHaveBeenCalledWith(
|
||||||
|
'sidebar.deleteFolderResult.successWithFiles',
|
||||||
|
{ name: 'empty', count: 3 },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the not_empty conflict when the tree was stale', async () => {
|
||||||
|
const conflict = Object.assign(new Error('still contains models'), { code: 'not_empty' });
|
||||||
|
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.notEmpty', {}, 'warning');
|
||||||
|
expect(manager.refresh).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a busy folder with a staged delete', 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.refresh = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await manager._deleteFolder('full');
|
||||||
|
|
||||||
|
expect(showToast).toHaveBeenCalledWith('sidebar.deleteFolderResult.busy', {}, 'warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the removed subtree from the persisted expand state', () => {
|
||||||
|
const manager = createManager(createApiClient());
|
||||||
|
manager.expandedNodes = new Set(['empty', 'empty/deep', 'other']);
|
||||||
|
manager.saveExpandedState = vi.fn();
|
||||||
|
|
||||||
|
manager._forgetRemovedFolder('empty');
|
||||||
|
|
||||||
|
expect([...manager.expandedNodes]).toEqual(['other']);
|
||||||
|
expect(manager.saveExpandedState).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the expand state untouched when nothing matched', () => {
|
||||||
|
const manager = createManager(createApiClient());
|
||||||
|
manager.expandedNodes = new Set(['other']);
|
||||||
|
manager.saveExpandedState = vi.fn();
|
||||||
|
|
||||||
|
manager._forgetRemovedFolder('empty');
|
||||||
|
|
||||||
|
expect([...manager.expandedNodes]).toEqual(['other']);
|
||||||
|
expect(manager.saveExpandedState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes the modal buttons to cancel and confirm', () => {
|
||||||
|
const manager = createManager(createApiClient());
|
||||||
|
manager._deleteFolder = vi.fn().mockResolvedValue(true);
|
||||||
|
manager._pendingDeleteFolderPath = 'empty';
|
||||||
|
manager._wireDeleteFolderModal();
|
||||||
|
|
||||||
|
confirmBtn().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
|
||||||
|
expect(modalManager.closeModal).toHaveBeenCalledWith('deleteFolderModal');
|
||||||
|
expect(manager._deleteFolder).toHaveBeenCalledWith('empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes the context-menu action to the delete modal', () => {
|
||||||
|
const manager = createManager(createApiClient());
|
||||||
|
manager.showDeleteFolderModal = vi.fn();
|
||||||
|
|
||||||
|
manager._performFolderAction('delete-folder', 'empty');
|
||||||
|
|
||||||
|
expect(manager.showDeleteFolderModal).toHaveBeenCalledWith('empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the delete entry when folder management is unsupported', () => {
|
||||||
|
document.body.insertAdjacentHTML('beforeend', `
|
||||||
|
<div id="sidebarFolderContextMenu" class="context-menu">
|
||||||
|
<div class="context-menu-item" data-action="create-subfolder"></div>
|
||||||
|
<div class="context-menu-item delete-item" data-action="delete-folder"></div>
|
||||||
|
</div>`);
|
||||||
|
const apiClient = createApiClient();
|
||||||
|
apiClient.apiConfig.config.supportsFolderManagement = false;
|
||||||
|
const manager = createManager(apiClient);
|
||||||
|
|
||||||
|
manager._showFolderContextMenu(10, 10, 'empty');
|
||||||
|
|
||||||
|
const item = document.querySelector('#sidebarFolderContextMenu [data-action="delete-folder"]');
|
||||||
|
expect(item.style.display).toBe('none');
|
||||||
|
|
||||||
|
manager._closeFolderContextMenu();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,17 @@ class FakeMoveService:
|
|||||||
def __init__(self, result):
|
def __init__(self, result):
|
||||||
self._result = result
|
self._result = result
|
||||||
self.received_path = None
|
self.received_path = None
|
||||||
|
self.received_dry_run = None
|
||||||
|
|
||||||
async def create_folder(self, folder_path):
|
async def create_folder(self, folder_path):
|
||||||
self.received_path = folder_path
|
self.received_path = folder_path
|
||||||
return self._result
|
return self._result
|
||||||
|
|
||||||
|
async def delete_folder(self, folder_path, dry_run=False):
|
||||||
|
self.received_path = folder_path
|
||||||
|
self.received_dry_run = dry_run
|
||||||
|
return self._result
|
||||||
|
|
||||||
|
|
||||||
class FakeRequest:
|
class FakeRequest:
|
||||||
def __init__(self, payload):
|
def __init__(self, payload):
|
||||||
@@ -91,3 +97,118 @@ async def test_create_folder_invalid_json_body():
|
|||||||
assert response.status == 400
|
assert response.status == 400
|
||||||
payload = json.loads(response.text)
|
payload = json.loads(response.text)
|
||||||
assert payload["success"] is False
|
assert payload["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_success():
|
||||||
|
handler, service = _make_handler(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"folder": "characters/anime",
|
||||||
|
"model_count": 0,
|
||||||
|
"restorable": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.delete_folder(
|
||||||
|
FakeRequest({"folder_path": "/library/characters/anime"})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["restorable"] is True
|
||||||
|
assert service.received_path == "/library/characters/anime"
|
||||||
|
assert service.received_dry_run is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_forwards_dry_run():
|
||||||
|
handler, service = _make_handler({"success": True, "dry_run": True})
|
||||||
|
|
||||||
|
response = await handler.delete_folder(
|
||||||
|
FakeRequest({"folder_path": "/library/empty", "dry_run": True})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert service.received_dry_run is True
|
||||||
|
assert json.loads(response.text)["dry_run"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_missing_path():
|
||||||
|
handler, service = _make_handler({"success": True})
|
||||||
|
|
||||||
|
response = await handler.delete_folder(FakeRequest({}))
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
assert payload["success"] is False
|
||||||
|
assert service.received_path is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_not_empty_maps_to_409():
|
||||||
|
handler, _service = _make_handler(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"code": "not_empty",
|
||||||
|
"error": "Folder still contains 2 model file(s); delete or move them first",
|
||||||
|
"manifest": {"model_count": 2},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.delete_folder(
|
||||||
|
FakeRequest({"folder_path": "/library/full"})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 409
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
assert payload["success"] is False
|
||||||
|
assert payload["code"] == "not_empty"
|
||||||
|
assert payload["manifest"]["model_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_busy_maps_to_409():
|
||||||
|
handler, _service = _make_handler(
|
||||||
|
{"success": False, "code": "busy", "error": "staged delete pending"}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.delete_folder(
|
||||||
|
FakeRequest({"folder_path": "/library/full"})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 409
|
||||||
|
assert json.loads(response.text)["code"] == "busy"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_containment_failure_maps_to_400():
|
||||||
|
handler, _service = _make_handler(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Folder path '/etc/evil' is outside configured library directories",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.delete_folder(FakeRequest({"folder_path": "/etc/evil"}))
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
assert payload["success"] is False
|
||||||
|
assert "outside configured library" in payload["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_invalid_json_body():
|
||||||
|
class BadJsonRequest:
|
||||||
|
async def json(self):
|
||||||
|
raise ValueError("bad json")
|
||||||
|
|
||||||
|
handler, _service = _make_handler({"success": True})
|
||||||
|
|
||||||
|
response = await handler.delete_folder(BadJsonRequest())
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
assert json.loads(response.text)["success"] is False
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class FakeScanner:
|
|||||||
def __init__(self, roots: List[Path]) -> None:
|
def __init__(self, roots: List[Path]) -> None:
|
||||||
self._roots = [str(root) for root in roots]
|
self._roots = [str(root) for root in roots]
|
||||||
self.known_folders: List[str] = []
|
self.known_folders: List[str] = []
|
||||||
|
self.removed_folders: List[str] = []
|
||||||
|
|
||||||
def get_model_roots(self) -> List[str]:
|
def get_model_roots(self) -> List[str]:
|
||||||
return list(self._roots)
|
return list(self._roots)
|
||||||
@@ -21,6 +22,9 @@ class FakeScanner:
|
|||||||
async def add_known_folder(self, folder: str) -> None:
|
async def add_known_folder(self, folder: str) -> None:
|
||||||
self.known_folders.append(folder)
|
self.known_folders.append(folder)
|
||||||
|
|
||||||
|
async def remove_known_folder(self, folder: str) -> None:
|
||||||
|
self.removed_folders.append(folder)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_folder_creates_directory_and_registers_it(tmp_path: Path):
|
async def test_create_folder_creates_directory_and_registers_it(tmp_path: Path):
|
||||||
@@ -87,3 +91,183 @@ async def test_create_folder_requires_path(tmp_path: Path):
|
|||||||
result = await service.create_folder("")
|
result = await service.create_folder("")
|
||||||
|
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def _make_nested(root: Path) -> Path:
|
||||||
|
target = root / "characters" / "anime"
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_removes_empty_directory_and_forgets_it(tmp_path: Path):
|
||||||
|
target = _make_nested(tmp_path)
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.delete_folder(str(target))
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["folder"] == "characters/anime"
|
||||||
|
assert result["model_count"] == 0
|
||||||
|
assert result["restorable"] is True
|
||||||
|
assert not target.exists()
|
||||||
|
assert scanner.removed_folders == ["characters/anime"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_reports_non_model_leftovers_as_not_restorable(tmp_path: Path):
|
||||||
|
target = _make_nested(tmp_path)
|
||||||
|
(target / "notes.txt").write_text("keep me?", encoding="utf-8")
|
||||||
|
(target / "nested").mkdir()
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.delete_folder(str(target))
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["file_count"] == 1
|
||||||
|
assert result["dir_count"] == 1
|
||||||
|
assert result["restorable"] is False
|
||||||
|
assert not target.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_refuses_when_models_live_below(tmp_path: Path):
|
||||||
|
target = _make_nested(tmp_path)
|
||||||
|
model_file = target / "model.safetensors"
|
||||||
|
model_file.write_text("weights", encoding="utf-8")
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
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 target.exists()
|
||||||
|
assert model_file.exists()
|
||||||
|
assert scanner.removed_folders == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
|
||||||
|
target = _make_nested(tmp_path)
|
||||||
|
(target / ".lm-pending-delete").mkdir()
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.delete_folder(str(target))
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["code"] == "busy"
|
||||||
|
assert target.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_refuses_the_library_root_itself(tmp_path: Path):
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.delete_folder(str(tmp_path))
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "root" in result["error"].lower()
|
||||||
|
assert tmp_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_rejects_paths_outside_roots(tmp_path: Path):
|
||||||
|
root = tmp_path / "library"
|
||||||
|
root.mkdir()
|
||||||
|
outside = tmp_path / "outside"
|
||||||
|
outside.mkdir()
|
||||||
|
scanner = FakeScanner([root])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.delete_folder(str(outside))
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "error" in result
|
||||||
|
assert outside.exists()
|
||||||
|
assert scanner.removed_folders == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_reports_missing_directory(tmp_path: Path):
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.delete_folder(str(tmp_path / "gone"))
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "no longer exists" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_requires_path(tmp_path: Path):
|
||||||
|
service = ModelMoveService(FakeScanner([tmp_path]), "lora")
|
||||||
|
|
||||||
|
result = await service.delete_folder("")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_dry_run_reports_without_removing(tmp_path: Path):
|
||||||
|
target = _make_nested(tmp_path)
|
||||||
|
(target / "leftover.webp").write_text("preview", encoding="utf-8")
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.delete_folder(str(target), dry_run=True)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["dry_run"] is True
|
||||||
|
assert result["file_count"] == 1
|
||||||
|
assert target.exists()
|
||||||
|
assert scanner.removed_folders == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_refuses_symlinked_directory(tmp_path: Path):
|
||||||
|
real = tmp_path / "real"
|
||||||
|
real.mkdir()
|
||||||
|
link = tmp_path / "link"
|
||||||
|
try:
|
||||||
|
link.symlink_to(real, target_is_directory=True)
|
||||||
|
except (OSError, NotImplementedError): # pragma: no cover - platform guard
|
||||||
|
pytest.skip("symlinks are not supported on this platform")
|
||||||
|
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.delete_folder(str(link))
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "symlink" in result["error"].lower()
|
||||||
|
assert link.is_symlink()
|
||||||
|
assert real.is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_counts_nested_symlinks_without_following_them(tmp_path: Path):
|
||||||
|
target = _make_nested(tmp_path)
|
||||||
|
real = tmp_path / "real"
|
||||||
|
real.mkdir()
|
||||||
|
(real / "model.safetensors").write_text("weights", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
(target / "linked").symlink_to(real, target_is_directory=True)
|
||||||
|
except (OSError, NotImplementedError): # pragma: no cover - platform guard
|
||||||
|
pytest.skip("symlinks are not supported on this platform")
|
||||||
|
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.delete_folder(str(target))
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["symlink_count"] == 1
|
||||||
|
# The linked model is not part of the subtree being deleted
|
||||||
|
assert result["model_count"] == 0
|
||||||
|
assert (real / "model.safetensors").exists()
|
||||||
|
|||||||
@@ -1547,6 +1547,68 @@ async def test_add_known_folder_ignores_empty_input(tmp_path: Path):
|
|||||||
assert cache.all_folders == before
|
assert cache.all_folders == before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_known_folder_drops_subtree_and_keeps_ancestors(tmp_path: Path):
|
||||||
|
_create_files(tmp_path)
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
await scanner.add_known_folder("nested/deep/leaf")
|
||||||
|
|
||||||
|
await scanner.remove_known_folder("nested/deep")
|
||||||
|
|
||||||
|
assert "nested/deep" not in cache.all_folders
|
||||||
|
assert "nested/deep/leaf" not in cache.all_folders
|
||||||
|
# The ancestor directory still exists on disk in its own right
|
||||||
|
assert "nested" in cache.all_folders
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_known_folder_purges_stale_cache_entries(tmp_path: Path):
|
||||||
|
_, second, _ = _create_files(tmp_path)
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
assert "nested" in cache.folders
|
||||||
|
|
||||||
|
await scanner.remove_known_folder("nested")
|
||||||
|
|
||||||
|
assert "nested" not in cache.all_folders
|
||||||
|
assert "nested" not in cache.folders
|
||||||
|
assert _normalize_path(second) not in {
|
||||||
|
item["file_path"] for item in cache.raw_data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_known_folder_noop_without_recorded_folders(tmp_path: Path):
|
||||||
|
_create_files(tmp_path)
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
cache.all_folders = None
|
||||||
|
|
||||||
|
# Legacy snapshot without recorded folders: nothing to prune, and the
|
||||||
|
# scheduled backfill walk rebuilds the list from disk.
|
||||||
|
await scanner.remove_known_folder("nested")
|
||||||
|
|
||||||
|
assert cache.all_folders is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_known_folder_ignores_empty_input(tmp_path: Path):
|
||||||
|
_create_files(tmp_path)
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
before = list(cache.all_folders)
|
||||||
|
|
||||||
|
await scanner.remove_known_folder("")
|
||||||
|
await scanner.remove_known_folder("/")
|
||||||
|
|
||||||
|
assert cache.all_folders == before
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_all_folders_updated_after_move(tmp_path: Path):
|
async def test_get_all_folders_updated_after_move(tmp_path: Path):
|
||||||
first, _, _ = _create_files(tmp_path)
|
first, _, _ = _create_files(tmp_path)
|
||||||
|
|||||||
Reference in New Issue
Block a user