From 2dcaf6a30e50e6eace230c8311f25890a23473f0 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 6 Oct 2026 17:14:07 +0800 Subject: [PATCH] feat(sidebar): manage folders that live under several model roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar's folder tree merges every model root into one relative-path namespace, but folder operations turned a node into a path by prefixing default_*_root. A folder living under another root failed to delete with "Folder no longer exists" (recipes under the primary lora root while default_lora_root is the extra one), and where the same relative folder exists in both roots the operation silently hit the other copy — 14 of the 16 top-level folders in the reporting library are shared, so guessing a root was never safe. Backend: * ModelMoveService.resolve_folder() and GET /api/lm/{prefix}/resolve-folder answer which directories a library-relative folder maps to (folder_path/root/is_symlink), in scanner root order, skipping directories no root holds and refusing absolute or climbing paths. * delete_folder/rename_folder tag a vanished directory with code "missing" so the sidebar can tell "this node is stale, refresh" from a failed operation. Frontend: * _resolveFolderCandidates() is the single place that turns a node into absolute paths: default root first, the old root-prefix fallback only while a single root is configured, and an explicit unresolved error for a multi-root library — nothing is guessed silently any more. * One copy keeps the single-target modal, which now names the resolved absolute path. Several copies render one checkbox row per copy, each dry-run against the delete guard ("no models" / "contains N model file(s)..." / a deletion is still pending / no longer exists / symbolic link): a blocked copy is unticked, disabled and explained, the button reads "Delete N folders", every ticked copy is deleted and guarded on its own, and a partial failure is reported without discarding the successes. * Rows are built once per open and only their status text is updated, so ticking a box no longer rebuilds the list, steals focus or resizes the modal mid-click; the action row keeps a fixed button width. * Rename offers a root picker in its inline row, create inherits the parent's root when the parent resolves to exactly one directory, and the undo restores every copy a delete removed. i18n: 26 new keys (sidebar.deleteFolderModal.*, .deleteFolderResult.*, .renameFolderResult.*, .folderRoot.*, .folderResult.*) translated in all 9 locales, with the en wording normalized to the established "model root" noun (it had said "library root") and the new terminology recorded in docs/i18n-translation-guidelines.md. Verified: pytest 3686 passed, vitest 1473 passed (86 in the folder-management suite), pytest tests/i18n 20 passed, sync_translation_keys.py --dry-run clean. A sandboxed standalone instance with two roots confirmed that deleting one copy leaves the node in place, that the twin's model card survives the purge, and that deleting both copies and undoing restores both directories. --- docs/i18n-translation-guidelines.md | 45 + locales/de.json | 34 +- locales/en.json | 34 +- locales/es.json | 34 +- locales/fr.json | 34 +- locales/he.json | 34 +- locales/ja.json | 34 +- locales/ko.json | 34 +- locales/ru.json | 34 +- locales/zh-CN.json | 34 +- locales/zh-TW.json | 34 +- py/routes/handlers/model_handlers.py | 17 + py/routes/model_route_registrar.py | 1 + py/services/model_file_service.py | 110 +- static/css/components/modal/delete-modal.css | 102 +- static/js/api/apiConfig.js | 4 + static/js/api/baseModelApi.js | 28 + static/js/components/SidebarManager.js | 965 +++++++++++++++--- .../components/modals/confirm_modals.html | 10 +- .../sidebarManager.folderManagement.test.js | 549 +++++++++- tests/routes/test_base_model_routes_smoke.py | 51 + tests/routes/test_model_move_handler.py | 63 ++ tests/services/test_model_file_service.py | 115 ++- 23 files changed, 2220 insertions(+), 180 deletions(-) diff --git a/docs/i18n-translation-guidelines.md b/docs/i18n-translation-guidelines.md index 50a364f4..568465ca 100644 --- a/docs/i18n-translation-guidelines.md +++ b/docs/i18n-translation-guidelines.md @@ -159,6 +159,27 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL). > holds again. The same pass scoped the client-side ETA to the current stage in > `static/js/api/baseModelApi.js`; no other locale string changed. +> **Status (2026-10, multi-root folder resolution):** the folder sidebar used to turn a tree +> node into a path by prefixing the *default* model root, so a folder living under another root +> failed to delete ("Folder no longer exists") and a same-named folder in another root could be +> the one that got hit. Folder operations now resolve a node through the backend +> (`GET /api/lm/{prefix}/resolve-folder`) and act on the directories that really exist. When +> several roots hold the folder, the delete modal lists **one checkbox row per copy** — each row +> carries its own guard verdict, every deletable copy is ticked by default, and a copy that +> still holds models (or is a symbolic link) is unticked, disabled and explained instead of +> silently dropped. Deleting a subset leaves the node in the sidebar, because the folder list is +> a union over the model roots; `deleteFolderModal.keptNote` warns about that before the click. +> The scanner bookkeeping is root-aware for the same reason: deleting one copy no longer hides +> the node until the next scan, and no longer purges the model cards of its same-named twin in +> another root (rename keeps the twin's records untouched too). That added 26 keys — +> `sidebar.deleteFolderModal.{missingTitle,missingMessage,symlinkTitle,symlinkMessage,confirmMulti,deleting,keptNote}`, +> `sidebar.deleteFolderResult.{missing,symlink,successMulti,partial}`, +> `sidebar.renameFolderResult.{missing,symlink}`, the 12 `sidebar.folderRoot.*` +> chooser/status strings and `sidebar.folderResult.unresolved` — all translated in all 9 +> locales (terminology below). The `en.json` wording was normalized to the established **model +> root** noun while translating (`library root` → `model root`, R5); the count-bearing +> `successMulti` uses the singular `success` key when only one copy was removed. + --- ## 1. Hard rules (do not violate) @@ -499,6 +520,30 @@ placeholders. The keys carrying `{count}` are `successWithFiles`, `notEmptyMessa `notEmptyMessageExcluded` and `notEmptyWithCount`; `notEmptyMessageExcluded` is the only key carrying `{excluded}`. +#### Multi-root folder operations (resolution, copies, symlinks) + +The sidebar's folder tree is a union over the model roots, so a relative folder can stand for +several directories at once. Copy is called **copy** (one directory per root), never "version" +or "instance"; a directory the backend refuses to touch is called out as a **symbolic link**, +and a per-row verdict reads **no models** when the copy is deletable: + +| Term | Rendering | +|---|---| +| copy (one directory per root holding the same relative folder) | zh-CN 副本 · zh-TW 副本 · ja コピー · ko 복사본 · fr copie · de Kopie · es copia · ru копия · he עותק | +| symbolic link | zh-CN 符号链接 · zh-TW 符號連結 · ja シンボリックリンク · ko 심볼릭 링크 · fr lien symbolique · de symbolischer Link · es enlace simbólico · ru символическая ссылка · he קישור סמלי | +| unchecked (row / note wording) | zh-CN 未勾选 · zh-TW 未勾選 · ja チェックを外した · ko 선택하지 않은 · fr non cochée · de nicht angehakt · es no marcada · ru неотмеченная · he שלא סומן | +| "no models" (row verdict) | zh-CN 无模型 · zh-TW 無模型 · ja モデルなし · ko 모델 없음 · fr aucun modèle · de keine Modelle · es sin modelos · ru моделей нет · he אין מודלים | +| model root — plural ("more than one model root", "from {count} model roots") | zh-CN 模型根目录(多个模型根目录)· zh-TW 模型根目錄(多個模型根目錄)· ja モデルルート(複数のモデルルート)· ko 모델 루트(여러 모델 루트)· fr racine de modèle (plusieurs racines de modèle) · de Modell-Stammverzeichnis (mehrere Modell-Stammverzeichnisse) · es raíz de modelo (más de una raíz de modelo) · ru корневая папка моделей (несколько корневых папок моделей) · he שורש מודלים (יותר משורש מודלים אחד) | + +`{count}` / `{total}` / `{failed}` / `{excluded}` / `{name}` are verbatim §1-R2 placeholders in +`deleteFolderModal.confirmMulti`, `deleteFolderResult.successMulti` / `.partial` and +`folderRoot.notEmptyStatus` / `.notEmptyExcludedStatus`. The row verdicts are **fragments, not +sentences**: they sit under a path inside the chooser box, so no locale capitalizes them or adds +a period (`de` keeps its lowercase start, `fr` too). `folderRoot.rootsHint` ends with each +locale's colon (`:` for CJK, ASCII elsewhere). `partial` reuses the locale's "N of M" shape and +keeps the `—` before the failed count; `successMulti` keeps the locale's plain +`deleteFolderResult.success` verb and appends the root count. + ### Settings Organization tab The settings modal's fourth nav tab groups everything about how files are arranged on diff --git a/locales/de.json b/locales/de.json index 317c17b9..9912af2e 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1409,9 +1409,16 @@ "notEmptyMessage": "Dieser Ordner enthält noch Modelle. Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.", "notEmptyMessageCount": "Dieser Ordner enthält noch {count} Modelldatei(en). Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.", "notEmptyMessageExcluded": "Dieser Ordner enthält noch {count} Modelldatei(en), davon {excluded} von der Bibliothek ausgeschlossen. Heben Sie den Ausschluss unter „Ausgeschlossene Modelle verwalten“ auf und löschen Sie sie zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.", + "missingTitle": "Ordner nicht gefunden", + "missingMessage": "Dieser Ordner existiert nicht mehr auf dem Datenträger. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.", + "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.", "busyTitle": "Eine Löschung steht noch aus", "checking": "Ordnerinhalt wird geprüft...", - "confirm": "Ordner löschen" + "confirm": "Ordner löschen", + "confirmMulti": "{count} Ordner löschen", + "deleting": "Wird gelöscht...", + "keptNote": "Nicht angehakte Kopien lassen diesen Ordner in der Seitenleiste bestehen." }, "deleteFolderResult": { "success": "Ordner \"{name}\" gelöscht", @@ -1422,7 +1429,10 @@ "notEmptyWithCount": "Dieser Ordner enthält noch {count} Modelldatei(en). Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.", "busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.", "unsupported": "Das Löschen von Ordnern wird auf dieser Seite nicht unterstützt", - "noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert" + "missing": "Dieser Ordner existiert nicht mehr auf dem Datenträger. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.", + "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." }, "renameFolder": "Ordner umbenennen", "renameFolderResult": { @@ -1431,7 +1441,25 @@ "targetExists": "Ein Ordner mit diesem Namen ist hier bereits vorhanden", "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 Umbenennen von Ordnern wird auf dieser Seite nicht unterstützt", - "noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert" + "missing": "Dieser Ordner existiert nicht mehr auf dem Datenträger. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.", + "symlink": "Dieser Ordner ist ein symbolischer Link; er kann nicht über die Seitenleiste umbenannt werden." + }, + "folderRoot": { + "chooseTitle": "Dieser Ordner existiert in mehreren Modell-Stammverzeichnissen", + "symlinkOption": "symbolischer Link", + "rootsHint": "Wählen Sie die Modell-Stammverzeichnisse, aus denen dieser Ordner gelöscht werden soll:", + "checkingStatus": "wird geprüft...", + "okStatus": "keine Modelle", + "notEmptyStatus": "enthält {count} Modelldatei(en) — löschen oder verschieben Sie diese zuerst", + "notEmptyExcludedStatus": "enthält {count} Modelldatei(en), davon {excluded} von der Bibliothek ausgeschlossen", + "busyStatus": "eine Löschung steht noch aus", + "missingStatus": "existiert nicht mehr auf dem Datenträger", + "symlinkStatus": "symbolischer Link — hier nicht löschbar", + "unknownStatus": "konnte nicht geprüft werden", + "deletedStatus": "gelöscht" + }, + "folderResult": { + "unresolved": "Es konnte nicht ermittelt werden, welches Modell-Stammverzeichnis diesen Ordner enthält. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut." }, "dragDrop": { "unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.", diff --git a/locales/en.json b/locales/en.json index 86f504d1..14caaeda 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1409,9 +1409,16 @@ "notEmptyMessage": "This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", "notEmptyMessageCount": "This folder still contains {count} model file(s). Delete or move them first — deleting a folder never cascades over model files.", "notEmptyMessageExcluded": "This folder still contains {count} model file(s), {excluded} of them excluded from the library. Un-exclude them in Manage Excluded Models and delete them first — deleting a folder never cascades over model files.", + "missingTitle": "Folder not found", + "missingMessage": "This folder no longer exists on disk. Refresh the sidebar and try again.", + "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.", "busyTitle": "A deletion is still pending", "checking": "Checking the folder contents...", - "confirm": "Delete folder" + "confirm": "Delete folder", + "confirmMulti": "Delete {count} folders", + "deleting": "Deleting...", + "keptNote": "Unchecked copies keep this folder in the sidebar." }, "deleteFolderResult": { "success": "Folder \"{name}\" deleted", @@ -1422,7 +1429,10 @@ "notEmptyWithCount": "This folder still contains {count} model file(s). Refresh the sidebar and try again.", "busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.", "unsupported": "Folder deletion is not supported on this page", - "noRoot": "No model root is configured" + "missing": "This folder no longer exists on disk. Refresh the sidebar and try again.", + "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." }, "renameFolder": "Rename folder", "renameFolderResult": { @@ -1431,7 +1441,25 @@ "targetExists": "A folder with that name already exists here", "busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.", "unsupported": "Folder renaming is not supported on this page", - "noRoot": "No model root is configured" + "missing": "This folder no longer exists on disk. Refresh the sidebar and try again.", + "symlink": "This folder is a symbolic link; it cannot be renamed from the sidebar." + }, + "folderRoot": { + "chooseTitle": "This folder exists in more than one model root", + "symlinkOption": "symbolic link", + "rootsHint": "Select the model roots to delete this folder from:", + "checkingStatus": "checking...", + "okStatus": "no models", + "notEmptyStatus": "contains {count} model file(s) — delete or move them first", + "notEmptyExcludedStatus": "contains {count} model file(s), {excluded} of them excluded from the library", + "busyStatus": "a deletion is still pending", + "missingStatus": "no longer exists on disk", + "symlinkStatus": "symbolic link — cannot be deleted here", + "unknownStatus": "could not be checked", + "deletedStatus": "deleted" + }, + "folderResult": { + "unresolved": "Could not determine which model root holds this folder. Refresh the sidebar and try again." }, "dragDrop": { "unableToResolveRoot": "Unable to determine destination path for move.", diff --git a/locales/es.json b/locales/es.json index b7338d42..1248cadb 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1409,9 +1409,16 @@ "notEmptyMessage": "Esta carpeta aún contiene modelos. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.", "notEmptyMessageCount": "Esta carpeta aún contiene {count} archivo(s) de modelo. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.", "notEmptyMessageExcluded": "Esta carpeta aún contiene {count} archivo(s) de modelo, de los cuales {excluded} están excluidos de la biblioteca. Anula su exclusión en «Gestionar modelos excluidos» y elimínalos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.", + "missingTitle": "Carpeta no encontrada", + "missingMessage": "Esta carpeta ya no existe en el disco. Actualiza la barra lateral e inténtalo de nuevo.", + "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.", "busyTitle": "Todavía hay una eliminación pendiente", "checking": "Comprobando el contenido de la carpeta...", - "confirm": "Eliminar carpeta" + "confirm": "Eliminar carpeta", + "confirmMulti": "Eliminar {count} carpetas", + "deleting": "Eliminando...", + "keptNote": "Las copias no marcadas mantienen esta carpeta en la barra lateral." }, "deleteFolderResult": { "success": "Carpeta \"{name}\" eliminada", @@ -1422,7 +1429,10 @@ "notEmptyWithCount": "Esta carpeta aún contiene {count} archivo(s) de modelo. Actualiza la barra lateral e inténtalo de nuevo.", "busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.", "unsupported": "La eliminación de carpetas no es compatible con esta página", - "noRoot": "No hay ninguna raíz de modelo configurada" + "missing": "Esta carpeta ya no existe en el disco. Actualiza la barra lateral e inténtalo de nuevo.", + "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." }, "renameFolder": "Cambiar nombre de la carpeta", "renameFolderResult": { @@ -1431,7 +1441,25 @@ "targetExists": "Ya existe una carpeta con ese nombre aquí", "busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.", "unsupported": "El cambio de nombre de carpetas no es compatible con esta página", - "noRoot": "No hay ninguna raíz de modelo configurada" + "missing": "Esta carpeta ya no existe en el disco. Actualiza la barra lateral e inténtalo de nuevo.", + "symlink": "Esta carpeta es un enlace simbólico; no se puede cambiar el nombre desde la barra lateral." + }, + "folderRoot": { + "chooseTitle": "Esta carpeta existe en más de una raíz de modelo", + "symlinkOption": "enlace simbólico", + "rootsHint": "Selecciona las raíces de modelo desde las que eliminar esta carpeta:", + "checkingStatus": "comprobando...", + "okStatus": "sin modelos", + "notEmptyStatus": "contiene {count} archivo(s) de modelo — elimínalos o muévelos primero", + "notEmptyExcludedStatus": "contiene {count} archivo(s) de modelo, de los cuales {excluded} están excluidos de la biblioteca", + "busyStatus": "todavía hay una eliminación pendiente", + "missingStatus": "ya no existe en el disco", + "symlinkStatus": "enlace simbólico — no se puede eliminar aquí", + "unknownStatus": "no se pudo comprobar", + "deletedStatus": "eliminada" + }, + "folderResult": { + "unresolved": "No se pudo determinar qué raíz de modelo contiene esta carpeta. Actualiza la barra lateral e inténtalo de nuevo." }, "dragDrop": { "unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.", diff --git a/locales/fr.json b/locales/fr.json index 21473b93..4ff0e3fa 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1409,9 +1409,16 @@ "notEmptyMessage": "Ce dossier contient encore des modèles. Supprimez-les ou déplacez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.", "notEmptyMessageCount": "Ce dossier contient encore {count} fichier(s) de modèle. Supprimez-les ou déplacez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.", "notEmptyMessageExcluded": "Ce dossier contient encore {count} fichier(s) de modèle, dont {excluded} sont exclus de la bibliothèque. Annulez leur exclusion dans « Gérer les modèles exclus » et supprimez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.", + "missingTitle": "Dossier introuvable", + "missingMessage": "Ce dossier n’existe plus sur le disque. Actualisez la barre latérale et réessayez.", + "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.", "busyTitle": "Une suppression est encore en attente", "checking": "Vérification du contenu du dossier...", - "confirm": "Supprimer le dossier" + "confirm": "Supprimer le dossier", + "confirmMulti": "Supprimer {count} dossiers", + "deleting": "Suppression...", + "keptNote": "Les copies non cochées maintiennent ce dossier dans la barre latérale." }, "deleteFolderResult": { "success": "Dossier \"{name}\" supprimé", @@ -1422,7 +1429,10 @@ "notEmptyWithCount": "Ce dossier contient encore {count} fichier(s) de modèle. Actualisez la barre latérale et réessayez.", "busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre d’annulation.", "unsupported": "La suppression de dossiers n’est pas prise en charge sur cette page", - "noRoot": "Aucune racine de modèle n’est configurée" + "missing": "Ce dossier n’existe plus sur le disque. Actualisez la barre latérale et réessayez.", + "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." }, "renameFolder": "Renommer le dossier", "renameFolderResult": { @@ -1431,7 +1441,25 @@ "targetExists": "Un dossier portant ce nom existe déjà ici", "busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre d’annulation.", "unsupported": "Le renommage de dossiers n’est pas pris en charge sur cette page", - "noRoot": "Aucune racine de modèle n’est configurée" + "missing": "Ce dossier n’existe plus sur le disque. Actualisez la barre latérale et réessayez.", + "symlink": "Ce dossier est un lien symbolique ; il ne peut pas être renommé depuis la barre latérale." + }, + "folderRoot": { + "chooseTitle": "Ce dossier existe dans plusieurs racines de modèle", + "symlinkOption": "lien symbolique", + "rootsHint": "Sélectionnez les racines de modèle d’où supprimer ce dossier :", + "checkingStatus": "vérification...", + "okStatus": "aucun modèle", + "notEmptyStatus": "contient {count} fichier(s) de modèle — supprimez-les ou déplacez-les d’abord", + "notEmptyExcludedStatus": "contient {count} fichier(s) de modèle, dont {excluded} exclus de la bibliothèque", + "busyStatus": "une suppression est encore en attente", + "missingStatus": "n’existe plus sur le disque", + "symlinkStatus": "lien symbolique — impossible à supprimer ici", + "unknownStatus": "vérification impossible", + "deletedStatus": "supprimé" + }, + "folderResult": { + "unresolved": "Impossible de déterminer quelle racine de modèle contient ce dossier. Actualisez la barre latérale et réessayez." }, "dragDrop": { "unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.", diff --git a/locales/he.json b/locales/he.json index 4446f414..ed15bde8 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1409,9 +1409,16 @@ "notEmptyMessage": "בתיקייה זו עדיין יש מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.", "notEmptyMessageCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.", "notEmptyMessageExcluded": "בתיקייה זו עדיין יש {count} קובצי מודלים, שמהם {excluded} מוחרגים מהספרייה. בטל את ההחרגה ב«ניהול מודלים מוחרגים» ומחק אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.", + "missingTitle": "התיקייה לא נמצאה", + "missingMessage": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.", + "symlinkTitle": "התיקייה היא קישור סמלי", + "symlinkMessage": "כל העותקים של תיקייה זו הם קישורים סמליים; מחק את הקישור או את היעד שלו מחוץ ל-LoRA Manager.", "busyTitle": "מחיקה עדיין ממתינה", "checking": "בודק את תוכן התיקייה...", - "confirm": "מחק תיקייה" + "confirm": "מחק תיקייה", + "confirmMulti": "מחק {count} תיקיות", + "deleting": "מוחק...", + "keptNote": "עותקים שלא סומנו ישאירו את התיקייה הזו בסרגל הצד." }, "deleteFolderResult": { "success": "התיקייה \"{name}\" נמחקה", @@ -1422,7 +1429,10 @@ "notEmptyWithCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. רענן את סרגל הצד ונסה שוב.", "busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.", "unsupported": "מחיקת תיקיות אינה נתמכת בדף זה", - "noRoot": "לא הוגדר שורש מודלים" + "missing": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.", + "symlink": "תיקייה זו היא קישור סמלי; לא ניתן למחוק אותה מסרגל הצד.", + "successMulti": "התיקייה \"{name}\" נמחקה מ-{count} שורשי מודלים", + "partial": "נמחקו {count} מתוך {total} עותקים — {failed} לא נמחקו. רענן את סרגל הצד ונסה שוב." }, "renameFolder": "שנה שם תיקייה", "renameFolderResult": { @@ -1431,7 +1441,25 @@ "targetExists": "תיקייה בשם זה כבר קיימת כאן", "busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.", "unsupported": "שינוי שם תיקיות אינו נתמך בדף זה", - "noRoot": "לא הוגדר שורש מודלים" + "missing": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.", + "symlink": "תיקייה זו היא קישור סמלי; לא ניתן לשנות את שמה מסרגל הצד." + }, + "folderRoot": { + "chooseTitle": "תיקייה זו קיימת ביותר משורש מודלים אחד", + "symlinkOption": "קישור סמלי", + "rootsHint": "בחר את שורשי המודלים שמהם למחוק את התיקייה הזו:", + "checkingStatus": "בודק...", + "okStatus": "אין מודלים", + "notEmptyStatus": "מכיל {count} קובצי מודלים — מחק או העבר אותם תחילה", + "notEmptyExcludedStatus": "מכיל {count} קובצי מודלים, שמהם {excluded} מוחרגים מהספרייה", + "busyStatus": "מחיקה עדיין ממתינה", + "missingStatus": "אינו קיים עוד בדיסק", + "symlinkStatus": "קישור סמלי — לא ניתן למחוק כאן", + "unknownStatus": "לא ניתן היה לבדוק", + "deletedStatus": "נמחקה" + }, + "folderResult": { + "unresolved": "לא ניתן לקבוע באיזה שורש מודלים נמצאת התיקייה הזו. רענן את סרגל הצד ונסה שוב." }, "dragDrop": { "unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.", diff --git a/locales/ja.json b/locales/ja.json index 7a924b10..d0553547 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1409,9 +1409,16 @@ "notEmptyMessage": "このフォルダにはまだモデルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。", "notEmptyMessageCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。", "notEmptyMessageExcluded": "このフォルダにはまだ {count} 個のモデルファイルがあり、そのうち {excluded} 個はライブラリから除外されています。「除外モデルを管理」で除外を解除してから削除してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。", + "missingTitle": "フォルダが見つかりません", + "missingMessage": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。", + "symlinkTitle": "フォルダがシンボリックリンクです", + "symlinkMessage": "このフォルダのすべてのコピーがシンボリックリンクです。LoRA Manager の外でリンクまたはそのリンク先を削除してください。", "busyTitle": "保留中の削除があります", "checking": "フォルダの内容を確認しています...", - "confirm": "フォルダを削除" + "confirm": "フォルダを削除", + "confirmMulti": "{count} 個のフォルダを削除", + "deleting": "削除しています...", + "keptNote": "チェックを外したコピーがあると、このフォルダはサイドバーに残ります。" }, "deleteFolderResult": { "success": "フォルダ \"{name}\" を削除しました", @@ -1422,7 +1429,10 @@ "notEmptyWithCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。サイドバーを再読み込みしてからもう一度お試しください。", "busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。", "unsupported": "このページではフォルダを削除できません", - "noRoot": "モデルルートが設定されていません" + "missing": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。", + "symlink": "このフォルダはシンボリックリンクです。サイドバーからは削除できません。", + "successMulti": "{count} 個のモデルルートからフォルダ \"{name}\" を削除しました", + "partial": "{total} 個のコピーのうち {count} 個を削除しました —— {failed} 個は削除できませんでした。サイドバーを再読み込みしてからもう一度お試しください。" }, "renameFolder": "フォルダ名を変更", "renameFolderResult": { @@ -1431,7 +1441,25 @@ "targetExists": "同じ名前のフォルダが既に存在します", "busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。", "unsupported": "このページではフォルダ名を変更できません", - "noRoot": "モデルルートが設定されていません" + "missing": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。", + "symlink": "このフォルダはシンボリックリンクです。サイドバーからはフォルダ名を変更できません。" + }, + "folderRoot": { + "chooseTitle": "このフォルダは複数のモデルルートに存在します", + "symlinkOption": "シンボリックリンク", + "rootsHint": "このフォルダを削除するモデルルートを選択してください:", + "checkingStatus": "確認しています...", + "okStatus": "モデルなし", + "notEmptyStatus": "モデルファイルが {count} 個あります —— 先に削除するか移動してください", + "notEmptyExcludedStatus": "モデルファイルが {count} 個あり、そのうち {excluded} 個はライブラリから除外されています", + "busyStatus": "保留中の削除があります", + "missingStatus": "ディスク上に存在しません", + "symlinkStatus": "シンボリックリンク —— ここからは削除できません", + "unknownStatus": "確認できませんでした", + "deletedStatus": "削除しました" + }, + "folderResult": { + "unresolved": "このフォルダがどのモデルルートにあるかを特定できませんでした。サイドバーを再読み込みしてからもう一度お試しください。" }, "dragDrop": { "unableToResolveRoot": "移動先のパスを特定できません。", diff --git a/locales/ko.json b/locales/ko.json index a90be678..89c30126 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1409,9 +1409,16 @@ "notEmptyMessage": "이 폴더에는 아직 모델이 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.", "notEmptyMessageCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.", "notEmptyMessageExcluded": "이 폴더에는 아직 모델 파일이 {count}개 있으며, 그중 {excluded}개는 라이브러리에서 제외되어 있습니다. '제외된 모델 관리'에서 제외를 해제한 뒤 먼저 삭제하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.", + "missingTitle": "폴더를 찾을 수 없습니다", + "missingMessage": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.", + "symlinkTitle": "폴더가 심볼릭 링크입니다", + "symlinkMessage": "이 폴더의 모든 복사본이 심볼릭 링크입니다. LoRA Manager 외부에서 링크 또는 그 대상을 삭제하세요.", "busyTitle": "대기 중인 삭제 작업이 있습니다", "checking": "폴더 내용을 확인하는 중...", - "confirm": "폴더 삭제" + "confirm": "폴더 삭제", + "confirmMulti": "폴더 {count}개 삭제", + "deleting": "삭제하는 중...", + "keptNote": "선택하지 않은 복사본이 있으면 이 폴더가 사이드바에 계속 표시됩니다." }, "deleteFolderResult": { "success": "\"{name}\" 폴더를 삭제했습니다", @@ -1422,7 +1429,10 @@ "notEmptyWithCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.", "busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.", "unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다", - "noRoot": "모델 루트가 설정되지 않았습니다" + "missing": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.", + "symlink": "이 폴더는 심볼릭 링크이므로 사이드바에서 삭제할 수 없습니다.", + "successMulti": "모델 루트 {count}개에서 \"{name}\" 폴더를 삭제했습니다", + "partial": "복사본 {total}개 중 {count}개를 삭제했습니다 —— {failed}개는 삭제하지 못했습니다. 사이드바를 새로 고친 후 다시 시도하세요." }, "renameFolder": "폴더 이름 바꾸기", "renameFolderResult": { @@ -1431,7 +1441,25 @@ "targetExists": "같은 이름의 폴더가 이미 있습니다", "busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.", "unsupported": "이 페이지에서는 폴더 이름을 바꿀 수 없습니다", - "noRoot": "모델 루트가 설정되지 않았습니다" + "missing": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.", + "symlink": "이 폴더는 심볼릭 링크이므로 사이드바에서 이름을 바꿀 수 없습니다." + }, + "folderRoot": { + "chooseTitle": "이 폴더는 여러 모델 루트에 존재합니다", + "symlinkOption": "심볼릭 링크", + "rootsHint": "이 폴더를 삭제할 모델 루트를 선택하세요:", + "checkingStatus": "확인하는 중...", + "okStatus": "모델 없음", + "notEmptyStatus": "모델 파일이 {count}개 있습니다 —— 먼저 삭제하거나 이동하세요", + "notEmptyExcludedStatus": "모델 파일이 {count}개 있으며, 그중 {excluded}개는 라이브러리에서 제외되어 있습니다", + "busyStatus": "대기 중인 삭제 작업이 있습니다", + "missingStatus": "디스크에 존재하지 않습니다", + "symlinkStatus": "심볼릭 링크 —— 여기에서는 삭제할 수 없습니다", + "unknownStatus": "확인할 수 없습니다", + "deletedStatus": "삭제됨" + }, + "folderResult": { + "unresolved": "이 폴더가 어느 모델 루트에 있는지 확인할 수 없습니다. 사이드바를 새로 고친 후 다시 시도하세요." }, "dragDrop": { "unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.", diff --git a/locales/ru.json b/locales/ru.json index 8a70d6eb..2c86f25e 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1409,9 +1409,16 @@ "notEmptyMessage": "В этой папке ещё есть модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.", "notEmptyMessageCount": "В этой папке ещё есть {count} файл(ов) модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.", "notEmptyMessageExcluded": "В этой папке ещё есть {count} файл(ов) модели, из них {excluded} исключены из библиотеки. Снимите исключение в разделе «Управление исключёнными моделями» и удалите их — удаление папки никогда не затрагивает файлы моделей.", + "missingTitle": "Папка не найдена", + "missingMessage": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.", + "symlinkTitle": "Папка является символической ссылкой", + "symlinkMessage": "Все копии этой папки являются символическими ссылками; удалите ссылку или её цель вне LoRA Manager.", "busyTitle": "Удаление всё ещё отложено", "checking": "Проверка содержимого папки...", - "confirm": "Удалить папку" + "confirm": "Удалить папку", + "confirmMulti": "Удалить папки ({count})", + "deleting": "Удаление...", + "keptNote": "Неотмеченные копии оставят эту папку в боковой панели." }, "deleteFolderResult": { "success": "Папка \"{name}\" удалена", @@ -1422,7 +1429,10 @@ "notEmptyWithCount": "В этой папке ещё есть {count} файл(ов) модели. Обновите боковую панель и повторите попытку.", "busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.", "unsupported": "Удаление папок не поддерживается на этой странице", - "noRoot": "Корневая папка моделей не настроена" + "missing": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.", + "symlink": "Эта папка является символической ссылкой; её нельзя удалить из боковой панели.", + "successMulti": "Папка \"{name}\" удалена из {count} корневых папок моделей", + "partial": "Удалено {count} из {total} копий — не удалось удалить {failed}. Обновите боковую панель и повторите попытку." }, "renameFolder": "Переименовать папку", "renameFolderResult": { @@ -1431,7 +1441,25 @@ "targetExists": "Папка с таким именем уже существует здесь", "busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.", "unsupported": "Переименование папок не поддерживается на этой странице", - "noRoot": "Корневая папка моделей не настроена" + "missing": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.", + "symlink": "Эта папка является символической ссылкой; её нельзя переименовать из боковой панели." + }, + "folderRoot": { + "chooseTitle": "Эта папка существует в нескольких корневых папках моделей", + "symlinkOption": "символическая ссылка", + "rootsHint": "Выберите корневые папки моделей, из которых нужно удалить эту папку:", + "checkingStatus": "проверка...", + "okStatus": "моделей нет", + "notEmptyStatus": "содержит {count} файл(ов) модели — сначала удалите или переместите их", + "notEmptyExcludedStatus": "содержит {count} файл(ов) модели, из них {excluded} исключены из библиотеки", + "busyStatus": "удаление всё ещё отложено", + "missingStatus": "больше не существует на диске", + "symlinkStatus": "символическая ссылка — здесь удалить нельзя", + "unknownStatus": "не удалось проверить", + "deletedStatus": "удалено" + }, + "folderResult": { + "unresolved": "Не удалось определить, в какой корневой папке моделей находится эта папка. Обновите боковую панель и повторите попытку." }, "dragDrop": { "unableToResolveRoot": "Не удалось определить путь назначения для перемещения.", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 2ceb6f17..8018e0dc 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1409,9 +1409,16 @@ "notEmptyMessage": "该文件夹中仍有模型,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。", "notEmptyMessageCount": "该文件夹中仍有 {count} 个模型文件,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。", "notEmptyMessageExcluded": "该文件夹中仍有 {count} 个模型文件,其中 {excluded} 个已从模型库中排除。请先在“管理已排除的模型”中取消排除并删除它们 —— 删除文件夹不会级联删除模型文件。", + "missingTitle": "未找到文件夹", + "missingMessage": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。", + "symlinkTitle": "文件夹是符号链接", + "symlinkMessage": "该文件夹的每一份副本都是符号链接;请在 LoRA Manager 之外删除该链接或其指向的目标。", "busyTitle": "仍有删除操作待处理", "checking": "正在检查文件夹内容...", - "confirm": "删除文件夹" + "confirm": "删除文件夹", + "confirmMulti": "删除 {count} 个文件夹", + "deleting": "正在删除...", + "keptNote": "未勾选的副本会让该文件夹继续显示在侧边栏中。" }, "deleteFolderResult": { "success": "已删除文件夹 \"{name}\"", @@ -1422,7 +1429,10 @@ "notEmptyWithCount": "该文件夹中仍有 {count} 个模型文件。请刷新侧边栏后重试。", "busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。", "unsupported": "此页面不支持删除文件夹", - "noRoot": "未配置模型根目录" + "missing": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。", + "symlink": "该文件夹是符号链接,无法从侧边栏中删除。", + "successMulti": "已从 {count} 个模型根目录中删除文件夹 \"{name}\"", + "partial": "已删除 {total} 份副本中的 {count} 份 —— {failed} 份无法删除。请刷新侧边栏后重试。" }, "renameFolder": "重命名文件夹", "renameFolderResult": { @@ -1431,7 +1441,25 @@ "targetExists": "此处已存在同名文件夹", "busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。", "unsupported": "此页面不支持重命名文件夹", - "noRoot": "未配置模型根目录" + "missing": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。", + "symlink": "该文件夹是符号链接,无法从侧边栏中重命名。" + }, + "folderRoot": { + "chooseTitle": "该文件夹存在于多个模型根目录中", + "symlinkOption": "符号链接", + "rootsHint": "选择要从中删除该文件夹的模型根目录:", + "checkingStatus": "正在检查...", + "okStatus": "无模型", + "notEmptyStatus": "包含 {count} 个模型文件 —— 请先删除或移出这些模型", + "notEmptyExcludedStatus": "包含 {count} 个模型文件,其中 {excluded} 个已从模型库中排除", + "busyStatus": "仍有删除操作待处理", + "missingStatus": "磁盘上已不存在", + "symlinkStatus": "符号链接 —— 无法在此删除", + "unknownStatus": "无法检查", + "deletedStatus": "已删除" + }, + "folderResult": { + "unresolved": "无法确定该文件夹位于哪个模型根目录。请刷新侧边栏后重试。" }, "dragDrop": { "unableToResolveRoot": "无法确定移动的目标路径。", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index a04a4a60..0ca955b0 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1409,9 +1409,16 @@ "notEmptyMessage": "該資料夾中仍有模型,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。", "notEmptyMessageCount": "該資料夾中仍有 {count} 個模型檔案,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。", "notEmptyMessageExcluded": "該資料夾中仍有 {count} 個模型檔案,其中 {excluded} 個已從模型庫中排除。請先在「管理已排除的模型」中取消排除並刪除它們 —— 刪除資料夾不會串聯刪除模型檔案。", + "missingTitle": "未找到資料夾", + "missingMessage": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。", + "symlinkTitle": "資料夾是符號連結", + "symlinkMessage": "該資料夾的每一份副本都是符號連結;請在 LoRA Manager 之外刪除該連結或其指向的目標。", "busyTitle": "仍有刪除操作待處理", "checking": "正在檢查資料夾內容...", - "confirm": "刪除資料夾" + "confirm": "刪除資料夾", + "confirmMulti": "刪除 {count} 個資料夾", + "deleting": "正在刪除...", + "keptNote": "未勾選的副本會讓該資料夾繼續顯示在側邊欄中。" }, "deleteFolderResult": { "success": "已刪除資料夾 \"{name}\"", @@ -1422,7 +1429,10 @@ "notEmptyWithCount": "該資料夾中仍有 {count} 個模型檔案。請重新整理側邊欄後再試。", "busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。", "unsupported": "此頁面不支援刪除資料夾", - "noRoot": "未設定模型根目錄" + "missing": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。", + "symlink": "該資料夾是符號連結,無法從側邊欄中刪除。", + "successMulti": "已從 {count} 個模型根目錄中刪除資料夾 \"{name}\"", + "partial": "已刪除 {total} 份副本中的 {count} 份 —— {failed} 份無法刪除。請重新整理側邊欄後再試。" }, "renameFolder": "重新命名資料夾", "renameFolderResult": { @@ -1431,7 +1441,25 @@ "targetExists": "此處已存在同名資料夾", "busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。", "unsupported": "此頁面不支援重新命名資料夾", - "noRoot": "未設定模型根目錄" + "missing": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。", + "symlink": "該資料夾是符號連結,無法從側邊欄中重新命名。" + }, + "folderRoot": { + "chooseTitle": "該資料夾存在於多個模型根目錄中", + "symlinkOption": "符號連結", + "rootsHint": "選擇要從中刪除該資料夾的模型根目錄:", + "checkingStatus": "正在檢查...", + "okStatus": "無模型", + "notEmptyStatus": "包含 {count} 個模型檔案 —— 請先刪除或移出這些模型", + "notEmptyExcludedStatus": "包含 {count} 個模型檔案,其中 {excluded} 個已從模型庫中排除", + "busyStatus": "仍有刪除操作待處理", + "missingStatus": "磁碟上已不存在", + "symlinkStatus": "符號連結 —— 無法在此刪除", + "unknownStatus": "無法檢查", + "deletedStatus": "已刪除" + }, + "folderResult": { + "unresolved": "無法確定該資料夾位於哪個模型根目錄。請重新整理側邊欄後再試。" }, "dragDrop": { "unableToResolveRoot": "無法確定移動的目標路徑。", diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index f7c74c58..2072fb2c 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -2513,6 +2513,22 @@ class ModelMoveHandler: self._logger.error("Error creating folder: %s", exc, exc_info=True) return web.json_response({"success": False, "error": str(exc)}, status=500) + async def resolve_folder(self, request: web.Request) -> web.Response: + """Resolve a library-relative folder onto the roots that hold it. + + The unified folder tree is a merged relative-path namespace, so the + client cannot tell which root a node came from; this is what lets the + sidebar act on the directory the user actually sees. + """ + try: + folder = request.query.get("folder", "") + result = self._move_service.resolve_folder(folder) + status = 200 if result.get("success") else 400 + return web.json_response(result, status=status) + except Exception as exc: + self._logger.error("Error resolving folder: %s", exc, exc_info=True) + 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() @@ -3614,6 +3630,7 @@ class ModelHandlerSet: "move_model": self.move.move_model, "move_models_bulk": self.move.move_models_bulk, "create_folder": self.move.create_folder, + "resolve_folder": self.move.resolve_folder, "delete_folder": self.move.delete_folder, "rename_folder": self.move.rename_folder, "auto_organize_models": self.auto_organize.auto_organize_models, diff --git a/py/routes/model_route_registrar.py b/py/routes/model_route_registrar.py index 066b3fa3..8955d9d9 100644 --- a/py/routes/model_route_registrar.py +++ b/py/routes/model_route_registrar.py @@ -61,6 +61,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"), RouteDefinition("GET", "/api/lm/{prefix}/roots", "get_model_roots"), RouteDefinition("GET", "/api/lm/{prefix}/folders", "get_folders"), + RouteDefinition("GET", "/api/lm/{prefix}/resolve-folder", "resolve_folder"), RouteDefinition("GET", "/api/lm/{prefix}/folder-tree", "get_folder_tree"), RouteDefinition( "GET", "/api/lm/{prefix}/unified-folder-tree", "get_unified_folder_tree" diff --git a/py/services/model_file_service.py b/py/services/model_file_service.py index 8638c389..a44b2d10 100644 --- a/py/services/model_file_service.py +++ b/py/services/model_file_service.py @@ -561,6 +561,85 @@ class ModelMoveService: return rel.replace(os.sep, "/") return "" + def resolve_folder(self, folder: str) -> Dict[str, Any]: + """Map a library-relative folder onto the directories it stands for. + + The unified folder tree merges every model root into a single + relative-path namespace, so a tree node does not carry the root it came + from: the same relative folder can exist under several roots, or under + one that is not the default root. Folder operations take an absolute + business path, so a caller holding only the rendered node must ask which + directories it maps to instead of assuming a root — assuming one is how + deleting a folder that sits right there fails with "Folder no longer + exists", or, when a same-named folder exists in another root, silently + acts on that other directory. + + Args: + folder: Library-relative folder path (``a/b``; backslashes are + accepted). Absolute paths are rejected — a caller that already + holds an absolute business path does not need this. + + Returns: + Dictionary with the success flag, the normalized relative folder and + ``candidates``: one entry per model root that holds the directory on + disk (``folder_path``/``root``/``is_symlink``), in scanner root + order. An empty list means the directory exists under no root. + """ + try: + relative = self._normalize_relative_folder(folder) + except ValueError as exc: + return {"success": False, "error": str(exc)} + + candidates: List[Dict[str, Any]] = [] + for root in self.scanner.get_model_roots(): + abs_root = os.path.abspath(root) + candidate = os.path.abspath(os.path.join(abs_root, relative)) + # normpath already collapsed any "." / ".." segment; the + # containment check is belt-and-braces, not traversal defence. + if candidate != abs_root and not candidate.startswith(abs_root + os.sep): + continue + if not os.path.isdir(candidate): + continue + candidates.append( + { + "folder_path": candidate.replace(os.sep, "/"), + "root": abs_root.replace(os.sep, "/"), + "is_symlink": os.path.islink(candidate), + } + ) + + return { + "success": True, + "folder": relative.replace(os.sep, "/"), + "candidates": candidates, + } + + @staticmethod + def _normalize_relative_folder(folder: str) -> str: + """Normalize a library-relative folder path, raising ``ValueError``. + + Absolute paths (POSIX, drive-letter or UNC) and paths that climb out of + the library root are refused: both mean the caller is confused about + which space it is working in, and guessing would be worse than an error. + """ + raw = str(folder or "").strip() + if not raw: + raise ValueError("Folder path is required") + + normalized = raw.replace("\\", "/") + if normalized.startswith("/") or (len(normalized) > 1 and normalized[1] == ":"): + raise ValueError("Folder path must be relative to a library root") + + normalized = os.path.normpath(normalized) + if ( + normalized == ".." + or normalized.startswith("../") + or normalized.startswith(".." + os.sep) + ): + raise ValueError("Folder path must stay inside the library root") + + return normalized + 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. @@ -598,7 +677,13 @@ class ModelMoveService: "error": "Symlinked folders cannot be deleted", } if not os.path.isdir(absolute_path): - return {"success": False, "error": "Folder no longer exists"} + # `missing` is its own code so the sidebar can tell "this node's + # directory is gone, refresh" apart from a failed operation. + return { + "success": False, + "code": "missing", + "error": "Folder no longer exists", + } if self._is_model_root(absolute_path): return { @@ -679,7 +764,7 @@ class ModelMoveService: except OSError: # pragma: no cover - best-effort cleanup pass - await self._forget_folder(relative_folder) + await self._forget_folder(relative_folder, absolute_path) return { "success": True, @@ -789,13 +874,20 @@ class ModelMoveService: return set() return {_normalize_match_path(path) for path in paths if path} - async def _forget_folder(self, relative_folder: str) -> None: - """Drop a removed directory from the scanner's folder/cache records.""" + async def _forget_folder( + self, relative_folder: str, absolute_path: Optional[str] = None + ) -> None: + """Drop a removed directory from the scanner's folder/cache records. + + ``absolute_path`` is what the caller actually deleted: the scanner needs + it to purge the right model cards, because the same relative folder can + exist under several roots. + """ 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) + await remove_known_folder(relative_folder, absolute_path) async def rename_folder(self, folder_path: str, new_name: str) -> Dict[str, Any]: """Rename a directory inside the model library roots. @@ -836,7 +928,13 @@ class ModelMoveService: "error": "Symlinked folders cannot be renamed", } if not os.path.isdir(absolute_path): - return {"success": False, "error": "Folder no longer exists"} + # `missing` is its own code so the sidebar can tell "this node's + # directory is gone, refresh" apart from a failed operation. + return { + "success": False, + "code": "missing", + "error": "Folder no longer exists", + } if self._is_model_root(absolute_path): return { diff --git a/static/css/components/modal/delete-modal.css b/static/css/components/modal/delete-modal.css index 08e6821d..719b830a 100644 --- a/static/css/components/modal/delete-modal.css +++ b/static/css/components/modal/delete-modal.css @@ -42,6 +42,12 @@ animation: modalFadeIn 0.2s ease-out; } +/* The label grows to "Delete N folders" when several roots are ticked; a fixed + floor keeps the centred action row from sliding sideways on every toggle. */ +#deleteFolderModal .delete-btn { + min-width: 150px; +} + /* The folder delete confirm button is held disabled while the folder contents are checked against the backend, so it must not look clickable. */ #deleteFolderModal .delete-btn:disabled { @@ -99,4 +105,98 @@ word-break: break-all; text-align: left; line-height: 1.5; -} \ No newline at end of file +} + +/* Root list for a folder that exists under several library roots: one checkbox + per copy, since "the folder" in a merged tree is all of them. Each row carries + its own guard verdict, so a copy that cannot be removed says why instead of + silently dropping out of the list. */ +.delete-folder-roots:empty { + display: none; +} + +.folder-root-picker { + background: var(--lora-surface); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + padding: var(--space-2); + margin: var(--space-2) 0; + text-align: left; + max-height: 200px; + overflow-y: auto; +} + +.folder-root-picker-title { + color: var(--text-color); + font-size: 0.9em; + margin-bottom: var(--space-1, 4px); +} + +.folder-root-option { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 4px 0; + cursor: pointer; + line-height: 1.4; +} + +.folder-root-option.disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.folder-root-option input[type="checkbox"] { + margin-top: 2px; + flex: 0 0 auto; +} + +/* Path over status, so a row keeps its height when the verdict replaces + "checking..." — the modal must not move while it is being read. */ +.folder-root-body { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1 1 auto; +} + +.folder-root-path { + font-family: var(--font-mono); + font-size: 0.85em; + word-break: break-all; +} + +.folder-root-status { + font-size: 0.78em; + opacity: 0.75; + min-height: 1.2em; + line-height: 1.2; +} + +.folder-root-status.blocked { + color: var(--lora-error); + opacity: 0.95; +} + +/* Says what the unchecked copies mean. The line is always in the DOM with its + height reserved, so warning about them cannot resize the modal mid-click. */ +.folder-root-kept-note { + min-height: 1.2em; + margin-top: 4px; + font-size: 0.78em; + line-height: 1.2; + opacity: 0.85; + color: var(--lora-warning, var(--lora-error)); +} + +/* Rename row: same choice, inline. Kept narrow so the row still reads as one + editable line in the sidebar tree. */ +.sidebar-folder-root-select { + flex: 0 0 auto; + max-width: 45%; + font-size: 0.8em; + background: var(--lora-surface); + color: var(--text-color); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); +} diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index 8d0b3dc4..cba60a6e 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -86,6 +86,10 @@ export function getApiEndpoints(modelType) { createFolder: `/api/lm/${modelType}/create-folder`, deleteFolder: `/api/lm/${modelType}/delete-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 + // own absolute path. + resolveFolder: `/api/lm/${modelType}/resolve-folder`, // CivitAI integration fetchCivitai: `/api/lm/${modelType}/fetch-civitai`, diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index d477b123..cce6ffa3 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1426,6 +1426,34 @@ export class BaseModelApiClient { return result; } + /** + * Resolve a library-relative folder onto the directories it stands for. + * + * The unified folder tree merges every model root into one relative-path + * namespace, so a tree node cannot be turned into a business path by + * prefixing a root: the same relative folder may live under several roots, + * or only under one that is not the default root. Callers that hold a node + * ask here first; the answer is what the folder APIs must be given. + * + * @param {string} folderPath Library-relative folder path (e.g. `a/b`) + * @returns {Promise<{folder: string, candidates: Array<{folder_path: string, root: string, is_symlink: boolean}>}>} + * `candidates` is empty when no root holds the directory on disk. + */ + async resolveFolder(folderPath) { + const params = new URLSearchParams({ folder: folderPath }); + const response = await fetch(`${this.apiConfig.endpoints.resolveFolder}?${params}`); + + const result = await response.json().catch(() => ({})); + + if (!response.ok || result.success === false) { + const error = new Error(result.error || `Failed to resolve folder`); + error.code = result.code || null; + throw error; + } + + return result; + } + async fetchUnifiedFolderTree(options = {}) { try { const { includeEmpty = false } = options; diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index 4e31b97c..1ca319a3 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -50,7 +50,14 @@ export class SidebarManager { this._createFolderTempChildren = null; // children container added for a leaf parent during inline creation this._renameFolderPath = null; this._renameFolderNode = null; + // Absolute path the pending delete will hit, and the node it came from: + // the tree path is relative to *some* root, so only the resolved + // absolute path may be handed to the delete API. this._pendingDeleteFolderPath = null; + this._pendingDeleteFolderRelative = null; + this._deleteFolderCandidates = []; + this._deleteFolderRootRows = new Map(); + this._deleteFolderKeptNote = null; this._deleteFolderModalWired = false; // Bumped on every modal open/close so a late dry-run answer can never // repaint a modal the user has already dismissed or retargeted. @@ -717,9 +724,7 @@ export class SidebarManager { } try { - const rootsData = await this.apiClient.fetchModelRoots(); - const roots = rootsData?.roots || []; - const root = this._resolveDefaultRoot(roots); + const root = await this._createFolderRoot(parentPath); if (!root) { showToast('sidebar.createFolderResult.noRoot', {}, 'error'); return false; @@ -759,10 +764,7 @@ export class SidebarManager { return ''; } - const singularName = this.apiClient?.apiConfig?.config?.singularName; - const defaultRoot = singularName - ? state.global?.settings?.[`default_${singularName}_root`] - : ''; + const defaultRoot = this._configuredDefaultRoot(); if (defaultRoot && roots.includes(defaultRoot)) { return defaultRoot; } @@ -770,6 +772,129 @@ export class SidebarManager { return roots[0]; } + /** The library's configured default root (may be empty or not a known root). */ + _configuredDefaultRoot() { + const singularName = this.apiClient?.apiConfig?.config?.singularName; + if (!singularName) return ''; + return state.global?.settings?.[`default_${singularName}_root`] || ''; + } + + /** + * Map a tree-relative folder onto the concrete directories it stands for. + * + * The unified tree merges every model root into one relative-path + * namespace, so a node does not carry the root it came from: the same + * relative folder can exist under several roots, or only under one that is + * not the default root. Folder operations take an absolute business path, + * so they ask the backend which directories the node maps to instead of + * prefixing a root (which is how a folder sitting right there used to fail + * with "Folder no longer exists", and how a same-named folder in another + * root could be the one that got deleted). + * + * @returns {Promise<{candidates: Array, defaultRoot: string}>} + * Candidates are ordered with the default root first; an empty list means + * no root holds the directory on disk (a stale tree node). + * @throws {Error} When the folder cannot be resolved *and* the library has + * several roots — guessing between them is exactly the bug this avoids. + */ + async _resolveFolderCandidates(relativePath) { + const defaultRoot = this._configuredDefaultRoot(); + + if (typeof this.apiClient.resolveFolder === 'function') { + try { + const response = await this.apiClient.resolveFolder(relativePath); + return { + candidates: this._orderCandidates( + Array.isArray(response?.candidates) ? response.candidates : [], + defaultRoot + ), + defaultRoot, + }; + } catch (error) { + // Fall through to the root-prefix fallback below, which is only + // trustworthy while a single root is configured. + console.error('[SidebarManager] Failed to resolve the folder path:', error); + } + } + + const rootsData = await this.apiClient.fetchModelRoots(); + const roots = rootsData?.roots || []; + if (roots.length > 1) { + const error = new Error('Unable to determine which model root holds this folder'); + error.code = 'unresolved'; + throw error; + } + + const root = this._resolveDefaultRoot(roots); + if (!root) return { candidates: [], defaultRoot }; + + // Single root (or no resolver at all): the prefix is unambiguous, but + // it is an assumption — the backend still decides on disk. + return { + candidates: [{ + folder_path: this.combineRootAndRelativePath(root, relativePath), + root, + is_symlink: false, + assumed: true, + }], + defaultRoot, + }; + } + + /** Order candidates with the default root first, keeping the rest stable. */ + _orderCandidates(candidates, defaultRoot) { + if (!defaultRoot) return [...candidates]; + return [...candidates].sort((a, b) => { + const aPreferred = a?.root === defaultRoot ? 0 : 1; + const bPreferred = b?.root === defaultRoot ? 0 : 1; + return aPreferred - bPreferred; + }); + } + + /** + * The candidate an operation preselects: the default root's copy when it has + * one, otherwise the first. Symlinked candidates are never preselected — + * folder operations refuse them. + */ + _preferredCandidate(candidates, defaultRoot) { + const selectable = (candidates || []).filter((candidate) => !candidate?.is_symlink); + if (selectable.length === 0) return null; + const preferredRoot = defaultRoot || this._configuredDefaultRoot(); + return selectable.find((candidate) => candidate.root === preferredRoot) || selectable[0]; + } + + async _resolveFolderCandidatesSafe(relativePath) { + try { + return await this._resolveFolderCandidates(relativePath); + } catch (error) { + console.error('[SidebarManager] Folder resolution failed:', error); + return null; + } + } + + /** + * Root a new folder is created under. + * + * A folder created inside an existing node belongs next to it, so the + * parent's own root wins when the parent resolves to exactly one directory. + * Everything else (root-level creation, an ambiguous or unreadable parent) + * keeps the old behaviour: the library's configured default root. + */ + async _createFolderRoot(parentPath) { + if (parentPath) { + const resolution = await this._resolveFolderCandidatesSafe(parentPath); + const selectable = (resolution?.candidates || []).filter( + (candidate) => !candidate.is_symlink + ); + if (selectable.length === 1) { + return selectable[0].root; + } + } + + const rootsData = await this.apiClient.fetchModelRoots(); + return this._resolveDefaultRoot(rootsData?.roots || []); + } + handleCreateFolderCancel() { this.hideCreateFolderInput(); } @@ -782,8 +907,13 @@ export class SidebarManager { * Mirrors the create-folder inline row (Enter confirms, Escape/blur * cancels) but is inserted where the node sits and hides that node while * editing, so the tree does not jump. + * + * The node is relative to the merged tree, so the directories it stands for + * are resolved first: a folder that exists under several roots gets a root + * picker in the row (renaming the wrong copy is worse than asking), while + * the ordinary single-root node keeps the plain input. */ - showRenameFolderInput(path) { + async showRenameFolderInput(path) { if (!path) return; this.hideRenameFolderInput(); @@ -794,7 +924,25 @@ export class SidebarManager { const node = this._findFolderNodeElement(folderTree, path); if (!node) return; - const row = this._buildRenameFolderRow(this._folderLeafName(path)); + const resolution = await this._resolveFolderCandidatesSafe(path); + // The tree may have re-rendered while the resolver was in flight. + if (!node.isConnected) return; + + const candidates = resolution?.candidates || []; + if (candidates.length === 0) { + showToast('sidebar.renameFolderResult.missing', {}, 'warning'); + return; + } + + const selected = this._preferredCandidate(candidates, resolution?.defaultRoot); + if (!selected) { + showToast('sidebar.renameFolderResult.symlink', {}, 'warning'); + return; + } + + const row = this._buildRenameFolderRow( + this._folderLeafName(path), candidates, selected.folder_path + ); node.parentElement.insertBefore(row, node); node.style.display = 'none'; @@ -823,7 +971,7 @@ export class SidebarManager { }); } - _buildRenameFolderRow(currentName) { + _buildRenameFolderRow(currentName, candidates = [], selectedPath = '') { const isListMode = this.displayMode === 'list'; const row = document.createElement('div'); row.id = 'sidebarRenameFolderInput'; @@ -839,11 +987,41 @@ export class SidebarManager { class="sidebar-create-folder-input sidebar-rename-folder-input" aria-label="${escapeAttribute(translate('sidebar.renameFolder', {}, 'Rename folder'))}" value="${escapeAttribute(currentName)}" /> + ${this._folderRootSelectHtml(candidates, selectedPath)} `; return row; } + /** + * Root picker markup for an ambiguous folder, or '' when there is nothing to + * choose (single candidate) — a ` + ${options} + + `; + } + _findFolderNodeElement(folderTree, path) { return [...folderTree.querySelectorAll('.sidebar-tree-node, .sidebar-folder-item')] .find(element => element.dataset.path === path) || null; @@ -877,12 +1055,17 @@ export class SidebarManager { } async handleRenameFolderSubmit() { - const input = document.querySelector('#sidebarRenameFolderInput .sidebar-rename-folder-input'); + const row = document.getElementById('sidebarRenameFolderInput'); + const input = row?.querySelector('.sidebar-rename-folder-input'); const path = this._renameFolderPath; if (!input || !path) { return; } + // Read the root picker (present only for an ambiguous node) before the + // row is removed: it holds the copy the user chose to rename. + const selectedPath = row.querySelector('.sidebar-folder-root-select')?.value || null; + const newName = input.value.trim(); if (!newName) { showToast('sidebar.dragDrop.emptyFolderName', {}, 'warning'); @@ -900,26 +1083,37 @@ export class SidebarManager { return; } - await this._renameFolder(path, newName); + await this._renameFolder(path, newName, selectedPath); } - async _renameFolder(relativePath, newName) { + async _renameFolder(relativePath, newName, absolutePath = null) { if (!this._supportsFolderManagement() || typeof this.apiClient.renameFolder !== 'function') { showToast('sidebar.renameFolderResult.unsupported', {}, 'error'); return false; } try { - const rootsData = await this.apiClient.fetchModelRoots(); - const roots = rootsData?.roots || []; - const root = this._resolveDefaultRoot(roots); - if (!root) { - showToast('sidebar.renameFolderResult.noRoot', {}, 'error'); - return false; + let target = absolutePath; + if (!target) { + const resolution = await this._resolveFolderCandidates(relativePath); + const selected = this._preferredCandidate( + resolution?.candidates, resolution?.defaultRoot + ); + if (!selected) { + const empty = (resolution?.candidates || []).length === 0; + showToast( + empty + ? 'sidebar.renameFolderResult.missing' + : 'sidebar.renameFolderResult.symlink', + {}, + 'warning' + ); + return false; + } + target = selected.folder_path; } - const absolutePath = this.combineRootAndRelativePath(root, relativePath); - const result = await this.apiClient.renameFolder(absolutePath, newName); + const result = await this.apiClient.renameFolder(target, newName); // Carry the user's place across the rename: the persisted // selection and the expanded set would otherwise point at a folder @@ -937,6 +1131,10 @@ export class SidebarManager { showToast('sidebar.renameFolderResult.targetExists', {}, 'warning'); } else if (error?.code === 'busy') { showToast('sidebar.renameFolderResult.busy', {}, 'warning'); + } else if (error?.code === 'missing') { + showToast('sidebar.renameFolderResult.missing', {}, 'warning'); + } else if (error?.code === 'unresolved') { + showToast('sidebar.folderResult.unresolved', {}, 'error'); } else { showToast( 'sidebar.renameFolderResult.failed', @@ -985,13 +1183,15 @@ export class SidebarManager { /** * Open the folder delete modal for *path*. * - * The models-only set that dims empty nodes is only a prediction: it is - * built from the scanned, non-excluded models, while the delete guard walks - * the folder on disk and refuses on any weight file — excluded ones - * included. So the modal opens on the prediction for an instant answer and - * is then corrected by a dry run of the very delete the user is about to - * confirm, which is the only way the button can never contradict the - * backend (see `_verifyFolderContents`). + * Two things are settled before the user can confirm. The node is first + * resolved to the directories it actually stands for: the tree merges every + * model root, so "delete this node" must not be answered with a path + * fabricated from the default root (see `_resolveFolderCandidates`). The + * models-only set that dims empty nodes is then corrected by a dry run of + * the very delete being confirmed: it is built from scanned, non-excluded + * models, while the backend walks the folder on disk and refuses on any + * weight file — excluded ones included. When several roots hold the folder, + * the modal offers each directory and re-checks the one the user picks. */ async showDeleteFolderModal(path) { const modal = document.getElementById('deleteFolderModal'); @@ -1013,6 +1213,10 @@ export class SidebarManager { } : { state: 'confirm' }; + this._pendingDeleteFolderRelative = path; + this._pendingDeleteFolderPath = null; + this._deleteFolderCandidates = []; + const probePending = this._supportsFolderManagement() && typeof this.apiClient.deleteFolder === 'function'; @@ -1025,16 +1229,522 @@ export class SidebarManager { if (!probePending) return; - await this._verifyFolderContents(path, token, prediction); + await this._resolveDeleteFolderTarget(path, token, prediction); + } + + /** + * Resolve the node, then check the directory the delete would hit. + * + * Resolution failing (an older client, a request error) keeps the previous + * behaviour: the tree prediction is shown and the real delete reports its + * own error. A library with several roots never falls back to guessing one + * — that guess is the bug this path exists to remove. + */ + async _resolveDeleteFolderTarget(relativePath, token, prediction) { + const resolution = await this._resolveFolderCandidatesSafe(relativePath); + if (token !== this._deleteFolderProbeToken) return; + + if (!resolution) { + this._renderDeleteFolderModal(relativePath, prediction.state, prediction); + return; + } + + const candidates = resolution.candidates || []; + this._deleteFolderCandidates = candidates; + + if (candidates.length === 0) { + this._renderDeleteFolderModal(relativePath, 'missing', {}); + return; + } + + if (candidates.length > 1) { + // The node stands for several directories: offer them as a list the + // user can narrow down, instead of silently picking one. + await this._openMultiRootDelete(relativePath, candidates, token); + return; + } + + if (candidates[0].is_symlink) { + // The backend refuses symlinked directories; asking it anyway would + // come back as a codeless error and repaint a confirm button. + this._renderDeleteFolderModal(relativePath, 'symlink', {}); + return; + } + + await this._probeDeleteFolder(relativePath, candidates[0], token, prediction); + } + + /** + * Dry-run the delete against one resolved directory and paint the verdict. + * + * The dry run is authoritative: it walks the folder on disk and applies the + * same guard the real delete uses, so it catches everything the sidebar + * prediction cannot know — excluded models, weight files no scanner indexes + * (a lora folder holding only a `.gguf`, say) and files added after the + * last scan. A check that fails for any other reason falls back to the + * prediction, leaving the real delete to report its own error. + */ + async _probeDeleteFolder(relativePath, candidate, token, prediction) { + const absolutePath = candidate.folder_path; + this._pendingDeleteFolderPath = absolutePath; + + const options = { selectedPath: absolutePath }; + + try { + await this.apiClient.deleteFolder(absolutePath, { dryRun: true }); + if (token !== this._deleteFolderProbeToken) return; + this._renderDeleteFolderModal(relativePath, 'confirm', options); + } catch (error) { + if (token !== this._deleteFolderProbeToken) return; + if (error?.code === 'not_empty') { + this._renderDeleteFolderModal(relativePath, 'blocked', { + ...this._notEmptyBlocker(error?.manifest), + ...options, + }); + } else if (error?.code === 'busy') { + this._renderDeleteFolderModal(relativePath, 'busy', options); + } else if (error?.code === 'missing') { + this._renderDeleteFolderModal(relativePath, 'missing', {}); + } else { + this._renderDeleteFolderModal(relativePath, prediction.state, { + ...prediction, + ...options, + }); + } + } + } + + /** + * Open the multi-root form of the modal: one checkbox per directory the node + * stands for, each checked by default and each checked against the backend + * on its own. + * + * Deleting "the folder" in a merged tree means deleting every copy the user + * keeps selected — picking exactly one was an artefact of the single-target + * API, not of what the tree shows. The rows are built once and only their + * status text is updated afterwards: re-rendering the list on every click + * made the modal jump and stole the focus of the checkbox being clicked. + */ + async _openMultiRootDelete(relativePath, candidates, token) { + this._deleteFolderCandidates = candidates.map((candidate) => ({ + ...candidate, + // A symlinked copy cannot be removed from here, so it starts + // unselected; everything else is selected because the node the user + // right-clicked represents all of them. + checked: !candidate.is_symlink, + status: candidate.is_symlink ? 'symlink' : 'checking', + modelCount: 0, + excludedCount: 0, + })); + + this._renderMultiRootDeleteModal(relativePath); + + await Promise.all(this._deleteFolderCandidates.map( + (entry) => this._probeDeleteCandidateRow(entry, token) + )); + + if (token !== this._deleteFolderProbeToken) return; + this._refreshDeleteFolderConfirm(); + } + + /** Paint the modal shell for an ambiguous node; rows carry the detail. */ + _renderMultiRootDeleteModal(relativePath) { + const modal = document.getElementById('deleteFolderModal'); + if (!modal) return; + + const title = modal.querySelector('[data-role="title"]'); + const message = modal.querySelector('[data-role="message"]'); + const info = modal.querySelector('[data-role="info"]'); + const roots = modal.querySelector('[data-role="roots"]'); + const confirmBtn = modal.querySelector('[data-action="confirm-delete-folder"]'); + + this._pendingDeleteFolderPath = null; + + // The modal-level text stays generic: what differs between the copies is + // per-row, and swapping these strings around was half of the jumping. + 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.' + ); + // The relative path is the node the user acted on; every absolute path + // lives on its row below, so none of them is singled out as "the" one. + const pathLine = `${escapeHtml(translate('sidebar.deleteFolderModal.folderLabel', {}, 'Folder'))}: ${escapeHtml(relativePath)}`; + info.innerHTML = pathLine; + + if (roots) { + roots.replaceChildren(this._buildDeleteFolderRootList()); + } + + confirmBtn.style.display = ''; + confirmBtn.disabled = true; + modal.dataset.state = 'multi'; + } + + /** + * Build the checkbox list once per modal open and remember the nodes so the + * probes can update a row without touching its neighbours. + */ + _buildDeleteFolderRootList() { + const list = document.createElement('div'); + list.className = 'folder-root-picker'; + + const heading = document.createElement('div'); + heading.className = 'folder-root-picker-title'; + heading.textContent = translate( + 'sidebar.folderRoot.rootsHint', {}, + 'Select the model roots to delete this folder from:' + ); + list.appendChild(heading); + + this._deleteFolderRootRows = new Map(); + + for (const entry of this._deleteFolderCandidates) { + const row = document.createElement('label'); + row.className = 'folder-root-option'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.name = 'deleteFolderRoot'; + checkbox.value = entry.folder_path; + checkbox.checked = entry.checked; + + const body = document.createElement('span'); + body.className = 'folder-root-body'; + + const path = document.createElement('span'); + path.className = 'folder-root-path'; + path.textContent = entry.folder_path; + + const status = document.createElement('span'); + status.className = 'folder-root-status'; + + body.append(path, status); + row.append(checkbox, body); + list.appendChild(row); + + this._deleteFolderRootRows.set(entry.folder_path, { row, checkbox, status }); + } + + // Always present (reserved height) so warning about the unchecked copies + // does not resize the modal the moment a box is clicked. + const keptNote = document.createElement('div'); + keptNote.className = 'folder-root-kept-note'; + list.appendChild(keptNote); + this._deleteFolderKeptNote = keptNote; + + for (const entry of this._deleteFolderCandidates) { + this._updateDeleteFolderRow(entry); + } + + return list; + } + + /** + * Check one copy against the delete guard and record the verdict on its row. + * + * This is the same dry run the single-target modal does, so the checkbox a + * user can tick is never one the backend would refuse. + */ + async _probeDeleteCandidateRow(entry, token) { + try { + await this.apiClient.deleteFolder(entry.folder_path, { dryRun: true }); + if (token !== this._deleteFolderProbeToken) return; + entry.status = 'ok'; + } catch (error) { + if (token !== this._deleteFolderProbeToken) return; + if (error?.code === 'not_empty') { + entry.status = 'not_empty'; + entry.modelCount = Number(error?.manifest?.model_count) || 0; + entry.excludedCount = Number(error?.manifest?.excluded_model_count) || 0; + entry.checked = false; + } else if (error?.code === 'busy') { + entry.status = 'busy'; + entry.checked = false; + } else if (error?.code === 'missing') { + entry.status = 'missing'; + entry.checked = false; + } else { + // Unknown failure: keep it selected and let the real delete + // report its own error, exactly like the single-target probe. + entry.status = 'unknown'; + } + } + + this._updateDeleteFolderRow(entry); + } + + /** Repaint one row in place (never rebuild it — that is what jumped). */ + _updateDeleteFolderRow(entry) { + const nodes = this._deleteFolderRootRows?.get(entry.folder_path); + if (!nodes) return; + + const selectable = this._isCandidateDeletable(entry); + nodes.row.classList.toggle('disabled', !selectable); + nodes.checkbox.disabled = !selectable; + nodes.checkbox.checked = entry.checked; + nodes.status.textContent = this._deleteFolderRowStatus(entry); + nodes.status.classList.toggle('blocked', entry.status === 'not_empty'); + } + + /** Whether a probed copy may be selected for deletion. */ + _isCandidateDeletable(entry) { + return entry.status === 'ok' || entry.status === 'unknown' || entry.status === 'checking'; + } + + _deleteFolderRowStatus(entry) { + switch (entry.status) { + case 'checking': + return translate('sidebar.folderRoot.checkingStatus', {}, 'checking...'); + case 'not_empty': + return entry.excludedCount > 0 + ? translate( + 'sidebar.folderRoot.notEmptyExcludedStatus', + { count: entry.modelCount, excluded: entry.excludedCount }, + `contains ${entry.modelCount} model file(s), ${entry.excludedCount} of them excluded from the library` + ) + : translate( + 'sidebar.folderRoot.notEmptyStatus', + { count: entry.modelCount }, + `contains ${entry.modelCount} model file(s) — delete or move them first` + ); + case 'busy': + return translate('sidebar.folderRoot.busyStatus', {}, 'a deletion is still pending'); + case 'missing': + return translate('sidebar.folderRoot.missingStatus', {}, 'no longer exists on disk'); + case 'symlink': + return translate( + 'sidebar.folderRoot.symlinkStatus', {}, + 'symbolic link — cannot be deleted here' + ); + case 'unknown': + return translate('sidebar.folderRoot.unknownStatus', {}, 'could not be checked'); + case 'deleted': + return translate('sidebar.folderRoot.deletedStatus', {}, 'deleted'); + default: + return translate('sidebar.folderRoot.okStatus', {}, 'no models'); + } + } + + /** Toggle one copy's checkbox; nothing is re-rendered. */ + _toggleDeleteFolderCandidate(absolutePath, checked) { + const entry = (this._deleteFolderCandidates || []) + .find((candidate) => candidate.folder_path === absolutePath); + if (!entry || !this._isCandidateDeletable(entry)) return; + + entry.checked = checked; + this._refreshDeleteFolderConfirm(); + } + + /** + * Reflect the current selection on the confirm button, and explain the dead + * end when nothing can be removed at all. + */ + _refreshDeleteFolderConfirm() { + const modal = document.getElementById('deleteFolderModal'); + if (!modal) return; + + const title = modal.querySelector('[data-role="title"]'); + const message = modal.querySelector('[data-role="message"]'); + const confirmBtn = modal.querySelector('[data-action="confirm-delete-folder"]'); + const candidates = this._deleteFolderCandidates || []; + + const pending = candidates.some((entry) => entry.status === 'checking'); + const deletable = candidates.filter((entry) => this._isCandidateDeletable(entry)); + const selected = deletable.filter((entry) => entry.checked); + const blocked = candidates.filter((entry) => entry.status === 'not_empty'); + + if (this._deleteFolderKeptNote) { + // Deleting a subset leaves the sidebar node in place (the other root + // still holds the folder), which is the surprise this warns about. + this._deleteFolderKeptNote.textContent = selected.length < deletable.length + ? translate( + 'sidebar.deleteFolderModal.keptNote', {}, + 'Unchecked copies keep this folder in the sidebar.' + ) + : ''; + } + + if (!pending && deletable.length === 0) { + // Not one copy can be removed: say why, using the blocking rows. + title.textContent = translate( + 'sidebar.deleteFolderModal.notEmptyTitle', {}, 'Folder is not empty' + ); + message.textContent = blocked.length > 0 + ? translate( + 'sidebar.deleteFolderModal.notEmptyMessage', {}, + 'This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.' + ) + : translate( + 'sidebar.deleteFolderModal.missingMessage', {}, + 'This folder no longer exists on disk. Refresh the sidebar and try again.' + ); + confirmBtn.style.display = 'none'; + confirmBtn.disabled = true; + return; + } + + 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.' + ); + confirmBtn.style.display = ''; + confirmBtn.disabled = pending || selected.length === 0; + confirmBtn.textContent = selected.length > 1 + ? translate( + 'sidebar.deleteFolderModal.confirmMulti', + { count: selected.length }, + `Delete ${selected.length} folders` + ) + : translate('sidebar.deleteFolderModal.confirm', {}, 'Delete folder'); + } + + /** + * Delete every selected copy, then report once. + * + * Each copy keeps its own guard: a copy that grew models since the check is + * refused by the backend and counted as a failure rather than taking the + * successful ones down with it. + */ + async _deleteCheckedDeleteFolders(relativePath) { + const targets = (this._deleteFolderCandidates || []) + .filter((entry) => entry.checked && this._isCandidateDeletable(entry)) + .map((entry) => ({ path: entry.folder_path, entry })); + if (targets.length === 0) return false; + + const confirmBtn = document.querySelector( + '#deleteFolderModal [data-action="confirm-delete-folder"]' + ); + if (confirmBtn) { + confirmBtn.disabled = true; + confirmBtn.textContent = translate( + 'sidebar.deleteFolderModal.deleting', {}, 'Deleting...' + ); + } + + const deleted = []; + const failed = []; + for (const target of targets) { + try { + const result = await this.apiClient.deleteFolder(target.path); + deleted.push({ path: target.path, result }); + this._markDeleteFolderRowDone(target.entry); + } catch (error) { + console.error('[SidebarManager] Error deleting folder:', target.path, error); + failed.push({ path: target.path, error }); + } + } + + this.hideDeleteFolderModal(); + + // Drop the node (and its subtree) from the persisted expand state + // before refreshing, otherwise stale keys accumulate forever. + this._forgetRemovedFolder(relativePath); + await this.refresh(); + + if (deleted.length === 0) { + this._reportDeleteFolderFailure(failed[0]?.error); + return false; + } + + if (failed.length > 0) { + showToast( + 'sidebar.deleteFolderResult.partial', + { count: deleted.length, total: targets.length, failed: failed.length }, + 'warning' + ); + return true; + } + + this._reportFoldersDeleted(relativePath, deleted); + return true; + } + + _markDeleteFolderRowDone(entry) { + entry.checked = false; + entry.status = 'deleted'; + this._updateDeleteFolderRow(entry); + } + + /** One toast for a multi-copy delete, with an undo that restores every copy. */ + _reportFoldersDeleted(relativePath, deleted) { + const restorable = deleted.every((entry) => entry.result?.restorable); + + if (restorable) { + showActionToast( + deleted.length > 1 + ? 'sidebar.deleteFolderResult.successMulti' + : 'sidebar.deleteFolderResult.success', + deleted.length > 1 + ? { name: relativePath, count: deleted.length } + : { name: relativePath }, + 'success', + { + actionText: translate('toast.undo.action', {}, 'Undo'), + // The undo recreates every directory that was removed. + onAction: () => this._restoreDeletedFolder( + deleted.map((entry) => entry.path), relativePath + ), + } + ); + return; + } + + const items = deleted.reduce( + (total, entry) => total + (entry.result?.file_count || 0) + (entry.result?.dir_count || 0), + 0 + ); + showToast( + 'sidebar.deleteFolderResult.successWithFiles', + { name: relativePath, count: items }, + 'success' + ); + } + + /** Shared failure reporting for one or many refused copies. */ + _reportDeleteFolderFailure(error) { + if (error?.code === 'not_empty') { + // The dry run normally catches this before the user can confirm; + // reaching here means the folder changed in between. + const modelCount = Number(error?.manifest?.model_count) || 0; + if (modelCount > 0) { + showToast( + 'sidebar.deleteFolderResult.notEmptyWithCount', + { count: modelCount }, + 'warning' + ); + } else { + showToast('sidebar.deleteFolderResult.notEmpty', {}, 'warning'); + } + } else if (error?.code === 'busy') { + showToast('sidebar.deleteFolderResult.busy', {}, 'warning'); + } else if (error?.code === 'missing') { + showToast('sidebar.deleteFolderResult.missing', {}, 'warning'); + } else if (error?.code === 'unresolved') { + showToast('sidebar.folderResult.unresolved', {}, 'error'); + } else { + showToast( + 'sidebar.deleteFolderResult.failed', + { message: error?.message || 'Unknown error' }, + 'error' + ); + } } /** * Paint one state of the folder delete modal. * * `state` is 'confirm' (deletion may proceed), 'blocked' (models would be - * cascaded over, which the backend refuses) or 'busy' (a staged delete is - * still pending inside the folder). `checking` keeps the confirm button - * disabled while the authoritative server-side check runs. + * cascaded over, which the backend refuses), 'busy' (a staged delete is + * still pending inside the folder), 'missing' (no root holds the node's + * directory any more) or 'symlink' (every copy is a symbolic link, which the + * backend refuses to remove). `checking` keeps the confirm button disabled + * while the authoritative server-side check runs. + * + * This is the single-target form, used when the node maps to at most one + * directory; an ambiguous node renders the multi-root list instead (see + * `_renderMultiRootDeleteModal`). */ _renderDeleteFolderModal(path, state, options = {}) { const modal = document.getElementById('deleteFolderModal'); @@ -1043,14 +1753,24 @@ export class SidebarManager { const title = modal.querySelector('[data-role="title"]'); const message = modal.querySelector('[data-role="message"]'); const info = modal.querySelector('[data-role="info"]'); + const roots = modal.querySelector('[data-role="roots"]'); const confirmBtn = modal.querySelector('[data-action="confirm-delete-folder"]'); const checking = Boolean(options.checking); + const selectedPath = options.selectedPath || ''; - const pathLine = `${escapeHtml(translate('sidebar.deleteFolderModal.folderLabel', {}, 'Folder'))}: ${escapeHtml(path)}`; + const displayPath = selectedPath || path; + const pathLine = `${escapeHtml(translate('sidebar.deleteFolderModal.folderLabel', {}, 'Folder'))}: ${escapeHtml(displayPath)}`; const extraLines = []; + // Anything the chooser owned is stale once this form is painted. + this._deleteFolderRootRows = new Map(); + this._deleteFolderKeptNote = null; + if (roots) roots.replaceChildren(); + if (state === 'confirm') { - this._pendingDeleteFolderPath = path; + // Only a resolved directory may be deleted: without one the + // confirmation goes back through resolution in `_deleteFolder`. + this._pendingDeleteFolderPath = selectedPath || null; title.textContent = translate( 'sidebar.deleteFolderModal.title', {}, 'Delete folder?' ); @@ -1067,6 +1787,9 @@ export class SidebarManager { } confirmBtn.style.display = ''; confirmBtn.disabled = checking; + confirmBtn.textContent = translate( + 'sidebar.deleteFolderModal.confirm', {}, 'Delete folder' + ); } else { this._pendingDeleteFolderPath = null; if (state === 'busy') { @@ -1077,6 +1800,22 @@ export class SidebarManager { 'sidebar.deleteFolderResult.busy', {}, 'A deletion is still pending inside this folder. Wait for the undo window to expire.' ); + } else if (state === 'missing') { + title.textContent = translate( + 'sidebar.deleteFolderModal.missingTitle', {}, 'Folder not found' + ); + message.textContent = translate( + 'sidebar.deleteFolderModal.missingMessage', {}, + 'This folder no longer exists on disk. Refresh the sidebar and try again.' + ); + } else if (state === 'symlink') { + title.textContent = translate( + 'sidebar.deleteFolderModal.symlinkTitle', {}, 'Folder is a symbolic link' + ); + message.textContent = translate( + 'sidebar.deleteFolderModal.symlinkMessage', {}, + 'Every copy of this folder is a symbolic link; remove the link or its target outside LoRA Manager.' + ); } else { title.textContent = translate( 'sidebar.deleteFolderModal.notEmptyTitle', {}, 'Folder is not empty' @@ -1093,6 +1832,7 @@ export class SidebarManager { } if (checking) { + extraLines.push(escapeHtml(translate( 'sidebar.deleteFolderModal.checking', {}, 'Checking the folder contents...' ))); @@ -1102,49 +1842,6 @@ export class SidebarManager { modal.dataset.state = state; } - /** - * Ask the backend what deleting *relativePath* would actually remove. - * - * The dry run is authoritative: it walks the folder on disk and applies the - * same guard the real delete uses, so it catches everything the sidebar - * prediction cannot know — excluded models, weight files no scanner indexes - * (a lora folder holding only a `.gguf`, say) and files added after the - * last scan. A check that fails for any other reason falls back to the - * prediction, leaving the real delete to report its own error. - */ - async _verifyFolderContents(relativePath, token, prediction) { - let resolved = null; - try { - resolved = await this._resolveFolderAbsolutePath(relativePath); - } catch (error) { - console.error('[SidebarManager] Failed to resolve the folder path:', error); - } - - if (token !== this._deleteFolderProbeToken) return; - - if (!resolved) { - this._renderDeleteFolderModal(relativePath, prediction.state, prediction); - return; - } - - try { - await this.apiClient.deleteFolder(resolved.absolutePath, { dryRun: true }); - if (token !== this._deleteFolderProbeToken) return; - this._renderDeleteFolderModal(relativePath, 'confirm'); - } catch (error) { - if (token !== this._deleteFolderProbeToken) return; - if (error?.code === 'not_empty') { - this._renderDeleteFolderModal( - relativePath, 'blocked', this._notEmptyBlocker(error?.manifest) - ); - } else if (error?.code === 'busy') { - this._renderDeleteFolderModal(relativePath, 'busy'); - } else { - this._renderDeleteFolderModal(relativePath, prediction.state, prediction); - } - } - } - /** * Message for a refused delete, split by whether the blocking models are * excluded from the library — the case where the sidebar legitimately shows @@ -1177,24 +1874,12 @@ export class SidebarManager { }; } - /** - * Resolve a tree-relative folder path to the absolute business path the - * folder APIs expect, or null when no model root is configured. - */ - async _resolveFolderAbsolutePath(relativePath) { - const rootsData = await this.apiClient.fetchModelRoots(); - const roots = rootsData?.roots || []; - const root = this._resolveDefaultRoot(roots); - if (!root) return null; - - return { - root, - absolutePath: this.combineRootAndRelativePath(root, relativePath), - }; - } - hideDeleteFolderModal() { this._pendingDeleteFolderPath = null; + this._pendingDeleteFolderRelative = null; + this._deleteFolderCandidates = []; + this._deleteFolderRootRows = new Map(); + this._deleteFolderKeptNote = null; // Retire any in-flight check so a late answer cannot repaint a modal // the user already dismissed. this._deleteFolderProbeToken += 1; @@ -1202,28 +1887,53 @@ export class SidebarManager { } async handleDeleteFolderConfirm() { - const path = this._pendingDeleteFolderPath; + const relativePath = this._pendingDeleteFolderRelative; + const multiRoot = (this._deleteFolderCandidates || []).length > 1; + if (multiRoot) { + // The selection lives on the rows, so the multi-copy path reads it + // before the modal state is cleared. + return this._deleteCheckedDeleteFolders(relativePath); + } + + const absolutePath = this._pendingDeleteFolderPath; this.hideDeleteFolderModal(); - if (!path) return false; + if (!relativePath) return false; - return this._deleteFolder(path); + // The absolute path is absent when the target could not be resolved + // before confirming (older client, failed request); `_deleteFolder` + // then resolves it itself rather than trusting a fabricated root. + return this._deleteFolder(relativePath, absolutePath); } - async _deleteFolder(relativePath) { + async _deleteFolder(relativePath, absolutePath = null) { if (!this._supportsFolderManagement() || typeof this.apiClient.deleteFolder !== 'function') { showToast('sidebar.deleteFolderResult.unsupported', {}, 'error'); return false; } try { - const resolved = await this._resolveFolderAbsolutePath(relativePath); - if (!resolved) { - showToast('sidebar.deleteFolderResult.noRoot', {}, 'error'); - return false; + let target = absolutePath; + if (!target) { + const resolution = await this._resolveFolderCandidates(relativePath); + const selected = this._preferredCandidate( + resolution?.candidates, resolution?.defaultRoot + ); + if (!selected) { + const empty = (resolution?.candidates || []).length === 0; + showToast( + empty + ? 'sidebar.deleteFolderResult.missing' + : 'sidebar.deleteFolderResult.symlink', + {}, + 'warning' + ); + return false; + } + target = selected.folder_path; } - const result = await this.apiClient.deleteFolder(resolved.absolutePath); + const result = await this.apiClient.deleteFolder(target); // Drop the node (and its subtree) from the persisted expand state // before refreshing, otherwise stale keys accumulate forever. A @@ -1240,7 +1950,9 @@ export class SidebarManager { // the same 20s undo affordance the model delete flow uses. showActionToast('sidebar.deleteFolderResult.success', { name }, 'success', { actionText: translate('toast.undo.action', {}, 'Undo'), - onAction: () => this._restoreDeletedFolder(resolved.absolutePath, relativePath), + // The undo recreates the very directory that was removed, so + // it has to carry the resolved path — not a root guess. + onAction: () => this._restoreDeletedFolder(target, relativePath), }); } else { showToast( @@ -1253,35 +1965,23 @@ export class SidebarManager { return true; } catch (error) { console.error('[SidebarManager] Error deleting folder:', error); - if (error?.code === 'not_empty') { - // The dry run normally catches this before the user can - // confirm; reaching here means the folder changed in between. - const modelCount = Number(error?.manifest?.model_count) || 0; - if (modelCount > 0) { - showToast( - 'sidebar.deleteFolderResult.notEmptyWithCount', - { count: modelCount }, - 'warning' - ); - } else { - showToast('sidebar.deleteFolderResult.notEmpty', {}, 'warning'); - } - } else if (error?.code === 'busy') { - showToast('sidebar.deleteFolderResult.busy', {}, 'warning'); - } else { - showToast( - 'sidebar.deleteFolderResult.failed', - { message: error?.message || 'Unknown error' }, - 'error' - ); - } + this._reportDeleteFolderFailure(error); return false; } } + /** + * Recreate removed directories through the create-folder API. + * + * Accepts one path or the list a multi-copy delete removed, so the undo of + * a multi-root delete restores every directory it took. + */ async _restoreDeletedFolder(absolutePath, relativePath) { + const paths = Array.isArray(absolutePath) ? absolutePath : [absolutePath]; try { - await this.apiClient.createFolder(absolutePath); + for (const path of paths) { + await this.apiClient.createFolder(path); + } this._forgetRemovedFolder(relativePath); await this.refresh(); showToast('sidebar.deleteFolderResult.restored', {}, 'success'); @@ -1326,6 +2026,17 @@ export class SidebarManager { } }); + // Ticking a root adds that copy to the pending delete. Nothing is + // re-rendered here: the row the user just clicked keeps its focus, and + // the modal keeps its height. + modal.addEventListener('change', (event) => { + const input = event.target instanceof Element + ? event.target.closest('input[name="deleteFolderRoot"]') + : null; + if (!input) return; + this._toggleDeleteFolderCandidate(input.value, input.checked); + }); + this._deleteFolderModalWired = true; } @@ -1968,7 +2679,7 @@ export class SidebarManager { this.showCreateFolderInput(path); break; case 'rename-folder': - this.showRenameFolderInput(path); + await this.showRenameFolderInput(path); break; case 'delete-folder': this.showDeleteFolderModal(path); diff --git a/templates/components/modals/confirm_modals.html b/templates/components/modals/confirm_modals.html index cde87e75..047e54a3 100644 --- a/templates/components/modals/confirm_modals.html +++ b/templates/components/modals/confirm_modals.html @@ -112,13 +112,19 @@ - +