Compare commits

...

7 Commits

Author SHA1 Message Date
Will Miao c6c44b741a feat(sidebar): show empty folders by default (#999)
The sidebar used the models-only folder list while the download and move
destination pickers list every directory, so a model downloaded into an
empty category folder did not appear in the sidebar at all. Empty folders
are also deliberate organization on disk, and a file-manager-shaped tree
that hides them is surprising. Default the preference to on.

Because the preference no longer gates fetching, both folder lists are
always loaded: the full list is the tree's single source of truth and the
empty-folder count, the models-only list is what "empty" is measured
against. That makes the view-options toggle a pure re-render, and lets the
new count decorate the "..." menu so the preference's effect is visible
without scanning the tree:

- empty-folder count shown next to the menu label, cleared when unknown
- the toggle is hidden entirely when there are no empty folders
- list view filters empty entries itself (the tree gets that for free
  from the backend, the flat list is now always loaded in full)
- creating a folder still re-enables the preference, since the folder the
  user just asked for would otherwise be invisible

Dim styling is decided by _isRenderedEmptyFolder() at render time; the
models-only set keeps its ancestor-expanded semantics for the delete
guard.
2026-09-15 20:44:19 +08:00
Will Miao 5095b23eb2 fix(css): restore the red on destructive context-menu entries
`Delete folder` and `Delete Model` rendered as plain menu text: the rule
was `color: var(--danger-color)`, and that token is defined nowhere in
the stylesheet tree. A var() reference to an undefined custom property is
invalid at computed-value time, so the declaration does not fall back to
a default — `color` inherits, and it silently matched the surrounding
menu text.

Points both the label and its icon (which inherits the colour) at
`--lora-error`, the themed error token the delete buttons already use.
The shared hover paints the accent background, which a red label does not
read against, so destructive entries also get their own `--lora-error-bg`
wash.

The same dead token was masked by a hardcoded fallback in the settings
priority-tags validation state; those now use the themed token too.

Fixes all seven destructive entries at once — the four model-card menus,
the exclude/duplicates `delete-all` entry and the folder sidebar menu.

Guard: tests/frontend/regression/contextMenuTokens.test.js fails if any
custom property used by menu.css stops resolving (verified by reverting
the token), and if var(--danger-color) ever comes back.
2026-09-15 20:28:05 +08:00
Will Miao cc25bb3dc2 refactor(sidebar): put the update check first, group the folder entries (#999)
The folder context menu now reads: the content action (check for updates)
on top, then the folder operations as one group (new subfolder, rename),
then the destructive entry behind its own divider.

Gating the three folder entries per page could leave the menu with
dangling separators — the recipes sidebar hides all of them and keeps
only the update check, which already rendered one stray divider before
this change and would have rendered two after it. Add
_updateContextMenuSeparators: a divider survives only when a visible
entry sits on both sides, and a run of consecutive ones collapses to a
single line. The three per-item display toggles fold into one loop.

The template order is guarded by a regression test that parses
templates/components/context_menu.html, plus behaviour tests for the
divider collapsing.
2026-09-15 20:25:08 +08:00
Will Miao 3b54a13cae i18n(sidebar): translate the folder-management strings (#999)
Fills in the 35 `sidebar.*` placeholders the folder-sidebar feature series
left behind — view options (tree/list, empty folders), folder creation,
the delete confirmation modal and its result toasts, and folder
renaming, including the undo copy and the "deleting a folder never
cascades over model files" rule the backend enforces.

All nine locales are translated, so no `[TODO: Translate]` placeholder
remains anywhere: the state §7 of the translation guidelines describes
holds again. Terminology reuses the existing §2 maps (folder, model
root, sidebar) with tree/list view added, recorded in a new §2
subsection; the stale leaf-count in the header is refreshed too.
2026-09-15 20:18:06 +08:00
Will Miao 9bbe57ee85 feat(sidebar): rename folders from the sidebar (#999)
Follows the folder create/delete work: a typo'd directory could be
removed but not corrected, and for a folder holding models the only fix
was to move every model out by hand.

Adds POST /api/lm/{prefix}/rename-folder. Unlike the delete path this one
deliberately works on folders that hold models — a rename keeps every
file, so nothing is cascaded over: the directory is renamed on disk and
the scanner re-keys the records that pointed at the old prefix (recorded
folder list, cache file_path/folder/preview_url, hash and autov3 index
paths, excluded-model paths, and the metadata sidecars that travelled
with the directory). Ancestors are never touched, and only the leaf name
is accepted so a rename can never escape its parent.

Library roots, top-level symlinks and folders holding a staged delete are
refused; the last because a staging manifest records absolute
original/staged paths, so moving it would break undo and purge. A name
collision is a 409 target_exists conflict.

The sidebar reuses the inline-row idiom from folder creation: prefilled
with the current name, inserted in place of the node with that node
hidden while editing, Enter confirms and Escape/blur cancels. The
persisted selection and the expanded set are re-keyed across the rename
so the user keeps their place in the refreshed tree.
2026-09-15 20:10:36 +08:00
Will Miao 4938faa049 feat(sidebar): delete folders from the sidebar (#999)
Folders created from the sidebar had no in-app way back out: the only
removal path was to leave ComfyUI, delete the directory by hand and
rescan. A typo'd folder also polluted the move/download destination
picker permanently, since it reads the same all_folders source.

Adds POST /api/lm/{prefix}/delete-folder, restricted to directories
whose subtree holds no model weight files — a folder-level cascade would
bypass the per-model lifecycle bookkeeping (metadata sidecars, previews,
cache entries, pending-delete staging, recipe references). The service
walks the directory itself instead of trusting the possibly stale cache,
reports what it would remove (models / files / subfolders / symlinks),
and refuses library roots, top-level symlinks (shutil.rmtree rejects
those) and folders holding a staged delete, whose manifest would be
invalidated by the move. Symbolic links inside the subtree are counted
but never followed.

ModelScanner.remove_known_folder mirrors add_known_folder: the removed
subtree leaves all_folders while ancestors are kept (every recorded
ancestor exists on disk in its own right), stale cache entries under the
prefix are purged and the folder list recomputed. The handler broadcasts
models_changed so destination pickers drop the folder too.

The sidebar entry is a destructive context-menu item. The modal opens in
a confirm state for model-free folders and an explanatory one when the
subtree still holds models, decided from the models-only set that already
dims empty nodes; a stale tree is caught by the 409 not_empty/busy
conflict. Truly empty folders get the existing 20s undo affordance,
implemented by re-creating the directory.
2026-09-15 20:05:10 +08:00
Will Miao cc8eedcff7 refactor(sidebar): inline new-folder row, drop drag-to-blank creation (#999)
- Render the new-folder input as a temporary tree row at the creation
  location (file-explorer style): full-width input confirmed with Enter
  and canceled with Escape/blur; the parent folder auto-expands, and in
  list mode the row is inserted after the parent item
- Remove the drag-to-blank-area folder creation (drop-zone strip,
  sidebar-level drag handlers, performDragMoveWithState); dropping models
  onto folder nodes still moves them
- Update empty-state hints and locale keys accordingly
2026-09-15 19:47:39 +08:00
30 changed files with 3483 additions and 728 deletions
+26 -1
View File
@@ -4,7 +4,7 @@ This document is the canonical set of conventions for translating LoRA Manager U
It applies to **human translators and AI agents** alike. Read it before editing anything in
`locales/`.
Source of truth: `locales/en.json` (10 locales, 1982 leaf keys; all locales share the exact
Source of truth: `locales/en.json` (10 locales, 2022 leaf keys; all locales share the exact
same key structure).
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
@@ -42,6 +42,13 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
> which had hardcoded "HF" for a button that now also enriches ModelScope models. The
> `modals.linkModelSource.urlPlaceholder` value stays byte-identical to `en.json` (it is a URL,
> the §6 exception). Terminology in §2, "Model source feature".
>
> **Status (2026-09, folder sidebar):** the model-root sidebar gained on-disk folder management
> (create / rename / delete folders, show empty folders, tree vs list view) plus its `...`
> view-options menu, adding 35 `sidebar.*` keys. Those were the only `[TODO: Translate]`
> placeholders left behind by the feature series, and all 35 are now translated in all 9
> locales, so the "no remaining placeholders" claim above holds again. Terminology in §2,
> "Folder sidebar feature".
---
@@ -330,6 +337,24 @@ in `en`, not "Enrich HF Metadata": they cover ModelScope as well, so no locale m
an `HF` qualifier in `loras.contextMenu.enrichHfAgent` / `loras.bulkOperations.enrichHfAgent`
(the key names keep the historical `Hf`; only the values changed).
### Folder sidebar feature (create / rename / delete folders, empty folders, view options)
The model-root sidebar manages on-disk folders. "Folder" reuses the noun already fixed in §2
(the `folder key` row); the rest is new surface:
| Term | Rendering |
|---|---|
| folder | zh-CN 文件夹 · zh-TW 資料夾 · ja フォルダ · ko 폴더 · fr dossier · de Ordner · es carpeta · ru папка · he תיקייה |
| model root (as in "no model root is configured") | zh-CN 模型根目录 · zh-TW 模型根目錄 · ja モデルルート · ko 모델 루트 · fr racine de modèle · de Modell-Stammverzeichnis · es raíz de modelo · ru корневая папка моделей · he שורש מודלים — note `sidebar.modelRoot` alone is the shorter 根目录 / 根目錄 / ルート / 루트 / Racine / Stammverzeichnis / Raíz / Корень / שורש |
| tree view / list view | zh-CN 树形视图 / 列表视图 · zh-TW 樹狀檢視 / 清單檢視 · ja ツリー表示 / リスト表示 · ko 트리 보기 / 목록 보기 · fr Vue arborescente / Vue liste · de Baumansicht / Listenansicht · es Vista de árbol / Vista de lista · ru Дерево / Список · he תצוגת עץ / תצוגת רשימה |
| sidebar | reuse each locale's `sidebar.hideOnThisPage` noun: zh-CN 侧边栏 · zh-TW 側邊欄 · ja サイドバー · ko 사이드바 · fr barre latérale · de Seitenleiste · es barra lateral · ru боковая панель · he סרגל צד |
Deleting a folder **never cascades over model files** — the backend refuses it and
`sidebar.deleteFolderModal.notEmptyMessage` states the rule in every locale, so keep that
clause (and its `—`) when the copy is edited. The `{name}` / `{count}` / `{message}` tokens in
`sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*`
are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{count}`.
---
## 3. Cross-cutting confusion hot-spots (must-fix list)
+41 -15
View File
@@ -1246,37 +1246,63 @@
"sidebar": {
"modelRoot": "Stammverzeichnis",
"collapseAll": "Alle Ordner einklappen",
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
"collapseAllDisabled": "In der Listenansicht nicht verfügbar",
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
"showSidebar": "Seitenleiste anzeigen",
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",
"viewOptions": "[TODO: Translate] View options",
"treeView": "[TODO: Translate] Tree view",
"listView": "[TODO: Translate] List view",
"viewOptions": "Ansichtsoptionen",
"treeView": "Baumansicht",
"listView": "Listenansicht",
"recursiveOn": "Unterordner einbeziehen",
"createFolder": "[TODO: Translate] New folder",
"newSubfolder": "[TODO: Translate] New subfolder",
"showEmptyFolders": "[TODO: Translate] Show empty folders",
"createFolder": "Neuer Ordner",
"newSubfolder": "Neuer Unterordner",
"showEmptyFolders": "Leere Ordner anzeigen",
"createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created",
"failed": "[TODO: Translate] Failed to create folder: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
"noRoot": "[TODO: Translate] No model root is configured"
"success": "Ordner \"{name}\" erstellt",
"failed": "Ordner konnte nicht erstellt werden: {message}",
"unsupported": "Das Erstellen von Ordnern wird auf dieser Seite nicht unterstützt",
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
},
"deleteFolder": "Ordner löschen",
"deleteFolderModal": {
"title": "Ordner löschen?",
"message": "Der Ordner und sein gesamter Inhalt werden endgültig vom Datenträger gelöscht.",
"folderLabel": "Ordner",
"emptyNote": "Dieser Ordner enthält keine Modelle. Alle anderen darin enthaltenen Dateien werden ebenfalls gelöscht.",
"notEmptyTitle": "Ordner ist nicht leer",
"notEmptyMessage": "Dieser Ordner enthält noch Modelle. Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
"confirm": "Ordner löschen"
},
"deleteFolderResult": {
"success": "Ordner \"{name}\" gelöscht",
"successWithFiles": "Ordner \"{name}\" sowie {count} weitere(s) Element(e) gelöscht",
"restored": "Ordner wiederhergestellt",
"failed": "Ordner konnte nicht gelöscht werden: {message}",
"notEmpty": "Dieser Ordner enthält noch Modelle. 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"
},
"renameFolder": "Ordner umbenennen",
"renameFolderResult": {
"success": "Ordner umbenannt in \"{name}\"",
"failed": "Ordner konnte nicht umbenannt werden: {message}",
"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"
},
"dragDrop": {
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
"moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.",
"createFolderHint": "Loslassen, um einen neuen Ordner zu erstellen",
"newFolderName": "Neuer Ordnername",
"folderNameHint": "Eingabetaste zum Bestätigen, Escape zum Abbrechen",
"emptyFolderName": "Bitte geben Sie einen Ordnernamen ein",
"invalidFolderName": "Ordnername enthält ungültige Zeichen",
"noDragState": "Kein ausstehender Ziehvorgang gefunden"
},
"empty": {
"noFolders": "Keine Ordner gefunden",
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen",
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
"createHint": "Klicken Sie oben auf „Neuer Ordner“, um Ordner zu erstellen"
},
"folderUpdateCheck": {
"label": "Auf Updates in diesem Ordner prüfen",
+30 -4
View File
@@ -1263,20 +1263,46 @@
"unsupported": "Folder creation is not supported on this page",
"noRoot": "No model root is configured"
},
"deleteFolder": "Delete folder",
"deleteFolderModal": {
"title": "Delete folder?",
"message": "The folder and everything inside it will be permanently removed from disk.",
"folderLabel": "Folder",
"emptyNote": "This folder contains no models. Any other files it holds will be deleted too.",
"notEmptyTitle": "Folder is not empty",
"notEmptyMessage": "This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.",
"confirm": "Delete folder"
},
"deleteFolderResult": {
"success": "Folder \"{name}\" deleted",
"successWithFiles": "Folder \"{name}\" deleted along with {count} other item(s)",
"restored": "Folder restored",
"failed": "Failed to delete folder: {message}",
"notEmpty": "This folder still contains models. Refresh the sidebar and try again.",
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.",
"unsupported": "Folder deletion is not supported on this page",
"noRoot": "No model root is configured"
},
"renameFolder": "Rename folder",
"renameFolderResult": {
"success": "Folder renamed to \"{name}\"",
"failed": "Failed to rename folder: {message}",
"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"
},
"dragDrop": {
"unableToResolveRoot": "Unable to determine destination path for move.",
"moveUnsupported": "Move is not supported for this item.",
"createFolderHint": "Release to create new folder",
"newFolderName": "New folder name",
"folderNameHint": "Press Enter to confirm, Escape to cancel",
"emptyFolderName": "Please enter a folder name",
"invalidFolderName": "Folder name contains invalid characters",
"noDragState": "No pending drag operation found"
},
"empty": {
"noFolders": "No folders found",
"dragHint": "Drag items here to create folders",
"createHint": "Click the New Folder button above, or drag items here to create folders"
"createHint": "Click the New Folder button above to create folders"
},
"folderUpdateCheck": {
"label": "Check for updates in this folder",
+41 -15
View File
@@ -1246,37 +1246,63 @@
"sidebar": {
"modelRoot": "Raíz",
"collapseAll": "Colapsar todas las carpetas",
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
"collapseAllDisabled": "No disponible en la vista de lista",
"hideOnThisPage": "Ocultar barra lateral en esta página",
"showSidebar": "Mostrar barra lateral",
"sidebarHiddenNotification": "Barra lateral oculta en la página {page}",
"viewOptions": "[TODO: Translate] View options",
"treeView": "[TODO: Translate] Tree view",
"listView": "[TODO: Translate] List view",
"viewOptions": "Opciones de vista",
"treeView": "Vista de árbol",
"listView": "Vista de lista",
"recursiveOn": "Incluir subcarpetas",
"createFolder": "[TODO: Translate] New folder",
"newSubfolder": "[TODO: Translate] New subfolder",
"showEmptyFolders": "[TODO: Translate] Show empty folders",
"createFolder": "Nueva carpeta",
"newSubfolder": "Nueva subcarpeta",
"showEmptyFolders": "Mostrar carpetas vacías",
"createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created",
"failed": "[TODO: Translate] Failed to create folder: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
"noRoot": "[TODO: Translate] No model root is configured"
"success": "Carpeta \"{name}\" creada",
"failed": "Error al crear la carpeta: {message}",
"unsupported": "La creación de carpetas no es compatible con esta página",
"noRoot": "No hay ninguna raíz de modelo configurada"
},
"deleteFolder": "Eliminar carpeta",
"deleteFolderModal": {
"title": "¿Eliminar carpeta?",
"message": "La carpeta y todo su contenido se eliminarán permanentemente del disco.",
"folderLabel": "Carpeta",
"emptyNote": "Esta carpeta no contiene modelos. Los demás archivos que contenga también se eliminarán.",
"notEmptyTitle": "La carpeta no está vacía",
"notEmptyMessage": "Esta carpeta aún contiene modelos. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
"confirm": "Eliminar carpeta"
},
"deleteFolderResult": {
"success": "Carpeta \"{name}\" eliminada",
"successWithFiles": "Carpeta \"{name}\" eliminada junto con {count} elemento(s) más",
"restored": "Carpeta restaurada",
"failed": "Error al eliminar la carpeta: {message}",
"notEmpty": "Esta carpeta aún contiene modelos. 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"
},
"renameFolder": "Cambiar nombre de la carpeta",
"renameFolderResult": {
"success": "Carpeta renombrada a \"{name}\"",
"failed": "Error al cambiar el nombre de la carpeta: {message}",
"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"
},
"dragDrop": {
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
"moveUnsupported": "El movimiento no es compatible con este elemento.",
"createFolderHint": "Suelta para crear una nueva carpeta",
"newFolderName": "Nombre de la nueva carpeta",
"folderNameHint": "Presiona Enter para confirmar, Escape para cancelar",
"emptyFolderName": "Por favor, introduce un nombre de carpeta",
"invalidFolderName": "El nombre de la carpeta contiene caracteres no válidos",
"noDragState": "No se encontró ninguna operación de arrastre pendiente"
},
"empty": {
"noFolders": "No se encontraron carpetas",
"dragHint": "Arrastra elementos aquí para crear carpetas",
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
"createHint": "Haz clic en el botón Nueva carpeta de arriba para crear carpetas"
},
"folderUpdateCheck": {
"label": "Buscar actualizaciones en esta carpeta",
+41 -15
View File
@@ -1246,37 +1246,63 @@
"sidebar": {
"modelRoot": "Racine",
"collapseAll": "Réduire tous les dossiers",
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
"collapseAllDisabled": "Non disponible en vue liste",
"hideOnThisPage": "Masquer la barre latérale sur cette page",
"showSidebar": "Afficher la barre latérale",
"sidebarHiddenNotification": "Barre latérale masquée sur la page {page}",
"viewOptions": "[TODO: Translate] View options",
"treeView": "[TODO: Translate] Tree view",
"listView": "[TODO: Translate] List view",
"viewOptions": "Options daffichage",
"treeView": "Vue arborescente",
"listView": "Vue liste",
"recursiveOn": "Inclure les sous-dossiers",
"createFolder": "[TODO: Translate] New folder",
"newSubfolder": "[TODO: Translate] New subfolder",
"showEmptyFolders": "[TODO: Translate] Show empty folders",
"createFolder": "Nouveau dossier",
"newSubfolder": "Nouveau sous-dossier",
"showEmptyFolders": "Afficher les dossiers vides",
"createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created",
"failed": "[TODO: Translate] Failed to create folder: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
"noRoot": "[TODO: Translate] No model root is configured"
"success": "Dossier \"{name}\" créé",
"failed": "Échec de la création du dossier : {message}",
"unsupported": "La création de dossiers nest pas prise en charge sur cette page",
"noRoot": "Aucune racine de modèle nest configurée"
},
"deleteFolder": "Supprimer le dossier",
"deleteFolderModal": {
"title": "Supprimer le dossier ?",
"message": "Le dossier et tout son contenu seront définitivement supprimés du disque.",
"folderLabel": "Dossier",
"emptyNote": "Ce dossier ne contient aucun modèle. Les autres fichiers quil contient seront également supprimés.",
"notEmptyTitle": "Le dossier nest pas vide",
"notEmptyMessage": "Ce dossier contient encore des modèles. Supprimez-les ou déplacez-les dabord — la suppression dun dossier nentraîne jamais celle des fichiers de modèles.",
"confirm": "Supprimer le dossier"
},
"deleteFolderResult": {
"success": "Dossier \"{name}\" supprimé",
"successWithFiles": "Dossier \"{name}\" supprimé, ainsi que {count} autre(s) élément(s)",
"restored": "Dossier restauré",
"failed": "Échec de la suppression du dossier : {message}",
"notEmpty": "Ce dossier contient encore des modèles. 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 dannulation.",
"unsupported": "La suppression de dossiers nest pas prise en charge sur cette page",
"noRoot": "Aucune racine de modèle nest configurée"
},
"renameFolder": "Renommer le dossier",
"renameFolderResult": {
"success": "Dossier renommé en \"{name}\"",
"failed": "Échec du renommage du dossier : {message}",
"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 dannulation.",
"unsupported": "Le renommage de dossiers nest pas pris en charge sur cette page",
"noRoot": "Aucune racine de modèle nest configurée"
},
"dragDrop": {
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
"moveUnsupported": "Le déplacement n'est pas pris en charge pour cet élément.",
"createFolderHint": "Relâcher pour créer un nouveau dossier",
"newFolderName": "Nom du nouveau dossier",
"folderNameHint": "Appuyez sur Entrée pour confirmer, Échap pour annuler",
"emptyFolderName": "Veuillez saisir un nom de dossier",
"invalidFolderName": "Le nom du dossier contient des caractères invalides",
"noDragState": "Aucune opération de glissement en attente trouvée"
},
"empty": {
"noFolders": "Aucun dossier trouvé",
"dragHint": "Faites glisser des éléments ici pour créer des dossiers",
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
"createHint": "Cliquez sur le bouton Nouveau dossier ci-dessus pour créer des dossiers"
},
"folderUpdateCheck": {
"label": "Vérifier les mises à jour dans ce dossier",
+41 -15
View File
@@ -1246,37 +1246,63 @@
"sidebar": {
"modelRoot": "שורש",
"collapseAll": "כווץ את כל התיקיות",
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
"hideOnThisPage": "הסתר סרגל צד בדף זה",
"showSidebar": "הצג סרגל צד",
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
"viewOptions": "[TODO: Translate] View options",
"treeView": "[TODO: Translate] Tree view",
"listView": "[TODO: Translate] List view",
"viewOptions": "אפשרויות תצוגה",
"treeView": "תצוגת עץ",
"listView": "תצוגת רשימה",
"recursiveOn": "כלול תיקיות משנה",
"createFolder": "[TODO: Translate] New folder",
"newSubfolder": "[TODO: Translate] New subfolder",
"showEmptyFolders": "[TODO: Translate] Show empty folders",
"createFolder": "תיקייה חדשה",
"newSubfolder": "תיקיית משנה חדשה",
"showEmptyFolders": "הצג תיקיות ריקות",
"createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created",
"failed": "[TODO: Translate] Failed to create folder: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
"noRoot": "[TODO: Translate] No model root is configured"
"success": "התיקייה \"{name}\" נוצרה",
"failed": "יצירת התיקייה נכשלה: {message}",
"unsupported": "יצירת תיקיות אינה נתמכת בדף זה",
"noRoot": "לא הוגדר שורש מודלים"
},
"deleteFolder": "מחק תיקייה",
"deleteFolderModal": {
"title": "למחוק את התיקייה?",
"message": "התיקייה וכל תוכנה יימחקו לצמיתות מהדיסק.",
"folderLabel": "תיקייה",
"emptyNote": "אין מודלים בתיקייה זו. קבצים אחרים שבה יימחקו גם הם.",
"notEmptyTitle": "התיקייה אינה ריקה",
"notEmptyMessage": "בתיקייה זו עדיין יש מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"confirm": "מחק תיקייה"
},
"deleteFolderResult": {
"success": "התיקייה \"{name}\" נמחקה",
"successWithFiles": "התיקייה \"{name}\" נמחקה יחד עם {count} פריטים נוספים",
"restored": "התיקייה שוחזרה",
"failed": "מחיקת התיקייה נכשלה: {message}",
"notEmpty": "בתיקייה זו עדיין יש מודלים. רענן את סרגל הצד ונסה שוב.",
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
"unsupported": "מחיקת תיקיות אינה נתמכת בדף זה",
"noRoot": "לא הוגדר שורש מודלים"
},
"renameFolder": "שנה שם תיקייה",
"renameFolderResult": {
"success": "שם התיקייה שונה ל-\"{name}\"",
"failed": "שינוי שם התיקייה נכשל: {message}",
"targetExists": "תיקייה בשם זה כבר קיימת כאן",
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
"unsupported": "שינוי שם תיקיות אינו נתמך בדף זה",
"noRoot": "לא הוגדר שורש מודלים"
},
"dragDrop": {
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
"moveUnsupported": "העברה אינה נתמכת עבור פריט זה.",
"createFolderHint": "שחרר כדי ליצור תיקייה חדשה",
"newFolderName": "שם תיקייה חדשה",
"folderNameHint": "הקש Enter לאישור, Escape לביטול",
"emptyFolderName": "אנא הזן שם תיקייה",
"invalidFolderName": "שם התיקייה מכיל תווים לא חוקיים",
"noDragState": "לא נמצאה פעולת גרירה ממתינה"
},
"empty": {
"noFolders": "לא נמצאו תיקיות",
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות",
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
"createHint": "לחץ על כפתור תיקייה חדשה למעלה כדי ליצור תיקיות"
},
"folderUpdateCheck": {
"label": "בדוק עדכונים בתיקייה זו",
+41 -15
View File
@@ -1246,37 +1246,63 @@
"sidebar": {
"modelRoot": "ルート",
"collapseAll": "すべてのフォルダを折りたたむ",
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
"collapseAllDisabled": "リスト表示では利用できません",
"hideOnThisPage": "このページでサイドバーを非表示",
"showSidebar": "サイドバーを表示",
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
"viewOptions": "[TODO: Translate] View options",
"treeView": "[TODO: Translate] Tree view",
"listView": "[TODO: Translate] List view",
"viewOptions": "表示オプション",
"treeView": "ツリー表示",
"listView": "リスト表示",
"recursiveOn": "サブフォルダーを含める",
"createFolder": "[TODO: Translate] New folder",
"newSubfolder": "[TODO: Translate] New subfolder",
"showEmptyFolders": "[TODO: Translate] Show empty folders",
"createFolder": "新規フォルダ",
"newSubfolder": "新規サブフォルダ",
"showEmptyFolders": "空のフォルダを表示",
"createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created",
"failed": "[TODO: Translate] Failed to create folder: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
"noRoot": "[TODO: Translate] No model root is configured"
"success": "フォルダ \"{name}\" を作成しました",
"failed": "フォルダの作成に失敗しました: {message}",
"unsupported": "このページではフォルダを作成できません",
"noRoot": "モデルルートが設定されていません"
},
"deleteFolder": "フォルダを削除",
"deleteFolderModal": {
"title": "フォルダを削除しますか?",
"message": "フォルダとその内容はすべてディスクから完全に削除されます。",
"folderLabel": "フォルダ",
"emptyNote": "このフォルダにはモデルがありません。他のファイルもすべて削除されます。",
"notEmptyTitle": "フォルダが空ではありません",
"notEmptyMessage": "このフォルダにはまだモデルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"confirm": "フォルダを削除"
},
"deleteFolderResult": {
"success": "フォルダ \"{name}\" を削除しました",
"successWithFiles": "フォルダ \"{name}\" を削除し、他に {count} 件の項目も削除しました",
"restored": "フォルダを復元しました",
"failed": "フォルダの削除に失敗しました: {message}",
"notEmpty": "このフォルダにはまだモデルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
"unsupported": "このページではフォルダを削除できません",
"noRoot": "モデルルートが設定されていません"
},
"renameFolder": "フォルダ名を変更",
"renameFolderResult": {
"success": "フォルダ名を \"{name}\" に変更しました",
"failed": "フォルダ名の変更に失敗しました: {message}",
"targetExists": "同じ名前のフォルダが既に存在します",
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
"unsupported": "このページではフォルダ名を変更できません",
"noRoot": "モデルルートが設定されていません"
},
"dragDrop": {
"unableToResolveRoot": "移動先のパスを特定できません。",
"moveUnsupported": "この項目の移動はサポートされていません。",
"createFolderHint": "放して新しいフォルダを作成",
"newFolderName": "新しいフォルダ名",
"folderNameHint": "Enterで確定、Escでキャンセル",
"emptyFolderName": "フォルダ名を入力してください",
"invalidFolderName": "フォルダ名に無効な文字が含まれています",
"noDragState": "保留中のドラッグ操作が見つかりません"
},
"empty": {
"noFolders": "フォルダが見つかりません",
"dragHint": "ここへアイテムをドラッグしてフォルダを作成ます",
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
"createHint": "上部の新規フォルダボタンからフォルダを作成できます"
},
"folderUpdateCheck": {
"label": "このフォルダのアップデートを確認",
+41 -15
View File
@@ -1246,37 +1246,63 @@
"sidebar": {
"modelRoot": "루트",
"collapseAll": "모든 폴더 접기",
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
"hideOnThisPage": "이 페이지에서 사이드바 숨기기",
"showSidebar": "사이드바 표시",
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
"viewOptions": "[TODO: Translate] View options",
"treeView": "[TODO: Translate] Tree view",
"listView": "[TODO: Translate] List view",
"viewOptions": "보기 옵션",
"treeView": "트리 보기",
"listView": "목록 보기",
"recursiveOn": "하위 폴더 포함",
"createFolder": "[TODO: Translate] New folder",
"newSubfolder": "[TODO: Translate] New subfolder",
"showEmptyFolders": "[TODO: Translate] Show empty folders",
"createFolder": "새 폴더",
"newSubfolder": "새 하위 폴더",
"showEmptyFolders": "빈 폴더 표시",
"createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created",
"failed": "[TODO: Translate] Failed to create folder: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
"noRoot": "[TODO: Translate] No model root is configured"
"success": "\"{name}\" 폴더를 생성했습니다",
"failed": "폴더 생성 실패: {message}",
"unsupported": "이 페이지에서는 폴더를 만들 수 없습니다",
"noRoot": "모델 루트가 설정되지 않았습니다"
},
"deleteFolder": "폴더 삭제",
"deleteFolderModal": {
"title": "폴더를 삭제할까요?",
"message": "폴더와 그 안의 모든 내용이 디스크에서 영구적으로 삭제됩니다.",
"folderLabel": "폴더",
"emptyNote": "이 폴더에는 모델이 없습니다. 폴더 안의 다른 파일도 함께 삭제됩니다.",
"notEmptyTitle": "폴더가 비어 있지 않습니다",
"notEmptyMessage": "이 폴더에는 아직 모델이 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"confirm": "폴더 삭제"
},
"deleteFolderResult": {
"success": "\"{name}\" 폴더를 삭제했습니다",
"successWithFiles": "\"{name}\" 폴더와 {count}개 항목을 함께 삭제했습니다",
"restored": "폴더를 복원했습니다",
"failed": "폴더 삭제 실패: {message}",
"notEmpty": "이 폴더에는 아직 모델이 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
"unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다",
"noRoot": "모델 루트가 설정되지 않았습니다"
},
"renameFolder": "폴더 이름 바꾸기",
"renameFolderResult": {
"success": "폴더 이름을 \"{name}\"(으)로 변경했습니다",
"failed": "폴더 이름 바꾸기 실패: {message}",
"targetExists": "같은 이름의 폴더가 이미 있습니다",
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
"unsupported": "이 페이지에서는 폴더 이름을 바꿀 수 없습니다",
"noRoot": "모델 루트가 설정되지 않았습니다"
},
"dragDrop": {
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
"moveUnsupported": "이 항목은 이동을 지원하지 않습니다.",
"createFolderHint": "놓아서 새 폴더 만들기",
"newFolderName": "새 폴더 이름",
"folderNameHint": "Enter를 눌러 확인, Escape를 눌러 취소",
"emptyFolderName": "폴더 이름을 입력하세요",
"invalidFolderName": "폴더 이름에 잘못된 문자가 포함되어 있습니다",
"noDragState": "보류 중인 드래그 작업을 찾을 수 없습니다"
},
"empty": {
"noFolders": "폴더를 찾을 수 없습니다",
"dragHint": "항목을 여기로 드래그하여 폴더를 만니다",
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
"createHint": "위의 새 폴더 버튼을 클릭하여 폴더를 만들 수 있습니다"
},
"folderUpdateCheck": {
"label": "이 폴더의 업데이트 확인",
+41 -15
View File
@@ -1246,37 +1246,63 @@
"sidebar": {
"modelRoot": "Корень",
"collapseAll": "Свернуть все папки",
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
"collapseAllDisabled": "Недоступно в виде списка",
"hideOnThisPage": "Скрыть боковую панель на этой странице",
"showSidebar": "Показать боковую панель",
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
"viewOptions": "[TODO: Translate] View options",
"treeView": "[TODO: Translate] Tree view",
"listView": "[TODO: Translate] List view",
"viewOptions": "Параметры отображения",
"treeView": "Дерево",
"listView": "Список",
"recursiveOn": "Включать вложенные папки",
"createFolder": "[TODO: Translate] New folder",
"newSubfolder": "[TODO: Translate] New subfolder",
"showEmptyFolders": "[TODO: Translate] Show empty folders",
"createFolder": "Новая папка",
"newSubfolder": "Новая вложенная папка",
"showEmptyFolders": "Показывать пустые папки",
"createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created",
"failed": "[TODO: Translate] Failed to create folder: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
"noRoot": "[TODO: Translate] No model root is configured"
"success": "Папка \"{name}\" создана",
"failed": "Не удалось создать папку: {message}",
"unsupported": "Создание папок не поддерживается на этой странице",
"noRoot": "Корневая папка моделей не настроена"
},
"deleteFolder": "Удалить папку",
"deleteFolderModal": {
"title": "Удалить папку?",
"message": "Папка и всё её содержимое будут безвозвратно удалены с диска.",
"folderLabel": "Папка",
"emptyNote": "В этой папке нет моделей. Остальные файлы в ней тоже будут удалены.",
"notEmptyTitle": "Папка не пуста",
"notEmptyMessage": "В этой папке ещё есть модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
"confirm": "Удалить папку"
},
"deleteFolderResult": {
"success": "Папка \"{name}\" удалена",
"successWithFiles": "Папка \"{name}\" удалена вместе с ещё {count} элемент(ами)",
"restored": "Папка восстановлена",
"failed": "Не удалось удалить папку: {message}",
"notEmpty": "В этой папке ещё есть модели. Обновите боковую панель и повторите попытку.",
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
"unsupported": "Удаление папок не поддерживается на этой странице",
"noRoot": "Корневая папка моделей не настроена"
},
"renameFolder": "Переименовать папку",
"renameFolderResult": {
"success": "Папка переименована в \"{name}\"",
"failed": "Не удалось переименовать папку: {message}",
"targetExists": "Папка с таким именем уже существует здесь",
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
"unsupported": "Переименование папок не поддерживается на этой странице",
"noRoot": "Корневая папка моделей не настроена"
},
"dragDrop": {
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
"moveUnsupported": "Перемещение этого элемента не поддерживается.",
"createFolderHint": "Отпустите, чтобы создать новую папку",
"newFolderName": "Имя новой папки",
"folderNameHint": "Нажмите Enter для подтверждения, Escape для отмены",
"emptyFolderName": "Пожалуйста, введите имя папки",
"invalidFolderName": "Имя папки содержит недопустимые символы",
"noDragState": "Ожидающая операция перетаскивания не найдена"
},
"empty": {
"noFolders": "Папки не найдены",
"dragHint": "Перетащите элементы сюда, чтобы создать папки",
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
"createHint": "Нажмите кнопку «Новая папка» вверху, чтобы создать папки"
},
"folderUpdateCheck": {
"label": "Проверить обновления в этой папке",
+41 -15
View File
@@ -1246,37 +1246,63 @@
"sidebar": {
"modelRoot": "根目录",
"collapseAll": "折叠所有文件夹",
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
"collapseAllDisabled": "列表视图下不可用",
"hideOnThisPage": "隐藏此页面侧边栏",
"showSidebar": "显示侧边栏",
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
"viewOptions": "[TODO: Translate] View options",
"treeView": "[TODO: Translate] Tree view",
"listView": "[TODO: Translate] List view",
"viewOptions": "视图选项",
"treeView": "树形视图",
"listView": "列表视图",
"recursiveOn": "包含子文件夹",
"createFolder": "[TODO: Translate] New folder",
"newSubfolder": "[TODO: Translate] New subfolder",
"showEmptyFolders": "[TODO: Translate] Show empty folders",
"createFolder": "新建文件夹",
"newSubfolder": "新建子文件夹",
"showEmptyFolders": "显示空文件夹",
"createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created",
"failed": "[TODO: Translate] Failed to create folder: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
"noRoot": "[TODO: Translate] No model root is configured"
"success": "已创建文件夹 \"{name}\"",
"failed": "创建文件夹失败: {message}",
"unsupported": "此页面不支持创建文件夹",
"noRoot": "未配置模型根目录"
},
"deleteFolder": "删除文件夹",
"deleteFolderModal": {
"title": "删除文件夹?",
"message": "该文件夹及其中所有内容都将从磁盘上永久删除。",
"folderLabel": "文件夹",
"emptyNote": "该文件夹中没有模型,其中的其他文件也会一并删除。",
"notEmptyTitle": "文件夹不为空",
"notEmptyMessage": "该文件夹中仍有模型,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
"confirm": "删除文件夹"
},
"deleteFolderResult": {
"success": "已删除文件夹 \"{name}\"",
"successWithFiles": "已删除文件夹 \"{name}\",同时删除了另外 {count} 项内容",
"restored": "文件夹已恢复",
"failed": "删除文件夹失败: {message}",
"notEmpty": "该文件夹中仍有模型。请刷新侧边栏后重试。",
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
"unsupported": "此页面不支持删除文件夹",
"noRoot": "未配置模型根目录"
},
"renameFolder": "重命名文件夹",
"renameFolderResult": {
"success": "文件夹已重命名为 \"{name}\"",
"failed": "重命名文件夹失败: {message}",
"targetExists": "此处已存在同名文件夹",
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
"unsupported": "此页面不支持重命名文件夹",
"noRoot": "未配置模型根目录"
},
"dragDrop": {
"unableToResolveRoot": "无法确定移动的目标路径。",
"moveUnsupported": "此条目不支持移动。",
"createFolderHint": "释放以创建新文件夹",
"newFolderName": "新文件夹名称",
"folderNameHint": "按 Enter 确认,Escape 取消",
"emptyFolderName": "请输入文件夹名称",
"invalidFolderName": "文件夹名称包含无效字符",
"noDragState": "未找到待处理的拖放操作"
},
"empty": {
"noFolders": "未找到文件夹",
"dragHint": "拖拽项目到此处以创建文件夹",
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
"createHint": "点击上方的新建文件夹按钮即可创建文件夹"
},
"folderUpdateCheck": {
"label": "检查此文件夹的更新",
+41 -15
View File
@@ -1246,37 +1246,63 @@
"sidebar": {
"modelRoot": "根目錄",
"collapseAll": "全部摺疊資料夾",
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
"collapseAllDisabled": "清單檢視下無法使用",
"hideOnThisPage": "隱藏此頁面側邊欄",
"showSidebar": "顯示側邊欄",
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
"viewOptions": "[TODO: Translate] View options",
"treeView": "[TODO: Translate] Tree view",
"listView": "[TODO: Translate] List view",
"viewOptions": "檢視選項",
"treeView": "樹狀檢視",
"listView": "清單檢視",
"recursiveOn": "包含子資料夾",
"createFolder": "[TODO: Translate] New folder",
"newSubfolder": "[TODO: Translate] New subfolder",
"showEmptyFolders": "[TODO: Translate] Show empty folders",
"createFolder": "新增資料夾",
"newSubfolder": "新增子資料夾",
"showEmptyFolders": "顯示空資料夾",
"createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created",
"failed": "[TODO: Translate] Failed to create folder: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
"noRoot": "[TODO: Translate] No model root is configured"
"success": "已建立資料夾 \"{name}\"",
"failed": "建立資料夾失敗: {message}",
"unsupported": "此頁面不支援建立資料夾",
"noRoot": "未設定模型根目錄"
},
"deleteFolder": "刪除資料夾",
"deleteFolderModal": {
"title": "刪除資料夾?",
"message": "該資料夾及其中的所有內容都將從磁碟上永久刪除。",
"folderLabel": "資料夾",
"emptyNote": "該資料夾中沒有模型,其中的其他檔案也會一併刪除。",
"notEmptyTitle": "資料夾不是空的",
"notEmptyMessage": "該資料夾中仍有模型,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
"confirm": "刪除資料夾"
},
"deleteFolderResult": {
"success": "已刪除資料夾 \"{name}\"",
"successWithFiles": "已刪除資料夾 \"{name}\",同時刪除了另外 {count} 項內容",
"restored": "資料夾已還原",
"failed": "刪除資料夾失敗: {message}",
"notEmpty": "該資料夾中仍有模型。請重新整理側邊欄後再試。",
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
"unsupported": "此頁面不支援刪除資料夾",
"noRoot": "未設定模型根目錄"
},
"renameFolder": "重新命名資料夾",
"renameFolderResult": {
"success": "資料夾已重新命名為 \"{name}\"",
"failed": "重新命名資料夾失敗: {message}",
"targetExists": "此處已存在同名資料夾",
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
"unsupported": "此頁面不支援重新命名資料夾",
"noRoot": "未設定模型根目錄"
},
"dragDrop": {
"unableToResolveRoot": "無法確定移動的目標路徑。",
"moveUnsupported": "此項目不支援移動。",
"createFolderHint": "放開以建立新資料夾",
"newFolderName": "新資料夾名稱",
"folderNameHint": "按 Enter 確認,Escape 取消",
"emptyFolderName": "請輸入資料夾名稱",
"invalidFolderName": "資料夾名稱包含無效字元",
"noDragState": "未找到待處理的拖放操作"
},
"empty": {
"noFolders": "未找到資料夾",
"dragHint": "將項目拖到此處以建立資料夾",
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
"createHint": "點擊上方的新增資料夾按鈕即可建立資料夾"
},
"folderUpdateCheck": {
"label": "檢查此資料夾的更新",
+66
View File
@@ -2499,6 +2499,70 @@ 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 delete_folder(self, request: web.Request) -> web.Response:
try:
data = await request.json()
except Exception:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
try:
folder_path = data.get("folder_path")
if not folder_path:
return web.json_response(
{"success": False, "error": "Folder path is required"}, status=400
)
dry_run = bool(data.get("dry_run"))
result = await self._move_service.delete_folder(
folder_path, dry_run=dry_run
)
if result.get("success"):
if not dry_run:
_broadcast_models_changed()
return web.json_response(result, status=200)
# "not_empty" / "busy" are conflicts between the tree the client
# rendered and the on-disk truth; everything else is a bad request.
code = result.get("code")
status = 409 if code in ("not_empty", "busy") else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error deleting folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def rename_folder(self, request: web.Request) -> web.Response:
try:
data = await request.json()
except Exception:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
try:
folder_path = data.get("folder_path")
new_name = data.get("new_name")
if not folder_path:
return web.json_response(
{"success": False, "error": "Folder path is required"}, status=400
)
if not new_name:
return web.json_response(
{"success": False, "error": "New folder name is required"}, status=400
)
result = await self._move_service.rename_folder(folder_path, new_name)
if result.get("success"):
if result.get("renamed"):
_broadcast_models_changed()
return web.json_response(result, status=200)
# A name collision or a staged delete inside the subtree is a
# conflict with the state the client rendered, not a bad request.
code = result.get("code")
status = 409 if code in ("target_exists", "busy") else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error renaming folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def move_model(self, request: web.Request) -> web.Response:
try:
data = await request.json()
@@ -3450,6 +3514,8 @@ class ModelHandlerSet:
"move_model": self.move.move_model,
"move_models_bulk": self.move.move_models_bulk,
"create_folder": self.move.create_folder,
"delete_folder": self.move.delete_folder,
"rename_folder": self.move.rename_folder,
"auto_organize_models": self.auto_organize.auto_organize_models,
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
"get_model_notes": self.query.get_model_notes,
+2
View File
@@ -41,6 +41,8 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/{prefix}/move_model", "move_model"),
RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"),
RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"),
RouteDefinition("POST", "/api/lm/{prefix}/delete-folder", "delete_folder"),
RouteDefinition("POST", "/api/lm/{prefix}/rename-folder", "rename_folder"),
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition(
+302 -1
View File
@@ -2,13 +2,15 @@ import asyncio
import fnmatch
import os
import logging
import shutil
from typing import Any, Dict, List, Optional, Sequence, Set
from abc import ABC, abstractmethod
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE, MODEL_FILE_EXTENSIONS
from ..services.settings_manager import get_settings_manager
from ..services.model_lifecycle_service import _require_path_in_library_roots
from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
logger = logging.getLogger(__name__)
@@ -536,6 +538,305 @@ class ModelMoveService:
return rel.replace(os.sep, "/")
return ""
async def delete_folder(self, folder_path: str, dry_run: bool = False) -> Dict[str, Any]:
"""Delete a model-free directory inside the model library roots.
Only directories whose subtree holds no model weight files can be
removed: a folder-level cascade would bypass the per-model lifecycle
bookkeeping (metadata sidecars, previews, cache entries, pending-delete
staging and recipe references), so it is deliberately refused. Leftover
non-model files (stray previews, sidecars, ``.bak`` files) are reported
in the manifest before they are removed.
Args:
folder_path: Absolute path of the directory to remove (business
path symlinks are not resolved)
dry_run: When true, only report what would be removed
Returns:
Dictionary with the success flag plus a removal manifest
(``model_count``/``file_count``/``dir_count``/``symlink_count``/
``total_bytes``/``restorable``) on success.
"""
try:
if not folder_path or not str(folder_path).strip():
return {"success": False, "error": "Folder path is required"}
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
absolute_path = os.path.abspath(folder_path)
if os.path.islink(absolute_path):
# shutil.rmtree refuses symlinked roots, and silently deleting
# the link (leaving the real directory behind) is a separate
# decision we do not make here.
return {
"success": False,
"error": "Symlinked folders cannot be deleted",
}
if not os.path.isdir(absolute_path):
return {"success": False, "error": "Folder no longer exists"}
if self._is_model_root(absolute_path):
return {
"success": False,
"error": "The library root itself cannot be deleted",
}
manifest = self._collect_folder_manifest(absolute_path)
if manifest["pending_delete_job"]:
return {
"success": False,
"code": "busy",
"error": (
"A staged delete is still pending inside this folder; "
"wait for the undo window to expire"
),
"manifest": manifest,
}
if manifest["model_count"] > 0:
return {
"success": False,
"code": "not_empty",
"error": (
f"Folder still contains {manifest['model_count']} model "
"file(s); delete or move them first"
),
"manifest": manifest,
}
relative_folder = self._calculate_relative_folder(absolute_path)
if dry_run:
return {
"success": True,
"dry_run": True,
"folder_path": absolute_path.replace(os.sep, "/"),
"folder": relative_folder,
**manifest,
}
shutil.rmtree(absolute_path)
await self._forget_folder(relative_folder)
return {
"success": True,
"dry_run": False,
"folder_path": absolute_path.replace(os.sep, "/"),
"folder": relative_folder,
**manifest,
}
except ValueError as exc:
return {"success": False, "error": str(exc)}
except Exception as exc:
logger.error(f"Error deleting folder: {exc}", exc_info=True)
return {"success": False, "error": str(exc)}
def _is_model_root(self, absolute_path: str) -> bool:
"""Return True when the path *is* one of the configured library roots."""
normalized = os.path.normpath(absolute_path)
for root in self.scanner.get_model_roots():
if os.path.normpath(os.path.abspath(root)) == normalized:
return True
return False
@staticmethod
def _is_model_file(file_name: str) -> bool:
"""Return True when the file name carries a model weight extension."""
return os.path.splitext(file_name)[1].lower() in MODEL_FILE_EXTENSIONS
def _collect_folder_manifest(self, absolute_path: str) -> Dict[str, Any]:
"""Describe everything a recursive delete of *absolute_path* removes.
Walking is intentional: the scanner cache can be stale, and a model file
that appeared on disk since the last scan must still block the delete.
Symbolic links are never followed (``os.walk`` default) and are counted
separately ``shutil.rmtree`` unlinks them without touching their
targets.
"""
model_count = 0
file_count = 0
dir_count = 0
symlink_count = 0
total_bytes = 0
pending_delete_job = False
for dirpath, dirnames, filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames:
pending_delete_job = True
for name in dirnames:
if os.path.islink(os.path.join(dirpath, name)):
symlink_count += 1
else:
dir_count += 1
for name in filenames:
full_path = os.path.join(dirpath, name)
if os.path.islink(full_path):
symlink_count += 1
continue
if self._is_model_file(name):
model_count += 1
else:
file_count += 1
try:
total_bytes += os.path.getsize(full_path)
except OSError: # pragma: no cover - defensive
pass
return {
"model_count": model_count,
"file_count": file_count,
"dir_count": dir_count,
"symlink_count": symlink_count,
"total_bytes": total_bytes,
"pending_delete_job": pending_delete_job,
# A truly empty directory is the only case an "undo" can restore by
# simply recreating it; a folder holding stray files is gone for good.
"restorable": (
model_count == 0
and file_count == 0
and dir_count == 0
and symlink_count == 0
),
}
async def _forget_folder(self, relative_folder: str) -> None:
"""Drop a removed directory from the scanner's folder/cache records."""
if not relative_folder:
return
remove_known_folder = getattr(self.scanner, "remove_known_folder", None)
if callable(remove_known_folder):
await remove_known_folder(relative_folder)
async def rename_folder(self, folder_path: str, new_name: str) -> Dict[str, Any]:
"""Rename a directory inside the model library roots.
Unlike :meth:`delete_folder` this works on folders that hold models.
A rename keeps every file, so no per-model lifecycle step is bypassed:
the directory is renamed on disk and the affected folder, cache, hash
index and metadata-sidecar records are re-keyed onto the new prefix by
the scanner.
Args:
folder_path: Absolute path of the directory to rename (business
path symlinks are not resolved)
new_name: New leaf name; a single path segment, not a path
Returns:
Dictionary with the success flag, the previous/next library-relative
folder names and whether the directory actually moved.
"""
try:
if not folder_path or not str(folder_path).strip():
return {"success": False, "error": "Folder path is required"}
new_name = str(new_name or "").strip()
if not new_name:
return {"success": False, "error": "New folder name is required"}
if new_name in (".", "..") or any(
char in new_name for char in '/\\:*?"<>|'
):
return {"success": False, "error": "Invalid characters in folder name"}
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
absolute_path = os.path.abspath(folder_path)
if os.path.islink(absolute_path):
return {
"success": False,
"error": "Symlinked folders cannot be renamed",
}
if not os.path.isdir(absolute_path):
return {"success": False, "error": "Folder no longer exists"}
if self._is_model_root(absolute_path):
return {
"success": False,
"error": "The library root itself cannot be renamed",
}
previous_relative = self._calculate_relative_folder(absolute_path)
target = os.path.join(os.path.dirname(absolute_path), new_name)
if os.path.normpath(target) == os.path.normpath(absolute_path):
return {
"success": True,
"renamed": False,
"folder": previous_relative,
"previous_folder": previous_relative,
"folder_path": absolute_path.replace(os.sep, "/"),
}
if os.path.exists(target):
return {
"success": False,
"code": "target_exists",
"error": f"A folder named \"{new_name}\" already exists here",
}
# A staging manifest records absolute original/staged paths, so
# moving a folder that holds one would break its undo and purge.
if self._has_pending_delete_job(absolute_path):
return {
"success": False,
"code": "busy",
"error": (
"A staged delete is still pending inside this folder; "
"wait for the undo window to expire"
),
}
os.rename(absolute_path, target)
new_relative = self._calculate_relative_folder(target)
await self._rename_folder_records(
previous_relative, new_relative, absolute_path, target
)
return {
"success": True,
"renamed": True,
"folder": new_relative,
"previous_folder": previous_relative,
"folder_path": target.replace(os.sep, "/"),
}
except ValueError as exc:
return {"success": False, "error": str(exc)}
except Exception as exc:
logger.error(f"Error renaming folder: {exc}", exc_info=True)
return {"success": False, "error": str(exc)}
@staticmethod
def _has_pending_delete_job(absolute_path: str) -> bool:
"""Return True when a staged-delete batch lives inside the subtree."""
for _dirpath, dirnames, _filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames:
return True
return False
async def _rename_folder_records(
self,
previous_relative: str,
new_relative: str,
previous_path: str,
new_path: str,
) -> None:
"""Hand the rename to the scanner so folder/cache records follow it."""
if not previous_relative or not new_relative:
return
rename_known_folder = getattr(self.scanner, "rename_known_folder", None)
if callable(rename_known_folder):
await rename_known_folder(
previous_relative,
new_relative,
previous_path=previous_path,
new_path=new_path,
)
async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
"""Move a single model file
+210
View File
@@ -1505,6 +1505,216 @@ class ModelScanner:
await self._persist_current_cache()
self.bump_cache_version()
async def remove_known_folder(self, folder: str) -> None:
"""Forget a folder (and its subtree) that no longer exists on disk.
Counterpart of :meth:`add_known_folder`, called after a directory is
removed between scans (e.g. via the delete-folder API) so folder trees
and the move/download destination pickers stop offering it without a
full rescan. Ancestors are kept on purpose: every recorded ancestor
exists on disk in its own right, so only the removed subtree is dropped.
Cache entries that referenced the now-missing directory are purged as
well, which keeps a stale (phantom) model card from surviving the
deletion. When ``all_folders`` has not been recorded yet (legacy
snapshot) only the cache purge runs the scheduled backfill walk
rebuilds the folder list from disk.
"""
normalized = folder.replace("\\", "/").strip("/")
if not normalized:
return
cache = self._cache
if cache is None:
return
prefix = f"{normalized}/"
folders_changed = False
recorded = getattr(cache, "all_folders", None)
if recorded is not None:
updated = [
entry
for entry in recorded
if entry != normalized and not entry.startswith(prefix)
]
if updated != list(recorded):
cache.all_folders = updated
folders_changed = True
stale_paths = [
item.get("file_path")
for item in (cache.raw_data or [])
if self._folder_within(item.get("folder", ""), normalized)
]
if stale_paths:
# The purge persists the cache — including the already updated
# all_folders list — and bumps the version itself.
await self._batch_update_cache_for_deleted_models(stale_paths)
folders = set(item.get("folder", "") for item in cache.raw_data)
cache.folders = sorted(folders, key=lambda x: x.lower())
elif folders_changed:
await self._persist_current_cache()
self.bump_cache_version()
@staticmethod
def _folder_within(candidate: str, target: str) -> bool:
"""Return True when *candidate* is *target* or lives below it."""
return candidate == target or candidate.startswith(f"{target}/")
@staticmethod
def _rekey_path(value: str, old_prefix: str, new_prefix: str) -> str:
"""Move a stored path (or URL) from *old_prefix* onto *new_prefix*."""
if not value:
return value
normalized = value.replace("\\", "/")
if normalized.startswith(old_prefix):
return new_prefix + normalized[len(old_prefix):]
return value
async def rename_known_folder(
self,
previous_folder: str,
new_folder: str,
*,
previous_path: str,
new_path: str,
) -> bool:
"""Re-key folder, cache and metadata records after a directory rename.
Counterpart of :meth:`add_known_folder` / :meth:`remove_known_folder`.
A rename keeps every file, so nothing may be dropped: the recorded
folder list, the affected cache entries (``file_path``/``folder``/
``preview_url``), the hash index and the on-disk metadata sidecars are
all rewritten onto the new prefix. That is what lets a folder full of
models be renamed without a rescan and without breaking per-model
bookkeeping.
Args:
previous_folder: Library-relative folder name before the rename
new_folder: Library-relative folder name after the rename
previous_path: Absolute directory path before the rename
new_path: Absolute directory path after the rename
Returns:
True when any recorded data was rewritten.
"""
previous = previous_folder.replace("\\", "/").strip("/")
current = new_folder.replace("\\", "/").strip("/")
if not previous or not current or previous == current:
return False
old_rel_prefix = f"{previous}/"
new_rel_prefix = f"{current}/"
old_abs_prefix = f"{str(previous_path).replace(chr(92), '/').rstrip('/')}/"
new_abs_prefix = f"{str(new_path).replace(chr(92), '/').rstrip('/')}/"
cache = self._cache
if cache is None:
return False
changed = False
recorded = getattr(cache, "all_folders", None)
if recorded is not None:
rekeyed = sorted(
(
self._rekey_folder_name(entry, previous, old_rel_prefix, new_rel_prefix)
for entry in recorded
),
key=lambda entry: entry.lower(),
)
if rekeyed != list(recorded):
cache.all_folders = rekeyed
changed = True
excluded = getattr(self, "_excluded_models", None)
if excluded:
rekeyed_excluded = [
self._rekey_path(entry, old_abs_prefix, new_abs_prefix)
for entry in excluded
]
if rekeyed_excluded != list(excluded):
self._excluded_models = rekeyed_excluded
changed = True
touched: List[Dict[str, Any]] = []
for item in cache.raw_data or []:
folder_value = item.get("folder", "") or self._calculate_folder(
item.get("file_path", "")
)
if not self._folder_within(folder_value, previous):
continue
old_file_path = item.get("file_path", "")
if old_file_path:
cache.remove_from_version_index(item)
item["file_path"] = self._rekey_path(
old_file_path, old_abs_prefix, new_abs_prefix
)
hash_value = (item.get("sha256") or "").lower()
if hash_value:
self._hash_index.remove_by_path(old_file_path, hash_value)
self._hash_index.add_entry(
hash_value, item["file_path"], item.get("autov3") or None
)
item["folder"] = self._rekey_folder_name(
folder_value, previous, old_rel_prefix, new_rel_prefix
)
if item.get("preview_url"):
item["preview_url"] = self._rekey_path(
item["preview_url"], old_abs_prefix, new_abs_prefix
)
touched.append(item)
if touched:
changed = True
await self._rewrite_sidecar_paths(touched)
folders = set(item.get("folder", "") for item in cache.raw_data)
cache.folders = sorted(folders, key=lambda x: x.lower())
cache.rebuild_version_index()
await cache.resort()
if changed:
await self._persist_current_cache()
self.bump_cache_version()
return changed
@staticmethod
def _rekey_folder_name(
entry: str, previous: str, old_rel_prefix: str, new_rel_prefix: str
) -> str:
"""Move a library-relative folder name (and its subtree) under a new name."""
if entry == previous:
return new_rel_prefix.rstrip("/")
if entry.startswith(old_rel_prefix):
return new_rel_prefix + entry[len(old_rel_prefix):]
return entry
async def _rewrite_sidecar_paths(self, entries: List[Dict[str, Any]]) -> None:
"""Point each model's metadata sidecar at its new location.
Sidecars travel with the renamed directory, so only the recorded
``file_path``/``preview_url`` inside them need rewriting. Failures are
logged and skipped a stale sidecar is repaired by the next metadata
refresh, and must not abort the rename.
"""
for item in entries:
file_path = item.get("file_path")
if not file_path:
continue
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
if not os.path.exists(metadata_path):
continue
try:
await self._update_metadata_paths(metadata_path, file_path)
except Exception as exc: # pragma: no cover - defensive
logger.warning(
"Failed to rewrite metadata sidecar %s: %s", metadata_path, exc
)
def _schedule_all_folders_backfill(self) -> None:
"""Kick off a one-shot background folder walk if none is running."""
if self._all_folders_backfill_running:
+28 -1
View File
@@ -46,8 +46,20 @@
pointer-events: none;
}
/* Destructive entries. The token used to be the nonexistent `--danger-color`,
which made the declaration invalid at computed-value time: the colour then
fell back to the menu's inherited text colour, so every "Delete …" entry in
the folder and model-card context menus rendered plain. */
.context-menu-item.delete-item {
color: var(--danger-color);
color: var(--lora-error);
}
/* The shared .context-menu-item:hover paints the accent background, which the
red label does not read against destructive entries get their own wash. */
.context-menu-item.delete-item:hover,
.context-menu-item.delete-item:focus-visible {
background-color: var(--lora-error-bg);
color: var(--lora-error);
}
.context-menu-item i {
@@ -55,6 +67,21 @@
text-align: center;
}
/* Muted counter shown next to a menu label (e.g. how many empty folders the
"Show empty folders" toggle would reveal) */
.context-menu-count {
color: var(--text-muted);
font-size: 12px;
}
/* The count keeps the label/tally muted even while the row is hovered, since
the accent background would otherwise wash the muted colour out. */
.context-menu-item:hover .context-menu-count,
.context-menu-item:focus-visible .context-menu-count {
color: var(--lora-text);
opacity: 0.8;
}
/* Section Headers */
.context-menu-section-header {
padding: 6px 12px 2px;
@@ -747,13 +747,13 @@
}
.priority-tags-input.settings-input-error {
border-color: var(--danger-color, #dc2626);
box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.12);
border-color: var(--lora-error);
box-shadow: 0 0 0 2px rgba(from var(--lora-error) r g b / 0.12);
}
.settings-input-error-message {
font-size: 0.8em;
color: var(--danger-color, #dc2626);
color: var(--lora-error);
display: none;
}
+20 -118
View File
@@ -639,90 +639,40 @@
display: inline;
}
/* Create folder drop zone */
.sidebar-create-folder-zone {
position: absolute;
bottom: 16px;
left: 16px;
right: 16px;
padding: 16px;
border: 2px dashed oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4);
border-radius: var(--border-radius-xs);
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08);
/* Create folder inline row: rendered inside the tree at the creation
location, styled like a regular node row with a full-width input */
.sidebar-create-folder-row {
padding-top: 4px;
padding-bottom: 4px;
cursor: default;
}
.sidebar-tree-node-content.sidebar-create-folder-row:hover,
.sidebar-node-content.sidebar-create-folder-row:hover {
background: transparent;
color: var(--text-color);
}
.sidebar-create-folder-spacer {
opacity: 0;
transform: translateY(10px);
transition: var(--transition-base);
pointer-events: none;
z-index: 10;
}
.sidebar-create-folder-zone.active {
opacity: 1;
transform: translateY(0);
}
.sidebar-create-folder-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
.sidebar-create-folder-row .sidebar-tree-folder-icon,
.sidebar-create-folder-row .sidebar-folder-icon {
color: var(--lora-accent);
font-size: 0.85em;
text-align: center;
}
.sidebar-create-folder-content i {
font-size: 1.5em;
opacity: 0.8;
}
/* Create folder input container */
.sidebar-create-folder-input-container {
/* Sticky footer inside the scroll container: always visible at the
bottom of the viewport regardless of tree scroll position */
position: sticky;
bottom: 8px;
margin: 8px 16px 0;
padding: 12px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
box-shadow: var(--shadow-lg);
z-index: 20;
animation: slideUp 0.2s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.sidebar-create-folder-input-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-create-folder-input-wrapper > i {
color: var(--lora-accent);
font-size: 1em;
opacity: 0.9;
}
.sidebar-create-folder-input {
flex: 1;
min-width: 0; /* allow the input to shrink below its intrinsic width */
padding: 6px 10px;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
font-size: 0.85em;
font-size: 1em;
outline: none;
transition: var(--transition-base);
}
@@ -732,49 +682,6 @@
box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15);
}
.sidebar-create-folder-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: var(--transition-base);
background: transparent;
color: var(--text-muted);
}
.sidebar-create-folder-btn:hover,
.sidebar-create-folder-btn:focus-visible {
background: var(--lora-surface);
color: var(--text-color);
outline: none;
}
.sidebar-create-folder-confirm:hover,
.sidebar-create-folder-confirm:focus-visible {
background: oklch(from var(--success-color) l c h / 0.15);
color: var(--success-color);
outline: none;
}
.sidebar-create-folder-cancel:hover,
.sidebar-create-folder-cancel:focus-visible {
background: oklch(from var(--error-color) l c h / 0.15);
color: var(--error-color);
outline: none;
}
.sidebar-create-folder-hint {
margin-top: 6px;
font-size: 0.75em;
color: var(--text-muted);
text-align: center;
opacity: 0.8;
}
/* Dragging state for sidebar */
.folder-sidebar.dragging-active {
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.5);
@@ -786,11 +693,6 @@
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.02);
}
/* Tree container positioning for create folder elements */
.sidebar-tree-container {
position: relative;
}
/* Folder context menu - positioned relative to sidebar */
#sidebarFolderContextMenu {
z-index: var(--z-modal, 1002);
+2
View File
@@ -84,6 +84,8 @@ export function getApiEndpoints(modelType) {
moveModel: `/api/lm/${modelType}/move_model`,
moveBulk: `/api/lm/${modelType}/move_models_bulk`,
createFolder: `/api/lm/${modelType}/create-folder`,
deleteFolder: `/api/lm/${modelType}/delete-folder`,
renameFolder: `/api/lm/${modelType}/rename-folder`,
// CivitAI integration
fetchCivitai: `/api/lm/${modelType}/fetch-civitai`,
+65
View File
@@ -1330,6 +1330,71 @@ export class BaseModelApiClient {
return result;
}
/**
* Delete a model-free folder inside the library roots.
*
* Only model-free folders can be removed; the backend answers with a 409
* `not_empty`/`busy` conflict otherwise. Those codes are attached to the
* thrown Error (`code`, `manifest`) so callers can explain the refusal
* instead of showing a bare message.
*
* @param {string} folderPath Absolute business path of the folder
* @param {{dryRun?: boolean}} [options]
*/
async deleteFolder(folderPath, options = {}) {
const { dryRun = false } = options || {};
const response = await fetch(this.apiConfig.endpoints.deleteFolder, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ folder_path: folderPath, dry_run: dryRun })
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.success === false) {
const error = new Error(result.error || `Failed to delete folder`);
error.code = result.code || null;
error.manifest = result.manifest || null;
throw error;
}
return result;
}
/**
* Rename a folder inside the library roots.
*
* Works on folders that hold models too the backend re-keys the affected
* cache records instead of cascading. A name collision or a staged delete
* inside the subtree surfaces as a 409 conflict, attached to the thrown
* Error as `code`.
*
* @param {string} folderPath Absolute business path of the folder
* @param {string} newName New leaf name (a single path segment)
*/
async renameFolder(folderPath, newName) {
const response = await fetch(this.apiConfig.endpoints.renameFolder, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ folder_path: folderPath, new_name: newName })
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.success === false) {
const error = new Error(result.error || `Failed to rename 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
+13
View File
@@ -243,6 +243,18 @@ export class ModalManager {
});
}
// Add deleteFolderModal registration
const deleteFolderModal = document.getElementById('deleteFolderModal');
if (deleteFolderModal) {
this.registerModal('deleteFolderModal', {
element: deleteFolderModal,
onClose: () => {
this.getModal('deleteFolderModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
}
});
}
// Add helpModal registration
const helpModal = document.getElementById('helpModal');
if (helpModal) {
@@ -441,6 +453,7 @@ export class ModalManager {
id === "clearCacheModal" ||
id === "bulkDeleteModal" ||
id === "checkUpdatesConfirmModal" ||
id === "deleteFolderModal" ||
id === "resolveFilenameConflictsModal"
) {
modal.element.classList.add("show");
+15 -2
View File
@@ -205,12 +205,24 @@
</div>
<!-- Sidebar Folder Context Menu -->
<!-- Order: the content action (update check) first, then the folder operations
as one group, then the destructive action behind its own divider. The
dividers are collapsed by SidebarManager when a group is hidden on the
current page (recipes keep only the update check). -->
<div id="sidebarFolderContextMenu" class="context-menu">
<div class="context-menu-item" data-action="check-folder-updates">
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="create-subfolder">
<i class="fas fa-folder-plus"></i> <span>{{ t('sidebar.newSubfolder') }}</span>
</div>
<div class="context-menu-item" data-action="check-folder-updates">
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span>
<div class="context-menu-item" data-action="rename-folder">
<i class="fas fa-i-cursor"></i> <span>{{ t('sidebar.renameFolder') }}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item delete-item" data-action="delete-folder">
<i class="fas fa-trash"></i> <span>{{ t('sidebar.deleteFolder') }}</span>
</div>
</div>
@@ -231,6 +243,7 @@
</div>
<div class="context-menu-item" data-action="toggle-empty-folders">
<i class="fas fa-folder-open"></i> <span>{{ t('sidebar.showEmptyFolders') }}</span>
<span id="sidebarEmptyFoldersCount" class="context-menu-count"></span>
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
</div>
</div>
@@ -82,6 +82,21 @@
</div>
</div>
<!-- Sidebar Folder Delete Confirmation Modal -->
<!-- Shared by two states: 'confirm' (model-free folder) and 'blocked' (the
subtree still holds models, so a cascade delete is refused). -->
<div id="deleteFolderModal" class="modal delete-modal">
<div class="modal-content delete-modal-content">
<h2 data-role="title">{{ t('sidebar.deleteFolderModal.title') }}</h2>
<p class="delete-message" data-role="message">{{ t('sidebar.deleteFolderModal.message') }}</p>
<div class="delete-model-info" data-role="info"></div>
<div class="modal-actions">
<button class="cancel-btn" data-action="cancel-delete-folder">{{ t('common.actions.cancel') }}</button>
<button class="delete-btn" data-action="confirm-delete-folder">{{ t('sidebar.deleteFolderModal.confirm') }}</button>
</div>
</div>
</div>
<!-- Bulk Download Missing LoRAs Confirmation Modal -->
<div id="bulkDownloadMissingLorasModal" class="modal">
<div class="modal-content">
@@ -9,6 +9,7 @@ const {
UI_HELPERS_MODULE,
UPDATE_CHECK_MODULE,
STATE_MODULE,
MODAL_MANAGER_MODULE,
} = vi.hoisted(() => ({
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
@@ -18,17 +19,23 @@ const {
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
UPDATE_CHECK_MODULE: new URL('../../../static/js/utils/updateCheckHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
}));
vi.mock(MODEL_API_FACTORY_MODULE, () => ({ getModelApiClient: vi.fn() }));
vi.mock(I18N_MODULE, () => ({ translate: (key, _args, fallback) => fallback || key }));
vi.mock(BULK_MANAGER_MODULE, () => ({ bulkManager: {} }));
vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn() }));
vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn(), showActionToast: vi.fn() }));
vi.mock(UPDATE_CHECK_MODULE, () => ({ performFolderUpdateCheck: vi.fn() }));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
}));
const { SidebarManager } = await import(SIDEBAR_MANAGER_MODULE);
const { state } = await import(STATE_MODULE);
const { setStorageItem, getStorageItem } = await import(STORAGE_HELPERS_MODULE);
const { showToast, showActionToast } = await import(UI_HELPERS_MODULE);
const { modalManager } = await import(MODAL_MANAGER_MODULE);
function createApiClient(overrides = {}) {
return {
@@ -44,6 +51,20 @@ function createApiClient(overrides = {}) {
fetchModelFolders: vi.fn().mockResolvedValue({ folders: ['', 'full'] }),
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/models/loras'] }),
createFolder: vi.fn().mockResolvedValue({ success: true, folder: 'new-folder', created: true }),
deleteFolder: vi.fn().mockResolvedValue({
success: true,
folder: 'empty',
model_count: 0,
file_count: 0,
dir_count: 0,
restorable: true,
}),
renameFolder: vi.fn().mockResolvedValue({
success: true,
renamed: true,
folder: 'renamed',
previous_folder: 'empty',
}),
...overrides,
};
}
@@ -65,28 +86,33 @@ describe('SidebarManager empty folders toggle', () => {
document.body.innerHTML = '';
});
it('requests the models-only tree by default', async () => {
it('loads the full and models-only folder lists and counts the empty folders', async () => {
const apiClient = createApiClient();
apiClient.fetchUnifiedFolderTree.mockResolvedValue({ tree: { full: {}, empty: {} } });
apiClient.fetchModelFolders.mockResolvedValue({ folders: ['', 'full'] });
const manager = createManager(apiClient);
await manager.loadFolderTree();
expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith();
expect(apiClient.fetchModelFolders).not.toHaveBeenCalled();
expect(manager.nonEmptyFolders).toBeNull();
expect(manager.treeData).toEqual({ full: {}, empty: {} });
});
it('includes empty folders and tracks the models-only set when enabled', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.showEmptyFolders = true;
await manager.loadFolderTree();
expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith({ includeEmpty: true });
expect(apiClient.fetchModelFolders).toHaveBeenCalledWith();
expect(manager.nonEmptyFolders).toEqual(new Set(['', 'full']));
expect(manager.treeData).toEqual({ full: {}, empty: {} });
expect(manager.emptyFolderCount).toBe(1);
});
it('keeps the folder data loaded while empty folders are hidden', async () => {
const apiClient = createApiClient();
apiClient.fetchUnifiedFolderTree.mockResolvedValue({ tree: { full: {}, empty: {} } });
const manager = createManager(apiClient);
manager.showEmptyFolders = false;
await manager.loadFolderTree();
// The data is still fetched so the menu can report the count; only the
// rendering is gated by the preference.
expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith({ includeEmpty: true });
expect(manager.emptyFolderCount).toBe(1);
});
it('passes includeEmpty to the folder list in list display mode', async () => {
@@ -95,41 +121,46 @@ describe('SidebarManager empty folders toggle', () => {
.mockResolvedValueOnce({ folders: ['', 'full', 'empty'] })
.mockResolvedValueOnce({ folders: ['', 'full'] });
const manager = createManager(apiClient, { displayMode: 'list' });
manager.showEmptyFolders = true;
await manager.loadFolderTree();
expect(apiClient.fetchModelFolders).toHaveBeenNthCalledWith(1, { includeEmpty: true });
expect(apiClient.fetchModelFolders).toHaveBeenNthCalledWith(2);
expect(manager.foldersList).toEqual(['', 'full', 'empty']);
expect(manager.nonEmptyFolders).toEqual(new Set(['', 'full']));
expect(manager.emptyFolderCount).toBe(1);
});
it('ignores the preference when the page does not support folder management', async () => {
it('does not request empty folders when the page does not support folder management', async () => {
const apiClient = createApiClient();
apiClient.apiConfig.config.supportsFolderManagement = false;
const manager = createManager(apiClient);
manager.showEmptyFolders = true;
await manager.loadFolderTree();
expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith();
expect(apiClient.fetchModelFolders).not.toHaveBeenCalled();
expect(manager.nonEmptyFolders).toBeNull();
expect(manager.emptyFolderCount).toBeNull();
});
it('persists the toggle and reloads the tree', async () => {
it('persists the toggle and re-renders without refetching', () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.showEmptyFolders = false;
manager.loadFolderTree = vi.fn();
manager.handleEmptyFoldersToggle({ stopPropagation: vi.fn() });
expect(manager.showEmptyFolders).toBe(true);
expect(getStorageItem('loras_showEmptyFolders')).toBe(true);
expect(manager.loadFolderTree).toHaveBeenCalledTimes(1);
expect(manager.loadFolderTree).not.toHaveBeenCalled();
expect(manager.renderFolderDisplay).toHaveBeenCalledTimes(1);
});
it('dims folders that contain no models', () => {
const manager = createManager(createApiClient());
manager.showEmptyFolders = true;
manager.treeData = { full: {}, empty: {} };
manager.nonEmptyFolders = new Set(['', 'full']);
@@ -142,6 +173,7 @@ describe('SidebarManager empty folders toggle', () => {
it('does not dim folders whose subtree contains models', () => {
const manager = createManager(createApiClient());
manager.showEmptyFolders = true;
// Models live in "characters/anime" only; "characters" itself holds no
// direct models but must not be dimmed.
manager.nonEmptyFolders = manager._buildNonEmptyFolderSet(['characters/anime']);
@@ -156,6 +188,48 @@ describe('SidebarManager empty folders toggle', () => {
expect(animeNode[0]).not.toContain('empty');
expect(emptyNode[0]).toContain('empty');
});
it('does not dim folders while the preference is off', () => {
const manager = createManager(createApiClient());
manager.showEmptyFolders = false;
manager.treeData = { full: {}, empty: {} };
manager.nonEmptyFolders = new Set(['', 'full']);
const html = manager.renderTreeNode(manager.treeData, '');
expect(html).not.toContain('sidebar-tree-node-content empty');
});
describe('list view', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
});
it('hides empty folders from the flat list while the preference is off', () => {
const manager = createManager(createApiClient(), { displayMode: 'list' });
manager.showEmptyFolders = false;
manager.foldersList = ['', 'full', 'empty'];
manager.nonEmptyFolders = manager._buildNonEmptyFolderSet(['full']);
manager.renderFolderList();
const html = document.getElementById('sidebarFolderTree').innerHTML;
expect(html).toContain('data-path="full"');
expect(html).not.toContain('data-path="empty"');
});
it('shows empty folders dimmed in the flat list when the preference is on', () => {
const manager = createManager(createApiClient(), { displayMode: 'list' });
manager.showEmptyFolders = true;
manager.foldersList = ['', 'full', 'empty'];
manager.nonEmptyFolders = manager._buildNonEmptyFolderSet(['full']);
manager.renderFolderList();
const html = document.getElementById('sidebarFolderTree').innerHTML;
expect(html).toContain('sidebar-node-content empty" data-path="empty"');
});
});
});
describe('SidebarManager view options menu', () => {
@@ -164,13 +238,17 @@ describe('SidebarManager view options menu', () => {
<div class="context-menu-item" data-action="view-mode-tree"><i class="check-indicator" style="display:none"></i></div>
<div class="context-menu-item" data-action="view-mode-list"><i class="check-indicator" style="display:none"></i></div>
<div class="context-menu-item" data-action="toggle-recursive"><i class="check-indicator" style="display:none"></i></div>
<div class="context-menu-item" data-action="toggle-empty-folders"><i class="check-indicator" style="display:none"></i></div>
<div class="context-menu-item" data-action="toggle-empty-folders"><span id="sidebarEmptyFoldersCount"></span><i class="check-indicator" style="display:none"></i></div>
</div>`;
function getCheck(action) {
return document.querySelector(`#sidebarViewOptionsMenu [data-action="${action}"] .check-indicator`);
}
function getEmptyFoldersItem() {
return document.querySelector('[data-action="toggle-empty-folders"]');
}
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = MENU_HTML;
@@ -206,7 +284,35 @@ describe('SidebarManager view options menu', () => {
manager.updateViewOptionsMenu();
expect(document.querySelector('[data-action="toggle-empty-folders"]').style.display).toBe('none');
expect(getEmptyFoldersItem().style.display).toBe('none');
});
it('hides the empty-folders item when the library has no empty folders', () => {
const manager = createManager(createApiClient());
manager.emptyFolderCount = 0;
manager.updateViewOptionsMenu();
expect(getEmptyFoldersItem().style.display).toBe('none');
});
it('keeps the empty-folders item visible while the count is unknown', () => {
const manager = createManager(createApiClient());
manager.emptyFolderCount = null;
manager.updateViewOptionsMenu();
expect(getEmptyFoldersItem().style.display).not.toBe('none');
});
it('shows the empty-folder count next to the label', () => {
const manager = createManager(createApiClient());
manager.emptyFolderCount = 12;
manager.updateViewOptionsMenu();
expect(getEmptyFoldersItem().style.display).not.toBe('none');
expect(document.getElementById('sidebarEmptyFoldersCount').textContent).toBe('(12)');
});
it('switches display mode from the menu and closes it', () => {
@@ -224,7 +330,7 @@ describe('SidebarManager view options menu', () => {
it('toggles empty folders from the menu and keeps it open', () => {
const manager = createManager(createApiClient());
manager.loadFolderTree = vi.fn();
manager.showEmptyFolders = false;
const menu = document.getElementById('sidebarViewOptionsMenu');
menu.style.display = 'block';
@@ -233,6 +339,7 @@ describe('SidebarManager view options menu', () => {
expect(manager.showEmptyFolders).toBe(true);
expect(getCheck('toggle-empty-folders').style.display).toBe('block');
expect(menu.style.display).toBe('block');
expect(manager.renderFolderDisplay).toHaveBeenCalled();
});
it('collapses all folders from the header button', () => {
@@ -277,6 +384,29 @@ describe('SidebarManager view options menu', () => {
manager.handleViewOptionsButton({ stopPropagation: vi.fn(), currentTarget: button });
expect(menu.style.display).toBe('none');
});
it('shows empty folders on a fresh library (default preference)', () => {
const manager = createManager(createApiClient());
manager.updateSearchRecursiveOption = vi.fn();
manager.updateFolderManagementButtons = vi.fn();
manager.updateCollapseAllButton = vi.fn();
manager.restoreSidebarState();
expect(manager.showEmptyFolders).toBe(true);
});
it('honours a stored preference to hide empty folders', () => {
setStorageItem('loras_showEmptyFolders', false);
const manager = createManager(createApiClient());
manager.updateSearchRecursiveOption = vi.fn();
manager.updateFolderManagementButtons = vi.fn();
manager.updateCollapseAllButton = vi.fn();
manager.restoreSidebarState();
expect(manager.showEmptyFolders).toBe(false);
});
});
describe('SidebarManager folder creation', () => {
@@ -306,19 +436,34 @@ describe('SidebarManager folder creation', () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.selectedPath = 'characters';
manager.showEmptyFolders = false;
manager.refresh = vi.fn().mockResolvedValue(undefined);
const success = await manager._createFolder('characters/anime', 'characters');
expect(success).toBe(true);
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/characters/anime');
// Empty-folder display is enabled so the new folder shows up immediately
// The new folder is empty, so creating it turns empty-folder display back
// on to keep the folder visible in the tree.
expect(manager.showEmptyFolders).toBe(true);
expect(getStorageItem('loras_showEmptyFolders')).toBe(true);
expect(manager.expandedNodes.has('characters')).toBe(true);
expect(manager.refresh).toHaveBeenCalledTimes(1);
});
it('re-enables empty folders when creating while the preference is off', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.showEmptyFolders = false;
manager.refresh = vi.fn().mockResolvedValue(undefined);
const success = await manager._createFolder('new-folder', '');
expect(success).toBe(true);
expect(manager.showEmptyFolders).toBe(true);
expect(getStorageItem('loras_showEmptyFolders')).toBe(true);
});
it('fails gracefully when no model root is configured', async () => {
const apiClient = createApiClient();
apiClient.fetchModelRoots.mockResolvedValue({ roots: [] });
@@ -332,22 +477,124 @@ describe('SidebarManager folder creation', () => {
expect(manager.refresh).not.toHaveBeenCalled();
});
it('opens the create-folder input for a context-menu folder', () => {
it('opens the create-folder input as an inline row under the context-menu folder', () => {
const manager = createManager(createApiClient());
document.body.innerHTML = '<div class="sidebar-tree-container"></div>';
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
manager.treeData = { characters: {} };
manager.renderTree();
manager._performFolderAction('create-subfolder', 'characters');
const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input');
expect(input).not.toBeNull();
const row = document.getElementById('sidebarCreateFolderInput');
expect(row).not.toBeNull();
expect(row.classList.contains('sidebar-create-folder-node')).toBe(true);
expect(manager._createFolderBasePath).toBe('characters');
// The leaf parent is expanded and the row sits inside its children container
expect(manager.expandedNodes.has('characters')).toBe(true);
const parentNode = document.querySelector('.sidebar-tree-node[data-path="characters"]');
expect(parentNode.querySelector(':scope > .sidebar-tree-children').contains(row)).toBe(true);
});
it('inserts the row as the first child of an already-expanded parent', () => {
const manager = createManager(createApiClient());
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
manager.treeData = { characters: { anime: {} } };
manager.expandedNodes = new Set(['characters']);
manager.renderTree();
manager.showCreateFolderInput('characters');
const children = document.querySelector('.sidebar-tree-node[data-path="characters"] > .sidebar-tree-children');
expect(children.firstElementChild.id).toBe('sidebarCreateFolderInput');
// Existing children container is reused, no temporary one is tracked
expect(manager._createFolderTempChildren).toBeNull();
});
it('appends the row at the top level for root creation', () => {
const manager = createManager(createApiClient());
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
manager.treeData = { characters: {} };
manager.renderTree();
manager.showCreateFolderInput('');
const folderTree = document.getElementById('sidebarFolderTree');
const row = document.getElementById('sidebarCreateFolderInput');
expect(row.parentElement).toBe(folderTree);
expect(folderTree.lastElementChild).toBe(row);
});
it('inserts the row after the parent item in list mode', () => {
const manager = createManager(createApiClient(), { displayMode: 'list' });
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
manager.foldersList = ['characters', 'characters/anime'];
manager.renderFolderList();
manager.showCreateFolderInput('characters');
const items = [...document.querySelectorAll('#sidebarFolderTree > div')];
const parentIndex = items.findIndex(el => el.dataset.path === 'characters');
expect(items[parentIndex + 1].id).toBe('sidebarCreateFolderInput');
// List-mode rows use the list content styling, not the tree one
expect(items[parentIndex + 1].querySelector('.sidebar-node-content')).not.toBeNull();
});
it('removes the temporary children container when creation is canceled', () => {
const manager = createManager(createApiClient());
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
manager.treeData = { characters: {} };
manager.renderTree();
manager.showCreateFolderInput('characters');
manager.handleCreateFolderCancel();
expect(document.getElementById('sidebarCreateFolderInput')).toBeNull();
expect(manager.isCreatingFolder).toBe(false);
const parentNode = document.querySelector('.sidebar-tree-node[data-path="characters"]');
expect(parentNode.querySelector(':scope > .sidebar-tree-children')).toBeNull();
});
it('cancels creation when the input loses focus', () => {
vi.useFakeTimers();
try {
const manager = createManager(createApiClient());
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
manager.showCreateFolderInput('');
const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input');
input.dispatchEvent(new Event('blur'));
vi.advanceTimersByTime(150);
expect(document.getElementById('sidebarCreateFolderInput')).toBeNull();
expect(manager.isCreatingFolder).toBe(false);
} finally {
vi.useRealTimers();
}
});
it('ignores tree clicks and context menus on the create row', () => {
const manager = createManager(createApiClient());
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
manager.treeData = { characters: {} };
manager.renderTree();
manager.selectFolder = vi.fn();
const showMenu = vi.spyOn(manager, '_showFolderContextMenu').mockImplementation(() => {});
manager.showCreateFolderInput('characters');
const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input');
manager.handleTreeClick({ target: input });
expect(manager.selectFolder).not.toHaveBeenCalled();
manager.handleTreeContextMenu({ target: input, preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(showMenu).not.toHaveBeenCalled();
});
it('submits a standalone folder creation when no drag is pending', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
document.body.innerHTML = '<div class="sidebar-tree-container"></div>';
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
manager.showCreateFolderInput('characters');
document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input').value = 'anime';
@@ -358,3 +605,440 @@ describe('SidebarManager folder creation', () => {
expect(document.getElementById('sidebarCreateFolderInput')).toBeNull();
});
});
describe('SidebarManager folder deletion', () => {
const MODAL_HTML = `
<div id="deleteFolderModal" class="modal delete-modal">
<div class="modal-content delete-modal-content">
<h2 data-role="title"></h2>
<p class="delete-message" data-role="message"></p>
<div class="delete-model-info" data-role="info"></div>
<div class="modal-actions">
<button class="cancel-btn" data-action="cancel-delete-folder">Cancel</button>
<button class="delete-btn" data-action="confirm-delete-folder">Delete folder</button>
</div>
</div>
</div>`;
function confirmBtn() {
return document.querySelector('#deleteFolderModal [data-action="confirm-delete-folder"]');
}
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = MODAL_HTML;
state.global.settings = {};
vi.clearAllMocks();
});
it('opens the confirm state for a folder whose subtree holds no models', () => {
const manager = createManager(createApiClient());
manager.nonEmptyFolders = new Set(['', 'full']);
manager.showDeleteFolderModal('empty');
const modal = document.getElementById('deleteFolderModal');
expect(modal.dataset.state).toBe('confirm');
expect(confirmBtn().style.display).toBe('');
expect(manager._pendingDeleteFolderPath).toBe('empty');
expect(modalManager.showModal).toHaveBeenCalledWith('deleteFolderModal');
});
it('explains the refusal when the subtree still holds models', () => {
const manager = createManager(createApiClient());
manager.nonEmptyFolders = new Set(['', 'full']);
manager.showDeleteFolderModal('full');
const modal = document.getElementById('deleteFolderModal');
expect(modal.dataset.state).toBe('blocked');
expect(confirmBtn().style.display).toBe('none');
expect(manager._pendingDeleteFolderPath).toBeNull();
});
it('treats an unknown folder as model-free when the models-only set is missing', () => {
// nonEmptyFolders is null outside the include-empty tree; the server still
// refuses a non-empty folder, so the client falls back to the confirm state.
const manager = createManager(createApiClient());
manager.nonEmptyFolders = null;
manager.showDeleteFolderModal('empty');
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('confirm');
});
it('deletes the folder and offers the undo affordance for an empty one', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
const success = await manager._deleteFolder('empty');
expect(success).toBe(true);
expect(apiClient.deleteFolder).toHaveBeenCalledWith('/models/loras/empty');
expect(manager.refresh).toHaveBeenCalledTimes(1);
expect(showActionToast).toHaveBeenCalledTimes(1);
expect(showToast).not.toHaveBeenCalled();
});
it('restores a deleted empty folder through the create-folder API', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
await manager._deleteFolder('empty');
const undo = showActionToast.mock.calls[0][3].onAction;
await undo();
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/empty');
expect(showToast).toHaveBeenCalledWith('sidebar.deleteFolderResult.restored', {}, 'success');
});
it('skips the undo affordance when non-model leftovers were removed', async () => {
const apiClient = createApiClient({
deleteFolder: vi.fn().mockResolvedValue({
success: true,
folder: 'empty',
file_count: 2,
dir_count: 1,
restorable: false,
}),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
await manager._deleteFolder('empty');
expect(showActionToast).not.toHaveBeenCalled();
expect(showToast).toHaveBeenCalledWith(
'sidebar.deleteFolderResult.successWithFiles',
{ name: 'empty', count: 3 },
'success'
);
});
it('surfaces the not_empty conflict when the tree was stale', async () => {
const conflict = Object.assign(new Error('still contains models'), { code: 'not_empty' });
const apiClient = createApiClient({
deleteFolder: vi.fn().mockRejectedValue(conflict),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
const success = await manager._deleteFolder('full');
expect(success).toBe(false);
expect(showToast).toHaveBeenCalledWith('sidebar.deleteFolderResult.notEmpty', {}, 'warning');
expect(manager.refresh).not.toHaveBeenCalled();
});
it('surfaces a busy folder with a staged delete', async () => {
const busy = Object.assign(new Error('staged delete pending'), { code: 'busy' });
const apiClient = createApiClient({
deleteFolder: vi.fn().mockRejectedValue(busy),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
await manager._deleteFolder('full');
expect(showToast).toHaveBeenCalledWith('sidebar.deleteFolderResult.busy', {}, 'warning');
});
it('drops the removed subtree from the persisted expand state', () => {
const manager = createManager(createApiClient());
manager.expandedNodes = new Set(['empty', 'empty/deep', 'other']);
manager.saveExpandedState = vi.fn();
manager._forgetRemovedFolder('empty');
expect([...manager.expandedNodes]).toEqual(['other']);
expect(manager.saveExpandedState).toHaveBeenCalledTimes(1);
});
it('leaves the expand state untouched when nothing matched', () => {
const manager = createManager(createApiClient());
manager.expandedNodes = new Set(['other']);
manager.saveExpandedState = vi.fn();
manager._forgetRemovedFolder('empty');
expect([...manager.expandedNodes]).toEqual(['other']);
expect(manager.saveExpandedState).not.toHaveBeenCalled();
});
it('routes the modal buttons to cancel and confirm', () => {
const manager = createManager(createApiClient());
manager._deleteFolder = vi.fn().mockResolvedValue(true);
manager._pendingDeleteFolderPath = 'empty';
manager._wireDeleteFolderModal();
confirmBtn().dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(modalManager.closeModal).toHaveBeenCalledWith('deleteFolderModal');
expect(manager._deleteFolder).toHaveBeenCalledWith('empty');
});
it('routes the context-menu action to the delete modal', () => {
const manager = createManager(createApiClient());
manager.showDeleteFolderModal = vi.fn();
manager._performFolderAction('delete-folder', 'empty');
expect(manager.showDeleteFolderModal).toHaveBeenCalledWith('empty');
});
it('hides the delete entry when folder management is unsupported', () => {
document.body.insertAdjacentHTML('beforeend', `
<div id="sidebarFolderContextMenu" class="context-menu">
<div class="context-menu-item" data-action="create-subfolder"></div>
<div class="context-menu-item delete-item" data-action="delete-folder"></div>
</div>`);
const apiClient = createApiClient();
apiClient.apiConfig.config.supportsFolderManagement = false;
const manager = createManager(apiClient);
manager._showFolderContextMenu(10, 10, 'empty');
const item = document.querySelector('#sidebarFolderContextMenu [data-action="delete-folder"]');
expect(item.style.display).toBe('none');
manager._closeFolderContextMenu();
});
});
describe('SidebarManager folder rename', () => {
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
state.global.settings = {};
vi.clearAllMocks();
});
function renameInput() {
return document.querySelector('#sidebarRenameFolderInput .sidebar-rename-folder-input');
}
it('turns the node into a prefilled inline row in tree mode', () => {
const manager = createManager(createApiClient());
manager.treeData = { characters: { anime: {} } };
manager.renderTree();
manager.showRenameFolderInput('characters/anime');
const row = document.getElementById('sidebarRenameFolderInput');
expect(row).not.toBeNull();
expect(renameInput().value).toBe('anime');
// The node is hidden in place, not removed: the row sits right before it
const node = document.querySelector('.sidebar-tree-node[data-path="characters/anime"]');
expect(node.style.display).toBe('none');
expect(row.nextElementSibling).toBe(node);
expect(manager._renameFolderPath).toBe('characters/anime');
});
it('inserts the row in place in list mode', () => {
const manager = createManager(createApiClient(), { displayMode: 'list' });
manager.foldersList = ['characters', 'characters/anime'];
manager.renderFolderList();
manager.showRenameFolderInput('characters/anime');
const row = document.getElementById('sidebarRenameFolderInput');
expect(row.querySelector('.sidebar-node-content')).not.toBeNull();
const item = document.querySelector('.sidebar-folder-item[data-path="characters/anime"]');
expect(row.nextElementSibling).toBe(item);
});
it('restores the node when the edit is canceled', () => {
const manager = createManager(createApiClient());
manager.treeData = { characters: { anime: {} } };
manager.renderTree();
manager.showRenameFolderInput('characters/anime');
manager.handleRenameFolderCancel();
expect(document.getElementById('sidebarRenameFolderInput')).toBeNull();
expect(manager._renameFolderPath).toBeNull();
const node = document.querySelector('.sidebar-tree-node[data-path="characters/anime"]');
expect(node.style.display).toBe('');
});
it('renames through the API and re-keys the persisted selection', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
manager.selectedPath = 'characters/anime';
manager.expandedNodes = new Set(['characters', 'characters/anime']);
manager.pageControls = { pageState: { activeFolder: 'characters/anime' } };
const success = await manager._renameFolder('characters/anime', 'animation');
expect(success).toBe(true);
expect(apiClient.renameFolder).toHaveBeenCalledWith('/models/loras/characters/anime', 'animation');
expect(manager.selectedPath).toBe('renamed');
expect(manager.pageControls.pageState.activeFolder).toBe('renamed');
expect(getStorageItem('loras_activeFolder')).toBe('renamed');
expect(manager.refresh).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith(
'sidebar.renameFolderResult.success', { name: 'animation' }, 'success'
);
});
it('re-keys the expanded subtree and the selection', () => {
const manager = createManager(createApiClient());
manager.expandedNodes = new Set(['a', 'a/b', 'a/b/c', 'x']);
manager.selectedPath = 'a/b/c';
manager.saveExpandedState = vi.fn();
manager._rekeyFolderPath('a/b', 'a/z');
expect([...manager.expandedNodes]).toEqual(['a', 'a/z', 'a/z/c', 'x']);
expect(manager.selectedPath).toBe('a/z/c');
expect(manager.saveExpandedState).toHaveBeenCalledTimes(1);
});
it('submits the inline edit and skips the API for an unchanged name', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
manager.treeData = { characters: { anime: {} } };
manager.renderTree();
manager.showRenameFolderInput('characters/anime');
renameInput().value = 'anime';
await manager.handleRenameFolderSubmit();
expect(apiClient.renameFolder).not.toHaveBeenCalled();
expect(document.getElementById('sidebarRenameFolderInput')).toBeNull();
});
it('rejects invalid names before calling the API', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.treeData = { characters: { anime: {} } };
manager.renderTree();
manager.showRenameFolderInput('characters/anime');
renameInput().value = 'bad/name';
await manager.handleRenameFolderSubmit();
expect(apiClient.renameFolder).not.toHaveBeenCalled();
expect(showToast).toHaveBeenCalledWith('sidebar.dragDrop.invalidFolderName', {}, 'error');
// The row stays open so the name can be corrected
expect(document.getElementById('sidebarRenameFolderInput')).not.toBeNull();
});
it('surfaces a name collision', async () => {
const conflict = Object.assign(new Error('already exists'), { code: 'target_exists' });
const apiClient = createApiClient({
renameFolder: vi.fn().mockRejectedValue(conflict),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
const success = await manager._renameFolder('characters/anime', 'animation');
expect(success).toBe(false);
expect(showToast).toHaveBeenCalledWith('sidebar.renameFolderResult.targetExists', {}, 'warning');
expect(manager.refresh).not.toHaveBeenCalled();
});
it('routes the context-menu action to the inline rename row', () => {
const manager = createManager(createApiClient());
manager.showRenameFolderInput = vi.fn();
manager._performFolderAction('rename-folder', 'characters/anime');
expect(manager.showRenameFolderInput).toHaveBeenCalledWith('characters/anime');
});
it('hides the rename entry when folder management is unsupported', () => {
document.body.insertAdjacentHTML('beforeend', `
<div id="sidebarFolderContextMenu" class="context-menu">
<div class="context-menu-item" data-action="rename-folder"></div>
</div>`);
const apiClient = createApiClient();
apiClient.apiConfig.config.supportsFolderManagement = false;
const manager = createManager(apiClient);
manager._showFolderContextMenu(10, 10, 'empty');
const item = document.querySelector('#sidebarFolderContextMenu [data-action="rename-folder"]');
expect(item.style.display).toBe('none');
manager._closeFolderContextMenu();
});
});
describe('SidebarManager folder context menu layout', () => {
// Mirrors templates/components/context_menu.html: the update check on top,
// the folder operations as one group, delete last behind its own divider.
const MENU_HTML = `
<div id="sidebarFolderContextMenu" class="context-menu">
<div class="context-menu-item" data-action="check-folder-updates"></div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="create-subfolder"></div>
<div class="context-menu-item" data-action="rename-folder"></div>
<div class="context-menu-separator"></div>
<div class="context-menu-item delete-item" data-action="delete-folder"></div>
</div>`;
const separators = () => [...document.querySelectorAll('#sidebarFolderContextMenu .context-menu-separator')];
const item = (action) => document.querySelector(`#sidebarFolderContextMenu [data-action="${action}"]`);
const visible = (el) => el.style.display !== 'none';
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = MENU_HTML;
state.global.settings = {};
vi.clearAllMocks();
});
it('keeps both dividers on a library page', () => {
const manager = createManager(createApiClient());
manager._showFolderContextMenu(10, 10, 'empty');
expect(separators().map(visible)).toEqual([true, true]);
expect(visible(item('create-subfolder'))).toBe(true);
expect(visible(item('rename-folder'))).toBe(true);
expect(visible(item('delete-folder'))).toBe(true);
manager._closeFolderContextMenu();
});
it('collapses both dividers when the page has no folder management', () => {
const apiClient = createApiClient();
apiClient.apiConfig.config.supportsFolderManagement = false;
const manager = createManager(apiClient);
manager._showFolderContextMenu(10, 10, 'empty');
expect(visible(item('check-folder-updates'))).toBe(true);
expect(visible(item('create-subfolder'))).toBe(false);
expect(visible(item('rename-folder'))).toBe(false);
expect(visible(item('delete-folder'))).toBe(false);
// Nothing left to divide: the update check stands alone
expect(separators().map(visible)).toEqual([false, false]);
manager._closeFolderContextMenu();
});
it('drops leading, trailing and doubled separators', () => {
document.body.innerHTML = `
<div id="sidebarFolderContextMenu" class="context-menu">
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="a"></div>
<div class="context-menu-separator"></div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="b"></div>
<div class="context-menu-separator"></div>
</div>`;
const manager = createManager(createApiClient());
manager._updateContextMenuSeparators(document.getElementById('sidebarFolderContextMenu'));
expect(separators().map(visible)).toEqual([false, true, false, false]);
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync } from 'fs';
import path from 'path';
// Regression guard for the destructive context-menu entries.
//
// They were styled with `var(--danger-color)`, a token defined nowhere in the
// stylesheet tree. A var() reference to an undefined property makes the
// declaration invalid at computed-value time, so the colour silently fell back
// to the menu's inherited text colour: every "Delete …" entry in the folder
// sidebar menu and the model-card menus rendered plain. The first check below
// keeps menu.css wired only to tokens that actually resolve.
describe('Context menu design tokens', () => {
const repoRoot = path.resolve(__dirname, '../../..');
const cssDir = path.join(repoRoot, 'static/css');
const menuCss = readFileSync(path.join(cssDir, 'components/menu.css'), 'utf-8');
const collectCss = (dir, files = []) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) collectCss(full, files);
else if (entry.name.endsWith('.css')) files.push(readFileSync(full, 'utf-8'));
}
return files;
};
const allCss = collectCss(cssDir).join('\n');
const defined = new Set([...allCss.matchAll(/(--[\w-]+)\s*:/g)].map((match) => match[1]));
// var(--x) with no fallback: an undefined name is a silent no-op.
const usedWithoutFallback = [...menuCss.matchAll(/var\(\s*(--[\w-]+)\s*\)/g)].map(
(match) => match[1]
);
it('resolves every custom property used by the context menu', () => {
const unresolved = [...new Set(usedWithoutFallback)].filter((name) => !defined.has(name));
expect(unresolved).toEqual([]);
});
it('paints destructive entries with the themed error colour', () => {
const rule = menuCss.match(/\.context-menu-item\.delete-item\s*\{([^}]*)\}/);
expect(rule).not.toBeNull();
expect(rule[1]).toContain('var(--lora-error)');
expect(defined.has('--lora-error')).toBe(true);
});
it('gives destructive entries their own hover treatment', () => {
// The shared hover paints the accent background, which a red label does
// not read against.
expect(menuCss).toMatch(/\.context-menu-item\.delete-item:hover[\s\S]*?\{/);
expect(menuCss).toMatch(/\.context-menu-item\.delete-item:hover[\s\S]*?var\(--lora-error-bg\)/);
});
it('never references the dead --danger-color token', () => {
// Comments may name it; a var() argument may not.
expect(allCss).not.toMatch(/var\(\s*--danger-color/);
});
});
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import path from 'path';
// Regression guard for the sidebar folder context-menu layout: the update check
// sits on top, the folder operations form a single group, and the destructive
// entry stays last behind its own divider. SidebarManager gates those groups
// per page and collapses the dividers when a group is hidden, so a reorder here
// also changes what the recipes page shows.
describe('Sidebar folder context menu layout', () => {
const repoRoot = path.resolve(__dirname, '../../..');
const html = readFileSync(
path.join(repoRoot, 'templates/components/context_menu.html'),
'utf-8'
);
const menuHtml = html.slice(
html.indexOf('id="sidebarFolderContextMenu"'),
html.indexOf('<!-- Sidebar View Options Menu -->')
);
const sequence = [...menuHtml.matchAll(/<div class="([^"]+)"([^>]*)>/g)].map(([, classes, rest]) => {
if (classes.includes('context-menu-separator')) return 'separator';
return /data-action="([^"]+)"/.exec(rest)?.[1] || null;
});
it('keeps the update check first and the folder operations grouped', () => {
expect(sequence).toEqual([
'check-folder-updates',
'separator',
'create-subfolder',
'rename-folder',
'separator',
'delete-folder',
]);
});
it('keeps the destructive entry last and visually marked', () => {
const deleteEntry = menuHtml.match(/<div class="([^"]*)"\s+data-action="delete-folder"/);
expect(deleteEntry).not.toBeNull();
expect(deleteEntry[1]).toContain('delete-item');
expect(sequence[sequence.length - 1]).toBe('delete-folder');
});
});
+240
View File
@@ -10,11 +10,23 @@ class FakeMoveService:
def __init__(self, result):
self._result = result
self.received_path = None
self.received_dry_run = None
self.received_new_name = None
async def create_folder(self, folder_path):
self.received_path = folder_path
return self._result
async def delete_folder(self, folder_path, dry_run=False):
self.received_path = folder_path
self.received_dry_run = dry_run
return self._result
async def rename_folder(self, folder_path, new_name):
self.received_path = folder_path
self.received_new_name = new_name
return self._result
class FakeRequest:
def __init__(self, payload):
@@ -91,3 +103,231 @@ async def test_create_folder_invalid_json_body():
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
@pytest.mark.asyncio
async def test_delete_folder_success():
handler, service = _make_handler(
{
"success": True,
"folder": "characters/anime",
"model_count": 0,
"restorable": True,
}
)
response = await handler.delete_folder(
FakeRequest({"folder_path": "/library/characters/anime"})
)
assert response.status == 200
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["restorable"] is True
assert service.received_path == "/library/characters/anime"
assert service.received_dry_run is False
@pytest.mark.asyncio
async def test_delete_folder_forwards_dry_run():
handler, service = _make_handler({"success": True, "dry_run": True})
response = await handler.delete_folder(
FakeRequest({"folder_path": "/library/empty", "dry_run": True})
)
assert response.status == 200
assert service.received_dry_run is True
assert json.loads(response.text)["dry_run"] is True
@pytest.mark.asyncio
async def test_delete_folder_missing_path():
handler, service = _make_handler({"success": True})
response = await handler.delete_folder(FakeRequest({}))
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
assert service.received_path is None
@pytest.mark.asyncio
async def test_delete_folder_not_empty_maps_to_409():
handler, _service = _make_handler(
{
"success": False,
"code": "not_empty",
"error": "Folder still contains 2 model file(s); delete or move them first",
"manifest": {"model_count": 2},
}
)
response = await handler.delete_folder(
FakeRequest({"folder_path": "/library/full"})
)
assert response.status == 409
payload = json.loads(response.text)
assert payload["success"] is False
assert payload["code"] == "not_empty"
assert payload["manifest"]["model_count"] == 2
@pytest.mark.asyncio
async def test_delete_folder_busy_maps_to_409():
handler, _service = _make_handler(
{"success": False, "code": "busy", "error": "staged delete pending"}
)
response = await handler.delete_folder(
FakeRequest({"folder_path": "/library/full"})
)
assert response.status == 409
assert json.loads(response.text)["code"] == "busy"
@pytest.mark.asyncio
async def test_delete_folder_containment_failure_maps_to_400():
handler, _service = _make_handler(
{
"success": False,
"error": "Folder path '/etc/evil' is outside configured library directories",
}
)
response = await handler.delete_folder(FakeRequest({"folder_path": "/etc/evil"}))
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
assert "outside configured library" in payload["error"]
@pytest.mark.asyncio
async def test_delete_folder_invalid_json_body():
class BadJsonRequest:
async def json(self):
raise ValueError("bad json")
handler, _service = _make_handler({"success": True})
response = await handler.delete_folder(BadJsonRequest())
assert response.status == 400
assert json.loads(response.text)["success"] is False
@pytest.mark.asyncio
async def test_rename_folder_success():
handler, service = _make_handler(
{
"success": True,
"renamed": True,
"folder": "characters/animation",
"previous_folder": "characters/anime",
}
)
response = await handler.rename_folder(
FakeRequest(
{"folder_path": "/library/characters/anime", "new_name": "animation"}
)
)
assert response.status == 200
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["folder"] == "characters/animation"
assert service.received_path == "/library/characters/anime"
assert service.received_new_name == "animation"
@pytest.mark.asyncio
async def test_rename_folder_missing_path():
handler, service = _make_handler({"success": True})
response = await handler.rename_folder(FakeRequest({"new_name": "animation"}))
assert response.status == 400
assert json.loads(response.text)["success"] is False
assert service.received_path is None
@pytest.mark.asyncio
async def test_rename_folder_missing_name():
handler, service = _make_handler({"success": True})
response = await handler.rename_folder(
FakeRequest({"folder_path": "/library/characters/anime"})
)
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
assert service.received_new_name is None
@pytest.mark.asyncio
async def test_rename_folder_target_exists_maps_to_409():
handler, _service = _make_handler(
{
"success": False,
"code": "target_exists",
"error": 'A folder named "animation" already exists here',
}
)
response = await handler.rename_folder(
FakeRequest(
{"folder_path": "/library/characters/anime", "new_name": "animation"}
)
)
assert response.status == 409
payload = json.loads(response.text)
assert payload["code"] == "target_exists"
@pytest.mark.asyncio
async def test_rename_folder_busy_maps_to_409():
handler, _service = _make_handler(
{"success": False, "code": "busy", "error": "staged delete pending"}
)
response = await handler.rename_folder(
FakeRequest({"folder_path": "/library/full", "new_name": "renamed"})
)
assert response.status == 409
assert json.loads(response.text)["code"] == "busy"
@pytest.mark.asyncio
async def test_rename_folder_invalid_name_maps_to_400():
handler, _service = _make_handler(
{"success": False, "error": "Invalid characters in folder name"}
)
response = await handler.rename_folder(
FakeRequest({"folder_path": "/library/full", "new_name": "a/b"})
)
assert response.status == 400
assert json.loads(response.text)["success"] is False
@pytest.mark.asyncio
async def test_rename_folder_invalid_json_body():
class BadJsonRequest:
async def json(self):
raise ValueError("bad json")
handler, _service = _make_handler({"success": True})
response = await handler.rename_folder(BadJsonRequest())
assert response.status == 400
assert json.loads(response.text)["success"] is False
+339
View File
@@ -14,6 +14,8 @@ class FakeScanner:
def __init__(self, roots: List[Path]) -> None:
self._roots = [str(root) for root in roots]
self.known_folders: List[str] = []
self.removed_folders: List[str] = []
self.renamed_folders: List[tuple] = []
def get_model_roots(self) -> List[str]:
return list(self._roots)
@@ -21,6 +23,12 @@ 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:
self.removed_folders.append(folder)
async def rename_known_folder(self, previous: str, current: str, **kwargs) -> None:
self.renamed_folders.append((previous, current, kwargs))
@pytest.mark.asyncio
async def test_create_folder_creates_directory_and_registers_it(tmp_path: Path):
@@ -87,3 +95,334 @@ async def test_create_folder_requires_path(tmp_path: Path):
result = await service.create_folder("")
assert result["success"] is False
def _make_nested(root: Path) -> Path:
target = root / "characters" / "anime"
target.mkdir(parents=True)
return target
@pytest.mark.asyncio
async def test_delete_folder_removes_empty_directory_and_forgets_it(tmp_path: Path):
target = _make_nested(tmp_path)
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is True
assert result["folder"] == "characters/anime"
assert result["model_count"] == 0
assert result["restorable"] is True
assert not target.exists()
assert scanner.removed_folders == ["characters/anime"]
@pytest.mark.asyncio
async def test_delete_folder_reports_non_model_leftovers_as_not_restorable(tmp_path: Path):
target = _make_nested(tmp_path)
(target / "notes.txt").write_text("keep me?", encoding="utf-8")
(target / "nested").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is True
assert result["file_count"] == 1
assert result["dir_count"] == 1
assert result["restorable"] is False
assert not target.exists()
@pytest.mark.asyncio
async def test_delete_folder_refuses_when_models_live_below(tmp_path: Path):
target = _make_nested(tmp_path)
model_file = target / "model.safetensors"
model_file.write_text("weights", encoding="utf-8")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is False
assert result["code"] == "not_empty"
assert result["manifest"]["model_count"] == 1
assert target.exists()
assert model_file.exists()
assert scanner.removed_folders == []
@pytest.mark.asyncio
async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
target = _make_nested(tmp_path)
(target / ".lm-pending-delete").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is False
assert result["code"] == "busy"
assert target.exists()
@pytest.mark.asyncio
async def test_delete_folder_refuses_the_library_root_itself(tmp_path: Path):
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(tmp_path))
assert result["success"] is False
assert "root" in result["error"].lower()
assert tmp_path.exists()
@pytest.mark.asyncio
async def test_delete_folder_rejects_paths_outside_roots(tmp_path: Path):
root = tmp_path / "library"
root.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
scanner = FakeScanner([root])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(outside))
assert result["success"] is False
assert "error" in result
assert outside.exists()
assert scanner.removed_folders == []
@pytest.mark.asyncio
async def test_delete_folder_reports_missing_directory(tmp_path: Path):
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(tmp_path / "gone"))
assert result["success"] is False
assert "no longer exists" in result["error"]
@pytest.mark.asyncio
async def test_delete_folder_requires_path(tmp_path: Path):
service = ModelMoveService(FakeScanner([tmp_path]), "lora")
result = await service.delete_folder("")
assert result["success"] is False
@pytest.mark.asyncio
async def test_delete_folder_dry_run_reports_without_removing(tmp_path: Path):
target = _make_nested(tmp_path)
(target / "leftover.webp").write_text("preview", encoding="utf-8")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target), dry_run=True)
assert result["success"] is True
assert result["dry_run"] is True
assert result["file_count"] == 1
assert target.exists()
assert scanner.removed_folders == []
@pytest.mark.asyncio
async def test_delete_folder_refuses_symlinked_directory(tmp_path: Path):
real = tmp_path / "real"
real.mkdir()
link = tmp_path / "link"
try:
link.symlink_to(real, target_is_directory=True)
except (OSError, NotImplementedError): # pragma: no cover - platform guard
pytest.skip("symlinks are not supported on this platform")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(link))
assert result["success"] is False
assert "symlink" in result["error"].lower()
assert link.is_symlink()
assert real.is_dir()
@pytest.mark.asyncio
async def test_delete_folder_counts_nested_symlinks_without_following_them(tmp_path: Path):
target = _make_nested(tmp_path)
real = tmp_path / "real"
real.mkdir()
(real / "model.safetensors").write_text("weights", encoding="utf-8")
try:
(target / "linked").symlink_to(real, target_is_directory=True)
except (OSError, NotImplementedError): # pragma: no cover - platform guard
pytest.skip("symlinks are not supported on this platform")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is True
assert result["symlink_count"] == 1
# The linked model is not part of the subtree being deleted
assert result["model_count"] == 0
assert (real / "model.safetensors").exists()
@pytest.mark.asyncio
async def test_rename_folder_moves_directory_and_forwards_rekey(tmp_path: Path):
target = _make_nested(tmp_path)
(target / "model.safetensors").write_text("weights", encoding="utf-8")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(target), "animation")
renamed = tmp_path / "characters" / "animation"
assert result["success"] is True
assert result["renamed"] is True
assert result["folder"] == "characters/animation"
assert result["previous_folder"] == "characters/anime"
assert renamed.is_dir()
assert (renamed / "model.safetensors").exists()
assert not target.exists()
previous, current, kwargs = scanner.renamed_folders[0]
assert previous == "characters/anime"
assert current == "characters/animation"
assert kwargs["previous_path"] == target.as_posix()
assert kwargs["new_path"] == renamed.as_posix()
@pytest.mark.asyncio
async def test_rename_folder_noop_when_name_is_unchanged(tmp_path: Path):
target = _make_nested(tmp_path)
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(target), "anime")
assert result["success"] is True
assert result["renamed"] is False
assert target.is_dir()
assert scanner.renamed_folders == []
@pytest.mark.asyncio
async def test_rename_folder_refuses_existing_target(tmp_path: Path):
target = _make_nested(tmp_path)
(tmp_path / "characters" / "animation").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(target), "animation")
assert result["success"] is False
assert result["code"] == "target_exists"
assert target.is_dir()
assert scanner.renamed_folders == []
@pytest.mark.parametrize("new_name", ["", " ", "a/b", "..", ".", "bad:name", "back\\slash"])
@pytest.mark.asyncio
async def test_rename_folder_rejects_invalid_names(tmp_path: Path, new_name: str):
target = _make_nested(tmp_path)
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(target), new_name)
assert result["success"] is False
assert target.is_dir()
assert scanner.renamed_folders == []
@pytest.mark.asyncio
async def test_rename_folder_refuses_the_library_root_itself(tmp_path: Path):
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(tmp_path), "renamed-root")
assert result["success"] is False
assert "root" in result["error"].lower()
assert tmp_path.is_dir()
@pytest.mark.asyncio
async def test_rename_folder_rejects_paths_outside_roots(tmp_path: Path):
root = tmp_path / "library"
root.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
scanner = FakeScanner([root])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(outside), "renamed")
assert result["success"] is False
assert outside.is_dir()
assert scanner.renamed_folders == []
@pytest.mark.asyncio
async def test_rename_folder_reports_missing_directory(tmp_path: Path):
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(tmp_path / "gone"), "renamed")
assert result["success"] is False
assert "no longer exists" in result["error"]
@pytest.mark.asyncio
async def test_rename_folder_refuses_symlinked_directory(tmp_path: Path):
real = tmp_path / "real"
real.mkdir()
link = tmp_path / "link"
try:
link.symlink_to(real, target_is_directory=True)
except (OSError, NotImplementedError): # pragma: no cover - platform guard
pytest.skip("symlinks are not supported on this platform")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(link), "renamed")
assert result["success"] is False
assert "symlink" in result["error"].lower()
assert link.is_symlink()
@pytest.mark.asyncio
async def test_rename_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
target = _make_nested(tmp_path)
(target / ".lm-pending-delete").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(target), "animation")
assert result["success"] is False
assert result["code"] == "busy"
assert target.is_dir()
assert scanner.renamed_folders == []
@pytest.mark.asyncio
async def test_rename_folder_requires_path(tmp_path: Path):
service = ModelMoveService(FakeScanner([tmp_path]), "lora")
result = await service.rename_folder("", "renamed")
assert result["success"] is False
+217
View File
@@ -1547,6 +1547,223 @@ async def test_add_known_folder_ignores_empty_input(tmp_path: Path):
assert cache.all_folders == before
@pytest.mark.asyncio
async def test_remove_known_folder_drops_subtree_and_keeps_ancestors(tmp_path: Path):
_create_files(tmp_path)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
await scanner.add_known_folder("nested/deep/leaf")
await scanner.remove_known_folder("nested/deep")
assert "nested/deep" not in cache.all_folders
assert "nested/deep/leaf" not in cache.all_folders
# The ancestor directory still exists on disk in its own right
assert "nested" in cache.all_folders
@pytest.mark.asyncio
async def test_remove_known_folder_purges_stale_cache_entries(tmp_path: Path):
_, second, _ = _create_files(tmp_path)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
assert "nested" in cache.folders
await scanner.remove_known_folder("nested")
assert "nested" not in cache.all_folders
assert "nested" not in cache.folders
assert _normalize_path(second) not in {
item["file_path"] for item in cache.raw_data
}
@pytest.mark.asyncio
async def test_remove_known_folder_noop_without_recorded_folders(tmp_path: Path):
_create_files(tmp_path)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
cache.all_folders = None
# Legacy snapshot without recorded folders: nothing to prune, and the
# scheduled backfill walk rebuilds the list from disk.
await scanner.remove_known_folder("nested")
assert cache.all_folders is None
@pytest.mark.asyncio
async def test_remove_known_folder_ignores_empty_input(tmp_path: Path):
_create_files(tmp_path)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
before = list(cache.all_folders)
await scanner.remove_known_folder("")
await scanner.remove_known_folder("/")
assert cache.all_folders == before
@pytest.mark.asyncio
async def test_rename_known_folder_rekeys_folders_cache_and_sidecar(tmp_path: Path):
_, second, _ = _create_files(tmp_path)
nested = tmp_path / "nested"
preview = nested / "two.preview.png"
preview.write_text("png", encoding="utf-8")
(nested / "two.metadata.json").write_text(
json.dumps(
{
"file_path": _normalize_path(second),
"preview_url": _normalize_path(preview),
}
),
encoding="utf-8",
)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
entry = next(item for item in cache.raw_data if item["model_name"] == "two")
entry["preview_url"] = _normalize_path(preview)
renamed = tmp_path / "renamed"
old_abs = _normalize_path(nested)
new_abs = _normalize_path(renamed)
os.rename(nested, renamed)
changed = await scanner.rename_known_folder(
"nested", "renamed", previous_path=old_abs, new_path=new_abs
)
assert changed is True
assert "renamed" in cache.all_folders
assert "nested" not in cache.all_folders
assert "renamed" in cache.folders
assert "nested" not in cache.folders
assert entry["folder"] == "renamed"
assert entry["file_path"] == _normalize_path(renamed / "two.txt")
assert entry["preview_url"] == _normalize_path(renamed / "two.preview.png")
assert scanner._hash_index.get_path("hash-two") == _normalize_path(
renamed / "two.txt"
)
# The sidecar travelled with the directory and was re-pointed in place
payload = json.loads(
(renamed / "two.metadata.json").read_text(encoding="utf-8")
)
assert payload["file_path"] == _normalize_path(renamed / "two.txt")
assert payload["preview_url"] == _normalize_path(renamed / "two.preview.png")
@pytest.mark.asyncio
async def test_rename_known_folder_handles_nested_targets(tmp_path: Path):
(tmp_path / "a" / "b" / "c").mkdir(parents=True)
model = tmp_path / "a" / "b" / "c" / "m.txt"
model.write_text("m", encoding="utf-8")
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
old_abs = _normalize_path(tmp_path / "a" / "b")
new_abs = _normalize_path(tmp_path / "a" / "z")
os.rename(tmp_path / "a" / "b", tmp_path / "a" / "z")
await scanner.rename_known_folder(
"a/b", "a/z", previous_path=old_abs, new_path=new_abs
)
assert "a/b" not in cache.all_folders
assert "a/b/c" not in cache.all_folders
assert "a/z" in cache.all_folders
assert "a/z/c" in cache.all_folders
# The parent is an untouched directory in its own right
assert "a" in cache.all_folders
entry = next(item for item in cache.raw_data if item["model_name"] == "m")
assert entry["folder"] == "a/z/c"
assert entry["file_path"] == _normalize_path(tmp_path / "a" / "z" / "c" / "m.txt")
@pytest.mark.asyncio
async def test_rename_known_folder_keeps_unrelated_entries(tmp_path: Path):
first, _, _ = _create_files(tmp_path)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
old_abs = _normalize_path(tmp_path / "nested")
new_abs = _normalize_path(tmp_path / "renamed")
os.rename(tmp_path / "nested", tmp_path / "renamed")
await scanner.rename_known_folder(
"nested", "renamed", previous_path=old_abs, new_path=new_abs
)
root_entry = next(item for item in cache.raw_data if item["model_name"] == "one")
assert root_entry["folder"] == ""
assert root_entry["file_path"] == _normalize_path(first)
@pytest.mark.asyncio
async def test_rename_known_folder_rekeys_excluded_models(tmp_path: Path):
nested = tmp_path / "nested"
nested.mkdir()
(nested / "one.txt").write_text("one", encoding="utf-8")
(nested / "skip-me.txt").write_text("skip", encoding="utf-8")
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
assert scanner._excluded_models == [_normalize_path(nested / "skip-me.txt")]
old_abs = _normalize_path(nested)
new_abs = _normalize_path(tmp_path / "renamed")
os.rename(nested, tmp_path / "renamed")
await scanner.rename_known_folder(
"nested", "renamed", previous_path=old_abs, new_path=new_abs
)
assert scanner._excluded_models == [
_normalize_path(tmp_path / "renamed" / "skip-me.txt")
]
@pytest.mark.asyncio
async def test_rename_known_folder_ignores_unchanged_or_empty_names(tmp_path: Path):
_create_files(tmp_path)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
before = list(cache.all_folders)
assert (
await scanner.rename_known_folder(
"nested",
"nested",
previous_path=_normalize_path(tmp_path / "nested"),
new_path=_normalize_path(tmp_path / "nested"),
)
is False
)
assert (
await scanner.rename_known_folder(
"",
"renamed",
previous_path=_normalize_path(tmp_path),
new_path=_normalize_path(tmp_path / "renamed"),
)
is False
)
assert cache.all_folders == before
@pytest.mark.asyncio
async def test_get_all_folders_updated_after_move(tmp_path: Path):
first, _, _ = _create_files(tmp_path)