mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-07 18:12:12 -03:00
feat(sidebar): manage folders that live under several model roots
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.
This commit is contained in:
@@ -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
|
||||
|
||||
+31
-3
@@ -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.",
|
||||
|
||||
+31
-3
@@ -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.",
|
||||
|
||||
+31
-3
@@ -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.",
|
||||
|
||||
+31
-3
@@ -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.",
|
||||
|
||||
+31
-3
@@ -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": "לא ניתן לקבוע את נתיב היעד להעברה.",
|
||||
|
||||
+31
-3
@@ -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": "移動先のパスを特定できません。",
|
||||
|
||||
+31
-3
@@ -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": "이동할 대상 경로를 확인할 수 없습니다.",
|
||||
|
||||
+31
-3
@@ -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": "Не удалось определить путь назначения для перемещения.",
|
||||
|
||||
+31
-3
@@ -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": "无法确定移动的目标路径。",
|
||||
|
||||
+31
-3
@@ -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": "無法確定移動的目標路徑。",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -112,13 +112,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Folder Delete Confirmation Modal -->
|
||||
<!-- Shared by two states: 'confirm' (model-free folder) and 'blocked' (the
|
||||
subtree still holds models, so a cascade delete is refused). -->
|
||||
<!-- Shared by five states: 'confirm' (model-free folder), 'blocked' (the
|
||||
subtree still holds models, so a cascade delete is refused), 'busy' (a
|
||||
staged delete is still pending), 'missing' (the directory is gone from
|
||||
disk) and 'symlink' (every copy is a symbolic link). The roots container
|
||||
holds the checkbox list shown when the same relative folder exists under
|
||||
several library roots: each copy is guarded on its own and the ticked ones
|
||||
are all deleted, since only the absolute paths tell those copies apart. -->
|
||||
<div id="deleteFolderModal" class="modal delete-modal">
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2 data-role="title">{{ t('sidebar.deleteFolderModal.title') }}</h2>
|
||||
<p class="delete-message" data-role="message">{{ t('sidebar.deleteFolderModal.message') }}</p>
|
||||
<div class="delete-model-info" data-role="info"></div>
|
||||
<div class="delete-folder-roots" data-role="roots"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" data-action="cancel-delete-folder">{{ t('common.actions.cancel') }}</button>
|
||||
<button class="delete-btn" data-action="confirm-delete-folder">{{ t('sidebar.deleteFolderModal.confirm') }}</button>
|
||||
|
||||
@@ -50,6 +50,17 @@ function createApiClient(overrides = {}) {
|
||||
fetchUnifiedFolderTree: vi.fn().mockResolvedValue({ tree: { full: {}, empty: {} } }),
|
||||
fetchModelFolders: vi.fn().mockResolvedValue({ folders: ['', 'full'] }),
|
||||
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/models/loras'] }),
|
||||
// Mirrors the backend resolver: a single-root library answers with the one
|
||||
// directory the relative folder maps to.
|
||||
resolveFolder: vi.fn((folder) => Promise.resolve({
|
||||
success: true,
|
||||
folder,
|
||||
candidates: [{
|
||||
folder_path: `/models/loras/${folder}`,
|
||||
root: '/models/loras',
|
||||
is_symlink: false,
|
||||
}],
|
||||
})),
|
||||
createFolder: vi.fn().mockResolvedValue({ success: true, folder: 'new-folder', created: true }),
|
||||
deleteFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
@@ -451,6 +462,33 @@ describe('SidebarManager folder creation', () => {
|
||||
expect(manager.refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates inside the parent root even when it is not the default root', async () => {
|
||||
const apiClient = createApiClient({
|
||||
fetchModelRoots: vi.fn().mockResolvedValue({
|
||||
roots: ['/models/loras', '/models/extra'],
|
||||
}),
|
||||
resolveFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
folder: 'recipes',
|
||||
candidates: [{
|
||||
folder_path: '/models/loras/recipes',
|
||||
root: '/models/loras',
|
||||
is_symlink: false,
|
||||
}],
|
||||
}),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
state.global.settings = { default_lora_root: '/models/extra' };
|
||||
|
||||
const success = await manager._createFolder('recipes/presets', 'recipes');
|
||||
|
||||
// A new folder belongs next to the node it was created from; only a
|
||||
// root-level creation falls back to the configured default root.
|
||||
expect(success).toBe(true);
|
||||
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/recipes/presets');
|
||||
});
|
||||
|
||||
it('re-enables empty folders when creating while the preference is off', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
@@ -613,6 +651,7 @@ describe('SidebarManager folder deletion', () => {
|
||||
<h2 data-role="title"></h2>
|
||||
<p class="delete-message" data-role="message"></p>
|
||||
<div class="delete-model-info" data-role="info"></div>
|
||||
<div class="delete-folder-roots" data-role="roots"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" data-action="cancel-delete-folder">Cancel</button>
|
||||
<button class="delete-btn" data-action="confirm-delete-folder">Delete folder</button>
|
||||
@@ -624,6 +663,14 @@ describe('SidebarManager folder deletion', () => {
|
||||
return document.querySelector('#deleteFolderModal [data-action="confirm-delete-folder"]');
|
||||
}
|
||||
|
||||
function rootChoices() {
|
||||
return [...document.querySelectorAll('#deleteFolderModal input[name="deleteFolderRoot"]')];
|
||||
}
|
||||
|
||||
function keptNote() {
|
||||
return document.querySelector('#deleteFolderModal .folder-root-kept-note');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = MODAL_HTML;
|
||||
@@ -642,10 +689,14 @@ describe('SidebarManager folder deletion', () => {
|
||||
expect(modal.dataset.state).toBe('confirm');
|
||||
expect(confirmBtn().style.display).toBe('');
|
||||
expect(confirmBtn().disabled).toBe(false);
|
||||
expect(manager._pendingDeleteFolderPath).toBe('empty');
|
||||
// The pending target is the resolved directory, never the bare tree path:
|
||||
// the tree path is relative to a root this node may not even live under.
|
||||
expect(manager._pendingDeleteFolderRelative).toBe('empty');
|
||||
expect(manager._pendingDeleteFolderPath).toBe('/models/loras/empty');
|
||||
expect(modalManager.showModal).toHaveBeenCalledWith('deleteFolderModal');
|
||||
// The prediction is confirmed against the real guard before the user can
|
||||
// act on it.
|
||||
expect(apiClient.resolveFolder).toHaveBeenCalledWith('empty');
|
||||
expect(apiClient.deleteFolder).toHaveBeenCalledWith(
|
||||
'/models/loras/empty', { dryRun: true }
|
||||
);
|
||||
@@ -746,7 +797,7 @@ describe('SidebarManager folder deletion', () => {
|
||||
it('keeps the confirm button disabled until the check settles', async () => {
|
||||
let release;
|
||||
const apiClient = createApiClient({
|
||||
fetchModelRoots: vi.fn(() => new Promise((resolve) => { release = resolve; })),
|
||||
resolveFolder: vi.fn(() => new Promise((resolve) => { release = resolve; })),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
@@ -754,7 +805,15 @@ describe('SidebarManager folder deletion', () => {
|
||||
const pending = manager.showDeleteFolderModal('empty');
|
||||
expect(confirmBtn().disabled).toBe(true);
|
||||
|
||||
release({ roots: ['/models/loras'] });
|
||||
release({
|
||||
success: true,
|
||||
folder: 'empty',
|
||||
candidates: [{
|
||||
folder_path: '/models/loras/empty',
|
||||
root: '/models/loras',
|
||||
is_symlink: false,
|
||||
}],
|
||||
});
|
||||
await pending;
|
||||
|
||||
expect(confirmBtn().disabled).toBe(false);
|
||||
@@ -920,13 +979,386 @@ describe('SidebarManager folder deletion', () => {
|
||||
it('routes the modal buttons to cancel and confirm', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager._deleteFolder = vi.fn().mockResolvedValue(true);
|
||||
manager._pendingDeleteFolderPath = 'empty';
|
||||
manager._pendingDeleteFolderRelative = 'empty';
|
||||
manager._pendingDeleteFolderPath = '/models/loras/empty';
|
||||
manager._wireDeleteFolderModal();
|
||||
|
||||
confirmBtn().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
expect(modalManager.closeModal).toHaveBeenCalledWith('deleteFolderModal');
|
||||
expect(manager._deleteFolder).toHaveBeenCalledWith('empty');
|
||||
expect(manager._deleteFolder).toHaveBeenCalledWith('empty', '/models/loras/empty');
|
||||
});
|
||||
|
||||
it('deletes the directory the node actually lives in, not the default root one', async () => {
|
||||
// The reported bug: the node exists under the primary root while
|
||||
// default_lora_root points at the extra root, so prefixing the default root
|
||||
// produced a path that did not exist and the delete always failed.
|
||||
const apiClient = createApiClient({
|
||||
fetchModelRoots: vi.fn().mockResolvedValue({
|
||||
roots: ['/models/loras', '/models/extra'],
|
||||
}),
|
||||
resolveFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
folder: 'recipes',
|
||||
candidates: [{
|
||||
folder_path: '/models/loras/recipes',
|
||||
root: '/models/loras',
|
||||
is_symlink: false,
|
||||
}],
|
||||
}),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
state.global.settings = { default_lora_root: '/models/extra' };
|
||||
|
||||
await manager.showDeleteFolderModal('recipes');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('confirm');
|
||||
expect(apiClient.deleteFolder).toHaveBeenCalledWith(
|
||||
'/models/loras/recipes', { dryRun: true }
|
||||
);
|
||||
// The absolute path is shown, so the modal names the directory it targets.
|
||||
expect(modal.querySelector('[data-role="info"]').textContent)
|
||||
.toContain('/models/loras/recipes');
|
||||
|
||||
await manager.handleDeleteFolderConfirm();
|
||||
|
||||
expect(apiClient.deleteFolder).toHaveBeenLastCalledWith('/models/loras/recipes');
|
||||
});
|
||||
|
||||
it('lists every root holding the folder as a checked row, and deletes only the ticked ones', async () => {
|
||||
const apiClient = createApiClient({
|
||||
resolveFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
folder: 'Pony',
|
||||
candidates: [
|
||||
{ folder_path: '/models/extra/Pony', root: '/models/extra', is_symlink: false },
|
||||
{ folder_path: '/models/loras/Pony', root: '/models/loras', is_symlink: false },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
state.global.settings = { default_lora_root: '/models/extra' };
|
||||
|
||||
await manager.showDeleteFolderModal('Pony');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
const choices = rootChoices();
|
||||
expect(choices.map((input) => input.value)).toEqual([
|
||||
'/models/extra/Pony',
|
||||
'/models/loras/Pony',
|
||||
]);
|
||||
// Both copies are selected up front: the node stands for all of them, and
|
||||
// deleting it one root at a time was the complaint.
|
||||
expect(choices.map((input) => input.checked)).toEqual([true, true]);
|
||||
expect(choices.every((input) => input.type === 'checkbox')).toBe(true);
|
||||
expect(choices.every((input) => input.disabled === false)).toBe(true);
|
||||
expect(confirmBtn().textContent).toContain('2');
|
||||
// Each copy carries its own verdict instead of one shared claim.
|
||||
expect(modal.querySelectorAll('.folder-root-status')).toHaveLength(2);
|
||||
expect([...modal.querySelectorAll('.folder-root-status')].map((el) => el.textContent))
|
||||
.toEqual(['no models', 'no models']);
|
||||
// The absolute paths live on the rows, so the header does not single one out.
|
||||
expect(modal.querySelector('[data-role="info"]').textContent).not.toContain('/models/');
|
||||
expect(apiClient.deleteFolder).toHaveBeenCalledWith('/models/extra/Pony', { dryRun: true });
|
||||
expect(apiClient.deleteFolder).toHaveBeenCalledWith('/models/loras/Pony', { dryRun: true });
|
||||
|
||||
// Unticking one copy keeps the other, and does not rebuild the list.
|
||||
const firstRow = choices[0];
|
||||
const secondRow = choices[1];
|
||||
expect(keptNote().textContent).toBe('');
|
||||
choices[1].checked = false;
|
||||
choices[1].dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
expect(rootChoices()[0]).toBe(firstRow);
|
||||
expect(rootChoices()[1]).toBe(secondRow);
|
||||
expect(rootChoices().map((input) => input.checked)).toEqual([true, false]);
|
||||
expect(confirmBtn().textContent).not.toContain('2');
|
||||
// The node stays in the sidebar while another root holds the folder, so the
|
||||
// modal says so before the user commits.
|
||||
expect(keptNote().textContent).toBe('Unchecked copies keep this folder in the sidebar.');
|
||||
|
||||
// Unticking everything just disables the button: the user emptied the
|
||||
// selection, the folders are not "blocked".
|
||||
choices[0].checked = false;
|
||||
choices[0].dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
expect(confirmBtn().disabled).toBe(true);
|
||||
expect(confirmBtn().style.display).toBe('');
|
||||
expect(modal.querySelector('[data-role="title"]').textContent).toBe('Delete folder?');
|
||||
|
||||
choices[0].checked = true;
|
||||
choices[0].dispatchEvent(new Event('change', { bubbles: true }));
|
||||
expect(confirmBtn().disabled).toBe(false);
|
||||
// Re-ticking the copy clears the warning again...
|
||||
choices[1].checked = true;
|
||||
choices[1].dispatchEvent(new Event('change', { bubbles: true }));
|
||||
expect(keptNote().textContent).toBe('');
|
||||
// ... and the copy is left alone for the rest of the test.
|
||||
choices[1].checked = false;
|
||||
choices[1].dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
await manager.handleDeleteFolderConfirm();
|
||||
|
||||
// Real deletes: only the ticked copy, and both dry runs are distinct calls.
|
||||
expect(apiClient.deleteFolder).toHaveBeenLastCalledWith('/models/extra/Pony');
|
||||
expect(apiClient.deleteFolder).not.toHaveBeenCalledWith('/models/loras/Pony');
|
||||
expect(manager.refresh).toHaveBeenCalledTimes(1);
|
||||
// A single removed copy keeps the singular wording (no "from 1 roots"), but
|
||||
// the toast stays a plain success: the sidebar node's fate is disk truth.
|
||||
expect(showActionToast).toHaveBeenCalledWith(
|
||||
'sidebar.deleteFolderResult.success',
|
||||
{ name: 'Pony' },
|
||||
'success',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('restores only the deleted copy when a kept copy survives the undo', async () => {
|
||||
const apiClient = createApiClient({
|
||||
resolveFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
folder: 'test',
|
||||
candidates: [
|
||||
{ folder_path: '/models/loras/test', root: '/models/loras', is_symlink: false },
|
||||
{ folder_path: '/models/extra/test', root: '/models/extra', is_symlink: false },
|
||||
],
|
||||
}),
|
||||
deleteFolder: vi.fn().mockResolvedValue({ success: true, folder: 'test', restorable: true }),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await manager.showDeleteFolderModal('test');
|
||||
const choices = rootChoices();
|
||||
choices[1].checked = false;
|
||||
choices[1].dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
await manager.handleDeleteFolderConfirm();
|
||||
|
||||
const undo = showActionToast.mock.calls[0][3].onAction;
|
||||
await undo();
|
||||
|
||||
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/test');
|
||||
expect(apiClient.createFolder).not.toHaveBeenCalledWith('/models/extra/test');
|
||||
});
|
||||
|
||||
it('unchecks and explains a copy that still holds models', async () => {
|
||||
const conflict = Object.assign(new Error('still contains models'), {
|
||||
code: 'not_empty',
|
||||
manifest: { model_count: 2, excluded_model_count: 0 },
|
||||
});
|
||||
const apiClient = createApiClient({
|
||||
resolveFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
folder: 'Pony',
|
||||
candidates: [
|
||||
{ folder_path: '/models/extra/Pony', root: '/models/extra', is_symlink: false },
|
||||
{ folder_path: '/models/loras/Pony', root: '/models/loras', is_symlink: false },
|
||||
],
|
||||
}),
|
||||
deleteFolder: vi.fn((path, options) => (
|
||||
path === '/models/loras/Pony' && options?.dryRun
|
||||
? Promise.reject(conflict)
|
||||
: Promise.resolve({ success: true, folder: 'Pony', restorable: true })
|
||||
)),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await manager.showDeleteFolderModal('Pony');
|
||||
|
||||
const choices = rootChoices();
|
||||
expect(choices.map((input) => input.checked)).toEqual([true, false]);
|
||||
expect(choices[1].disabled).toBe(true);
|
||||
const statuses = [...document.querySelectorAll('#deleteFolderModal .folder-root-status')];
|
||||
expect(statuses[0].textContent).toBe('no models');
|
||||
expect(statuses[1].textContent).toContain('2 model file(s)');
|
||||
expect(statuses[1].classList.contains('blocked')).toBe(true);
|
||||
expect(confirmBtn().disabled).toBe(false);
|
||||
|
||||
await manager.handleDeleteFolderConfirm();
|
||||
|
||||
// The clean copy is deleted; the blocked one is never asked for a real delete.
|
||||
expect(apiClient.deleteFolder).toHaveBeenLastCalledWith('/models/extra/Pony');
|
||||
expect(apiClient.deleteFolder).not.toHaveBeenCalledWith('/models/loras/Pony');
|
||||
});
|
||||
|
||||
it('blocks the whole modal when no copy can be deleted', async () => {
|
||||
const conflict = Object.assign(new Error('still contains models'), {
|
||||
code: 'not_empty',
|
||||
manifest: { model_count: 1, excluded_model_count: 0 },
|
||||
});
|
||||
const apiClient = createApiClient({
|
||||
resolveFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
folder: 'Pony',
|
||||
candidates: [
|
||||
{ folder_path: '/models/extra/Pony', root: '/models/extra', is_symlink: false },
|
||||
{ folder_path: '/models/loras/Pony', root: '/models/loras', is_symlink: false },
|
||||
],
|
||||
}),
|
||||
deleteFolder: vi.fn().mockRejectedValue(conflict),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
|
||||
await manager.showDeleteFolderModal('Pony');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(confirmBtn().style.display).toBe('none');
|
||||
expect(modal.querySelector('[data-role="title"]').textContent).toBe('Folder is not empty');
|
||||
expect(rootChoices().every((input) => input.disabled)).toBe(true);
|
||||
});
|
||||
|
||||
it('deletes every ticked copy in one action and restores them all on undo', async () => {
|
||||
const apiClient = createApiClient({
|
||||
resolveFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
folder: 'test',
|
||||
candidates: [
|
||||
{ folder_path: '/models/loras/test', root: '/models/loras', is_symlink: false },
|
||||
{ folder_path: '/models/extra/test', root: '/models/extra', is_symlink: false },
|
||||
],
|
||||
}),
|
||||
deleteFolder: vi.fn().mockResolvedValue({ success: true, folder: 'test', restorable: true }),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await manager.showDeleteFolderModal('test');
|
||||
await manager.handleDeleteFolderConfirm();
|
||||
|
||||
expect(apiClient.deleteFolder).toHaveBeenCalledWith('/models/loras/test');
|
||||
expect(apiClient.deleteFolder).toHaveBeenCalledWith('/models/extra/test');
|
||||
expect(showActionToast).toHaveBeenCalledWith(
|
||||
'sidebar.deleteFolderResult.successMulti',
|
||||
{ name: 'test', count: 2 },
|
||||
'success',
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
const undo = showActionToast.mock.calls[0][3].onAction;
|
||||
await undo();
|
||||
|
||||
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/test');
|
||||
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/extra/test');
|
||||
});
|
||||
|
||||
it('reports the copies it managed to delete when one of them fails', async () => {
|
||||
const apiClient = createApiClient({
|
||||
resolveFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
folder: 'test',
|
||||
candidates: [
|
||||
{ folder_path: '/models/loras/test', root: '/models/loras', is_symlink: false },
|
||||
{ folder_path: '/models/extra/test', root: '/models/extra', is_symlink: false },
|
||||
],
|
||||
}),
|
||||
deleteFolder: vi.fn((path, options) => {
|
||||
if (options?.dryRun) return Promise.resolve({ success: true });
|
||||
if (path === '/models/extra/test') {
|
||||
return Promise.reject(Object.assign(new Error('gone'), { code: 'missing' }));
|
||||
}
|
||||
return Promise.resolve({ success: true, folder: 'test', restorable: true });
|
||||
}),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await manager.showDeleteFolderModal('test');
|
||||
const success = await manager.handleDeleteFolderConfirm();
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'sidebar.deleteFolderResult.partial',
|
||||
{ count: 1, total: 2, failed: 1 },
|
||||
'warning'
|
||||
);
|
||||
expect(manager.refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports a stale tree node as missing instead of fabricating a path', async () => {
|
||||
const apiClient = createApiClient({
|
||||
resolveFolder: vi.fn().mockResolvedValue({
|
||||
success: true, folder: 'removed', candidates: [],
|
||||
}),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
await manager.showDeleteFolderModal('removed');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('missing');
|
||||
expect(confirmBtn().style.display).toBe('none');
|
||||
expect(manager._pendingDeleteFolderPath).toBeNull();
|
||||
expect(apiClient.deleteFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a node whose every copy is a symbolic link', async () => {
|
||||
const apiClient = createApiClient({
|
||||
resolveFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
folder: 'linked',
|
||||
candidates: [
|
||||
{ folder_path: '/models/loras/linked', root: '/models/loras', is_symlink: true },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
|
||||
await manager.showDeleteFolderModal('linked');
|
||||
|
||||
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('symlink');
|
||||
expect(confirmBtn().style.display).toBe('none');
|
||||
expect(apiClient.deleteFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not guess a root when the resolver fails on a multi-root library', async () => {
|
||||
const apiClient = createApiClient({
|
||||
fetchModelRoots: vi.fn().mockResolvedValue({
|
||||
roots: ['/models/loras', '/models/extra'],
|
||||
}),
|
||||
resolveFolder: vi.fn().mockRejectedValue(new Error('boom')),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const success = await manager._deleteFolder('Pony');
|
||||
|
||||
expect(success).toBe(false);
|
||||
expect(apiClient.deleteFolder).not.toHaveBeenCalled();
|
||||
expect(showToast).toHaveBeenCalledWith('sidebar.folderResult.unresolved', {}, 'error');
|
||||
});
|
||||
|
||||
it('keeps the single-root fallback for clients without the resolver', async () => {
|
||||
const apiClient = createApiClient({ resolveFolder: undefined });
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const success = await manager._deleteFolder('empty');
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(apiClient.deleteFolder).toHaveBeenCalledWith('/models/loras/empty');
|
||||
});
|
||||
|
||||
it('reports a folder that vanished between the check and the confirm', async () => {
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error('Folder no longer exists'), { code: 'missing' })
|
||||
),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const success = await manager._deleteFolder('empty');
|
||||
|
||||
expect(success).toBe(false);
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'sidebar.deleteFolderResult.missing', {}, 'warning'
|
||||
);
|
||||
});
|
||||
|
||||
it('routes the context-menu action to the delete modal', () => {
|
||||
@@ -969,12 +1401,12 @@ describe('SidebarManager folder rename', () => {
|
||||
return document.querySelector('#sidebarRenameFolderInput .sidebar-rename-folder-input');
|
||||
}
|
||||
|
||||
it('turns the node into a prefilled inline row in tree mode', () => {
|
||||
it('turns the node into a prefilled inline row in tree mode', async () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.treeData = { characters: { anime: {} } };
|
||||
manager.renderTree();
|
||||
|
||||
manager.showRenameFolderInput('characters/anime');
|
||||
await manager.showRenameFolderInput('characters/anime');
|
||||
|
||||
const row = document.getElementById('sidebarRenameFolderInput');
|
||||
expect(row).not.toBeNull();
|
||||
@@ -986,12 +1418,12 @@ describe('SidebarManager folder rename', () => {
|
||||
expect(manager._renameFolderPath).toBe('characters/anime');
|
||||
});
|
||||
|
||||
it('inserts the row in place in list mode', () => {
|
||||
it('inserts the row in place in list mode', async () => {
|
||||
const manager = createManager(createApiClient(), { displayMode: 'list' });
|
||||
manager.foldersList = ['characters', 'characters/anime'];
|
||||
manager.renderFolderList();
|
||||
|
||||
manager.showRenameFolderInput('characters/anime');
|
||||
await manager.showRenameFolderInput('characters/anime');
|
||||
|
||||
const row = document.getElementById('sidebarRenameFolderInput');
|
||||
expect(row.querySelector('.sidebar-node-content')).not.toBeNull();
|
||||
@@ -999,12 +1431,12 @@ describe('SidebarManager folder rename', () => {
|
||||
expect(row.nextElementSibling).toBe(item);
|
||||
});
|
||||
|
||||
it('restores the node when the edit is canceled', () => {
|
||||
it('restores the node when the edit is canceled', async () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.treeData = { characters: { anime: {} } };
|
||||
manager.renderTree();
|
||||
|
||||
manager.showRenameFolderInput('characters/anime');
|
||||
await manager.showRenameFolderInput('characters/anime');
|
||||
manager.handleRenameFolderCancel();
|
||||
|
||||
expect(document.getElementById('sidebarRenameFolderInput')).toBeNull();
|
||||
@@ -1054,7 +1486,7 @@ describe('SidebarManager folder rename', () => {
|
||||
manager.treeData = { characters: { anime: {} } };
|
||||
manager.renderTree();
|
||||
|
||||
manager.showRenameFolderInput('characters/anime');
|
||||
await manager.showRenameFolderInput('characters/anime');
|
||||
renameInput().value = 'anime';
|
||||
await manager.handleRenameFolderSubmit();
|
||||
|
||||
@@ -1068,7 +1500,7 @@ describe('SidebarManager folder rename', () => {
|
||||
manager.treeData = { characters: { anime: {} } };
|
||||
manager.renderTree();
|
||||
|
||||
manager.showRenameFolderInput('characters/anime');
|
||||
await manager.showRenameFolderInput('characters/anime');
|
||||
renameInput().value = 'bad/name';
|
||||
await manager.handleRenameFolderSubmit();
|
||||
|
||||
@@ -1102,6 +1534,97 @@ describe('SidebarManager folder rename', () => {
|
||||
expect(manager.showRenameFolderInput).toHaveBeenCalledWith('characters/anime');
|
||||
});
|
||||
|
||||
it('renames the directory the node actually lives in', async () => {
|
||||
// Same trap as the delete flow: the node is under the primary root while
|
||||
// the configured default root is another one.
|
||||
const apiClient = createApiClient({
|
||||
resolveFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
folder: 'recipes',
|
||||
candidates: [{
|
||||
folder_path: '/models/loras/recipes',
|
||||
root: '/models/loras',
|
||||
is_symlink: false,
|
||||
}],
|
||||
}),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
state.global.settings = { default_lora_root: '/models/extra' };
|
||||
|
||||
const success = await manager._renameFolder('recipes', 'presets');
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(apiClient.renameFolder).toHaveBeenCalledWith('/models/loras/recipes', 'presets');
|
||||
});
|
||||
|
||||
it('lets the user pick the copy to rename when several roots hold the folder', async () => {
|
||||
const apiClient = createApiClient({
|
||||
resolveFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
folder: 'Pony',
|
||||
candidates: [
|
||||
{ folder_path: '/models/extra/Pony', root: '/models/extra', is_symlink: false },
|
||||
{ folder_path: '/models/loras/Pony', root: '/models/loras', is_symlink: false },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
state.global.settings = { default_lora_root: '/models/extra' };
|
||||
manager.treeData = { Pony: {} };
|
||||
manager.renderTree();
|
||||
|
||||
await manager.showRenameFolderInput('Pony');
|
||||
|
||||
const picker = document.querySelector('#sidebarRenameFolderInput .sidebar-folder-root-select');
|
||||
expect([...picker.options].map((option) => option.value)).toEqual([
|
||||
'/models/extra/Pony',
|
||||
'/models/loras/Pony',
|
||||
]);
|
||||
// The default root's copy is preselected, the other one stays reachable.
|
||||
expect(picker.value).toBe('/models/extra/Pony');
|
||||
|
||||
picker.value = '/models/loras/Pony';
|
||||
renameInput().value = 'PonyV6';
|
||||
await manager.handleRenameFolderSubmit();
|
||||
|
||||
expect(apiClient.renameFolder).toHaveBeenCalledWith('/models/loras/Pony', 'PonyV6');
|
||||
});
|
||||
|
||||
it('reports a stale node instead of opening the rename row', async () => {
|
||||
const apiClient = createApiClient({
|
||||
resolveFolder: vi.fn().mockResolvedValue({
|
||||
success: true, folder: 'gone', candidates: [],
|
||||
}),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.treeData = { gone: {} };
|
||||
manager.renderTree();
|
||||
|
||||
await manager.showRenameFolderInput('gone');
|
||||
|
||||
expect(document.getElementById('sidebarRenameFolderInput')).toBeNull();
|
||||
expect(showToast).toHaveBeenCalledWith('sidebar.renameFolderResult.missing', {}, 'warning');
|
||||
});
|
||||
|
||||
it('does not guess a root when the resolver fails on a multi-root library', async () => {
|
||||
const apiClient = createApiClient({
|
||||
fetchModelRoots: vi.fn().mockResolvedValue({
|
||||
roots: ['/models/loras', '/models/extra'],
|
||||
}),
|
||||
resolveFolder: vi.fn().mockRejectedValue(new Error('boom')),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const success = await manager._renameFolder('Pony', 'PonyV6');
|
||||
|
||||
expect(success).toBe(false);
|
||||
expect(apiClient.renameFolder).not.toHaveBeenCalled();
|
||||
expect(showToast).toHaveBeenCalledWith('sidebar.folderResult.unresolved', {}, 'error');
|
||||
});
|
||||
|
||||
it('hides the rename entry when folder management is unsupported', () => {
|
||||
document.body.insertAdjacentHTML('beforeend', `
|
||||
<div id="sidebarFolderContextMenu" class="context-menu">
|
||||
|
||||
@@ -901,3 +901,54 @@ def test_download_model_returns_skipped_success(mock_service, download_manager_s
|
||||
await client.close()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_resolve_folder_answers_with_the_root_that_holds_the_directory(
|
||||
mock_service, mock_scanner, tmp_path: Path
|
||||
):
|
||||
"""The route the sidebar needs to stop guessing a root for a tree node.
|
||||
|
||||
The unified folder tree merges every model root into one relative-path
|
||||
namespace, so "recipes" below must resolve to the root that actually has it
|
||||
even though another root is configured as the library default.
|
||||
"""
|
||||
|
||||
primary = tmp_path / "primary"
|
||||
extra = tmp_path / "extra"
|
||||
(primary / "recipes").mkdir(parents=True)
|
||||
extra.mkdir()
|
||||
mock_scanner.get_model_roots = lambda: [str(primary), str(extra)]
|
||||
|
||||
async def scenario():
|
||||
client = await create_test_client(mock_service)
|
||||
try:
|
||||
response = await client.get(
|
||||
"/api/lm/test-models/resolve-folder", params={"folder": "recipes"}
|
||||
)
|
||||
payload = await response.json()
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["folder"] == "recipes"
|
||||
assert payload["candidates"] == [
|
||||
{
|
||||
"folder_path": str(primary / "recipes"),
|
||||
"root": str(primary),
|
||||
"is_symlink": False,
|
||||
}
|
||||
]
|
||||
|
||||
stale = await client.get(
|
||||
"/api/lm/test-models/resolve-folder", params={"folder": "gone"}
|
||||
)
|
||||
assert (await stale.json())["candidates"] == []
|
||||
|
||||
refused = await client.get(
|
||||
"/api/lm/test-models/resolve-folder",
|
||||
params={"folder": str(primary / "recipes")},
|
||||
)
|
||||
assert refused.status == 400
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
@@ -12,6 +12,7 @@ class FakeMoveService:
|
||||
self.received_path = None
|
||||
self.received_dry_run = None
|
||||
self.received_new_name = None
|
||||
self.received_folder = None
|
||||
|
||||
async def create_folder(self, folder_path):
|
||||
self.received_path = folder_path
|
||||
@@ -27,6 +28,10 @@ class FakeMoveService:
|
||||
self.received_new_name = new_name
|
||||
return self._result
|
||||
|
||||
def resolve_folder(self, folder):
|
||||
self.received_folder = folder
|
||||
return self._result
|
||||
|
||||
|
||||
class FakeRequest:
|
||||
def __init__(self, payload):
|
||||
@@ -36,6 +41,13 @@ class FakeRequest:
|
||||
return self._payload
|
||||
|
||||
|
||||
class FakeQueryRequest:
|
||||
"""Request stand-in exposing only the query string."""
|
||||
|
||||
def __init__(self, query):
|
||||
self.query = query
|
||||
|
||||
|
||||
def _make_handler(result):
|
||||
service = FakeMoveService(result)
|
||||
handler = ModelMoveHandler(
|
||||
@@ -141,6 +153,43 @@ async def test_delete_folder_forwards_dry_run():
|
||||
assert json.loads(response.text)["dry_run"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_folder_returns_the_matching_roots():
|
||||
handler, service = _make_handler(
|
||||
{
|
||||
"success": True,
|
||||
"folder": "recipes",
|
||||
"candidates": [
|
||||
{
|
||||
"folder_path": "/library/recipes",
|
||||
"root": "/library",
|
||||
"is_symlink": False,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
response = await handler.resolve_folder(FakeQueryRequest({"folder": "recipes"}))
|
||||
|
||||
assert response.status == 200
|
||||
payload = json.loads(response.text)
|
||||
assert payload["success"] is True
|
||||
assert payload["candidates"][0]["folder_path"] == "/library/recipes"
|
||||
assert service.received_folder == "recipes"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_folder_maps_a_refused_path_to_400():
|
||||
handler, _service = _make_handler(
|
||||
{"success": False, "error": "Folder path is required"}
|
||||
)
|
||||
|
||||
response = await handler.resolve_folder(FakeQueryRequest({}))
|
||||
|
||||
assert response.status == 400
|
||||
assert json.loads(response.text)["success"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_missing_path():
|
||||
handler, service = _make_handler({"success": True})
|
||||
@@ -206,6 +255,20 @@ async def test_delete_folder_containment_failure_maps_to_400():
|
||||
assert "outside configured library" in payload["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_reports_a_vanished_directory_as_missing():
|
||||
handler, _service = _make_handler(
|
||||
{"success": False, "code": "missing", "error": "Folder no longer exists"}
|
||||
)
|
||||
|
||||
response = await handler.delete_folder(
|
||||
FakeRequest({"folder_path": "/library/gone"})
|
||||
)
|
||||
|
||||
assert response.status == 400
|
||||
assert json.loads(response.text)["code"] == "missing"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_invalid_json_body():
|
||||
class BadJsonRequest:
|
||||
|
||||
@@ -15,6 +15,7 @@ class FakeScanner:
|
||||
self._roots = [str(root) for root in roots]
|
||||
self.known_folders: List[str] = []
|
||||
self.removed_folders: List[str] = []
|
||||
self.removed_folder_paths: List[str | None] = []
|
||||
self.renamed_folders: List[tuple] = []
|
||||
self._excluded = list(excluded or [])
|
||||
|
||||
@@ -27,8 +28,9 @@ class FakeScanner:
|
||||
async def add_known_folder(self, folder: str) -> None:
|
||||
self.known_folders.append(folder)
|
||||
|
||||
async def remove_known_folder(self, folder: str) -> None:
|
||||
async def remove_known_folder(self, folder: str, absolute_path: str | None = None) -> None:
|
||||
self.removed_folders.append(folder)
|
||||
self.removed_folder_paths.append(absolute_path)
|
||||
|
||||
async def rename_known_folder(self, previous: str, current: str, **kwargs) -> None:
|
||||
self.renamed_folders.append((previous, current, kwargs))
|
||||
@@ -107,6 +109,114 @@ async def test_create_folder_requires_path(tmp_path: Path):
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
def test_resolve_folder_finds_the_directory_under_the_root_that_holds_it(tmp_path: Path):
|
||||
# The reported bug: the tree node lives under the primary root while the
|
||||
# configured default root is the extra one. Resolution must answer with the
|
||||
# directory that exists, not with a path fabricated from the default root.
|
||||
primary = tmp_path / "primary"
|
||||
extra = tmp_path / "extra"
|
||||
primary.mkdir()
|
||||
extra.mkdir()
|
||||
(primary / "recipes").mkdir()
|
||||
|
||||
service = ModelMoveService(FakeScanner([primary, extra]), "lora")
|
||||
|
||||
result = service.resolve_folder("recipes")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["folder"] == "recipes"
|
||||
assert [Path(candidate["folder_path"]) for candidate in result["candidates"]] == [
|
||||
primary / "recipes"
|
||||
]
|
||||
assert result["candidates"][0]["root"] == primary.as_posix()
|
||||
assert result["candidates"][0]["is_symlink"] is False
|
||||
|
||||
|
||||
def test_resolve_folder_returns_a_candidate_per_root_holding_the_folder(tmp_path: Path):
|
||||
primary = tmp_path / "primary"
|
||||
extra = tmp_path / "extra"
|
||||
(primary / "characters" / "anime").mkdir(parents=True)
|
||||
(extra / "characters" / "anime").mkdir(parents=True)
|
||||
|
||||
service = ModelMoveService(FakeScanner([primary, extra]), "lora")
|
||||
|
||||
result = service.resolve_folder("characters/anime")
|
||||
|
||||
# Order follows the scanner's root order so the caller can prefer the
|
||||
# default root without losing the rest.
|
||||
assert [Path(candidate["folder_path"]) for candidate in result["candidates"]] == [
|
||||
primary / "characters" / "anime",
|
||||
extra / "characters" / "anime",
|
||||
]
|
||||
assert [candidate["root"] for candidate in result["candidates"]] == [
|
||||
primary.as_posix(),
|
||||
extra.as_posix(),
|
||||
]
|
||||
|
||||
|
||||
def test_resolve_folder_reports_no_candidate_for_a_stale_tree_node(tmp_path: Path):
|
||||
root = tmp_path / "library"
|
||||
root.mkdir()
|
||||
|
||||
service = ModelMoveService(FakeScanner([root]), "lora")
|
||||
|
||||
result = service.resolve_folder("gone")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["candidates"] == []
|
||||
|
||||
|
||||
def test_resolve_folder_flags_symlinked_candidates(tmp_path: Path):
|
||||
root = tmp_path / "library"
|
||||
root.mkdir()
|
||||
real = tmp_path / "real"
|
||||
real.mkdir()
|
||||
try:
|
||||
(root / "linked").symlink_to(real, target_is_directory=True)
|
||||
except (OSError, NotImplementedError): # pragma: no cover - platform guard
|
||||
pytest.skip("symlinks are not supported on this platform")
|
||||
|
||||
service = ModelMoveService(FakeScanner([root]), "lora")
|
||||
|
||||
result = service.resolve_folder("linked")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["candidates"][0]["is_symlink"] is True
|
||||
|
||||
|
||||
def test_resolve_folder_normalizes_the_relative_path(tmp_path: Path):
|
||||
root = tmp_path / "library"
|
||||
(root / "characters" / "anime").mkdir(parents=True)
|
||||
|
||||
service = ModelMoveService(FakeScanner([root]), "lora")
|
||||
|
||||
result = service.resolve_folder(" characters\\anime/ ")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["folder"] == "characters/anime"
|
||||
assert len(result["candidates"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"folder",
|
||||
["", " ", "/library/recipes", "C:/library/recipes", "..", "../outside", "a/../../b"],
|
||||
)
|
||||
def test_resolve_folder_rejects_paths_outside_the_relative_namespace(
|
||||
tmp_path: Path, folder: str
|
||||
):
|
||||
root = tmp_path / "library"
|
||||
root.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
|
||||
service = ModelMoveService(FakeScanner([root]), "lora")
|
||||
|
||||
result = service.resolve_folder(folder)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "candidates" not in result
|
||||
|
||||
|
||||
def _make_nested(root: Path) -> Path:
|
||||
target = root / "characters" / "anime"
|
||||
target.mkdir(parents=True)
|
||||
@@ -127,6 +237,9 @@ async def test_delete_folder_removes_empty_directory_and_forgets_it(tmp_path: Pa
|
||||
assert result["restorable"] is True
|
||||
assert not target.exists()
|
||||
assert scanner.removed_folders == ["characters/anime"]
|
||||
# The scanner purges by the removed directory: the same relative folder may
|
||||
# exist under another root, whose model cards must survive.
|
||||
assert scanner.removed_folder_paths == [str(target)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user