Compare commits

...
Author SHA1 Message Date
Will Miao 7aee964448 feat(sidecars): surface storage location and cover excluded models in migration
After migrating to centralized sidecar storage users had no indication
where their files went, and portable-mode installs silently placed the
sidecar root inside the plugin folder where a reinstall or git clean
would delete it.

- Migration now also covers models excluded from the library view and
  returns the resolved sidecar root in its result payload
- get_settings exposes the resolved sidecar root, whether it is the
  default, and whether it lives inside the installation folder
- New POST /api/lm/sidecars/open-location endpoint opens (or copies)
  the sidecar storage folder
- Settings UI always shows the effective storage path with an
  open-folder button, and warns when the root is inside the
  installation folder (portable-mode hazard)
- Migration confirmation shows the destination; on completion a result
  dialog summarizes moved/skipped/conflict counts with the storage
  location and an open-folder action
- Ignore /sidecars/ at the repository root so portable-mode sidecars
  are never committed

Refs #1045
2026-09-26 23:08:29 +08:00
25 changed files with 817 additions and 34 deletions
+3
View File
@@ -10,6 +10,9 @@ civitai/
stats/
wildcards/
backups/
# Portable-mode centralized sidecar storage (<repo>/sidecars): user data that
# must survive pulls and stay out of git status
/sidecars/
logs/
node_modules/
coverage/
+2 -1
View File
@@ -28,8 +28,9 @@ In centralized mode, sidecars and previews mirror the library-relative directory
- `<library>` is the active library name and `<rel_dir>` the model's directory relative to the model root containing the file. `<root_basename-roothash>` combines the root's basename with a short hash of its full path so two roots sharing a basename (e.g. `/mnt/a/loras` and `/mnt/b/loras`) never collide. Each component is sanitized to filesystem-safe characters.
- `.civitai.info` files always stay next to the model file, in both modes.
- Changing the mode does **not** move existing files automatically — run the migration (`POST /api/lm/sidecars/migrate` with `{"direction": "to_centralized" | "to_alongside"}`, or the "Migrate Sidecars Now" button in settings).
- Changing the mode does **not** move existing files automatically — run the migration (`POST /api/lm/sidecars/migrate` with `{"direction": "to_centralized" | "to_alongside"}`, or the "Migrate Sidecars Now" button in settings). The migration covers excluded (hidden) models too, so un-excluding one later never strands its sidecar in the old layout. The result payload includes a `sidecar_root` field with the resolved centralized root, and the settings UI shows the outcome counters plus an "Open Folder" shortcut.
- Changing `sidecar_storage_path` while centralized likewise needs a root relocation: `{"direction": "relocate_root", "old_root": "<previous path>"}` moves the whole mirror tree to the new root (the settings UI offers this automatically).
- The settings UI always shows the resolved effective storage root (via the `sidecar_storage_root*` fields in `GET /api/lm/settings`), with `POST /api/lm/sidecars/open-location` opening it in the file manager. When the resolved root lies inside the plugin installation folder (portable settings mode), the UI warns: reinstalling or clean-updating the plugin would delete the sidecars, so an explicit path outside the installation folder is recommended. The repo `.gitignore` excludes the portable-mode default (`/sidecars/`).
- All sidecar/preview path derivation goes through the helpers in `py/utils/sidecar_paths.py`; never construct paths inline.
---
+16 -2
View File
@@ -810,7 +810,14 @@
"migrationDeferred": "Vorhandene Sidecar-Dateien wurden nicht verschoben. Sie können sie später unter Einstellungen → Bibliothek → Sidecar-Speicherung verschieben.",
"confirmToCentralized": "Der Speichermodus wurde geändert, aber vorhandene .metadata.json-Sidecar-Dateien und Vorschaubilder werden nicht automatisch verschoben. Jetzt in das zentrale Speicherverzeichnis verschieben? Sie können das auch später über die Schaltfläche „Sidecar-Dateien jetzt verschieben“ tun.",
"confirmToAlongside": "Der Speichermodus wurde geändert, aber vorhandene .metadata.json-Sidecar-Dateien und Vorschaubilder werden nicht automatisch verschoben. Jetzt wieder neben ihre Modelldateien verschieben? Sie können das auch später über die Schaltfläche „Sidecar-Dateien jetzt verschieben“ tun.",
"confirmRelocateRoot": "Das zentrale Speicherverzeichnis wurde geändert, aber vorhandene Sidecar-Dateien und Vorschaubilder liegen noch im vorherigen Verzeichnis. Jetzt in das neue Verzeichnis verschieben?"
"confirmRelocateRoot": "Das zentrale Speicherverzeichnis wurde geändert, aber vorhandene Sidecar-Dateien und Vorschaubilder liegen noch im vorherigen Verzeichnis. Jetzt in das neue Verzeichnis verschieben?",
"effectivePathLabel": "[TODO: Translate] Effective storage location:",
"openFolderButton": "[TODO: Translate] Open Folder",
"repoWarning": "[TODO: Translate] The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
"openLocationSuccess": "[TODO: Translate] Opened sidecar storage folder",
"openLocationCopied": "[TODO: Translate] Sidecar storage path copied to clipboard: {path}",
"openLocationClipboardFallback": "[TODO: Translate] Copy the sidecar storage path manually: {path}",
"openLocationFailed": "[TODO: Translate] Failed to open the sidecar storage folder"
},
"proxySettings": {
"enableProxy": "App-Proxy aktivieren",
@@ -1669,7 +1676,14 @@
"titleToCentralized": "Sidecar-Dateien in die zentrale Speicherung verschieben?",
"titleToAlongside": "Sidecar-Dateien zurück neben die Modelldateien verschieben?",
"confirmButton": "Jetzt verschieben",
"titleRelocateRoot": "Sidecar-Dateien in das neue Speicherverzeichnis verschieben?"
"titleRelocateRoot": "Sidecar-Dateien in das neue Speicherverzeichnis verschieben?",
"destination": "[TODO: Translate] Destination: {path}"
},
"sidecarMigrationResult": {
"title": "[TODO: Translate] Sidecar migration completed",
"titleWithErrors": "[TODO: Translate] Sidecar migration completed with {count} error(s)",
"summary": "[TODO: Translate] Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
"location": "[TODO: Translate] Storage location: {path}"
},
"bulkAddTags": {
"title": "Tags zu mehreren Modellen hinzufügen",
+16 -2
View File
@@ -810,7 +810,14 @@
"migrationDeferred": "Existing sidecars were not moved. You can migrate them later from Settings → Library → Sidecar Storage.",
"confirmToCentralized": "The storage mode changed, but existing .metadata.json sidecars and preview images are not moved automatically. Move them into the centralized storage directory now? You can also do this later with the \"Migrate Sidecars Now\" button.",
"confirmToAlongside": "The storage mode changed, but existing .metadata.json sidecars and preview images are not moved automatically. Move them back next to their model files now? You can also do this later with the \"Migrate Sidecars Now\" button.",
"confirmRelocateRoot": "The centralized storage directory changed, but existing sidecars and preview images are still in the previous directory. Move them to the new directory now?"
"confirmRelocateRoot": "The centralized storage directory changed, but existing sidecars and preview images are still in the previous directory. Move them to the new directory now?",
"effectivePathLabel": "Effective storage location:",
"openFolderButton": "Open Folder",
"repoWarning": "The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
"openLocationSuccess": "Opened sidecar storage folder",
"openLocationCopied": "Sidecar storage path copied to clipboard: {path}",
"openLocationClipboardFallback": "Copy the sidecar storage path manually: {path}",
"openLocationFailed": "Failed to open the sidecar storage folder"
},
"proxySettings": {
"enableProxy": "Enable App-level Proxy",
@@ -1669,7 +1676,14 @@
"titleToCentralized": "Move sidecars to centralized storage?",
"titleToAlongside": "Move sidecars back next to model files?",
"confirmButton": "Migrate Now",
"titleRelocateRoot": "Move sidecars to the new storage directory?"
"titleRelocateRoot": "Move sidecars to the new storage directory?",
"destination": "Destination: {path}"
},
"sidecarMigrationResult": {
"title": "Sidecar migration completed",
"titleWithErrors": "Sidecar migration completed with {count} error(s)",
"summary": "Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
"location": "Storage location: {path}"
},
"bulkAddTags": {
"title": "Add Tags to Multiple Models",
+16 -2
View File
@@ -810,7 +810,14 @@
"migrationDeferred": "Los archivos sidecar existentes no se movieron. Puedes migrarlos más tarde desde Configuración → Biblioteca → Almacenamiento de archivos sidecar.",
"confirmToCentralized": "El modo de almacenamiento ha cambiado, pero los archivos sidecar .metadata.json y las imágenes de vista previa existentes no se mueven automáticamente. ¿Moverlos ahora al directorio de almacenamiento centralizado? También puedes hacerlo más tarde con el botón «Migrar archivos sidecar ahora».",
"confirmToAlongside": "El modo de almacenamiento ha cambiado, pero los archivos sidecar .metadata.json y las imágenes de vista previa existentes no se mueven automáticamente. ¿Devolverlos ahora junto a sus archivos de modelo? También puedes hacerlo más tarde con el botón «Migrar archivos sidecar ahora».",
"confirmRelocateRoot": "El directorio de almacenamiento centralizado ha cambiado, pero los archivos sidecar y las imágenes de vista previa existentes siguen en el directorio anterior. ¿Moverlos ahora al nuevo directorio?"
"confirmRelocateRoot": "El directorio de almacenamiento centralizado ha cambiado, pero los archivos sidecar y las imágenes de vista previa existentes siguen en el directorio anterior. ¿Moverlos ahora al nuevo directorio?",
"effectivePathLabel": "[TODO: Translate] Effective storage location:",
"openFolderButton": "[TODO: Translate] Open Folder",
"repoWarning": "[TODO: Translate] The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
"openLocationSuccess": "[TODO: Translate] Opened sidecar storage folder",
"openLocationCopied": "[TODO: Translate] Sidecar storage path copied to clipboard: {path}",
"openLocationClipboardFallback": "[TODO: Translate] Copy the sidecar storage path manually: {path}",
"openLocationFailed": "[TODO: Translate] Failed to open the sidecar storage folder"
},
"proxySettings": {
"enableProxy": "Habilitar proxy a nivel de aplicación",
@@ -1669,7 +1676,14 @@
"titleToCentralized": "¿Mover los archivos sidecar al almacenamiento centralizado?",
"titleToAlongside": "¿Devolver los archivos sidecar junto a los archivos de modelo?",
"confirmButton": "Migrar ahora",
"titleRelocateRoot": "¿Mover los archivos sidecar al nuevo directorio de almacenamiento?"
"titleRelocateRoot": "¿Mover los archivos sidecar al nuevo directorio de almacenamiento?",
"destination": "[TODO: Translate] Destination: {path}"
},
"sidecarMigrationResult": {
"title": "[TODO: Translate] Sidecar migration completed",
"titleWithErrors": "[TODO: Translate] Sidecar migration completed with {count} error(s)",
"summary": "[TODO: Translate] Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
"location": "[TODO: Translate] Storage location: {path}"
},
"bulkAddTags": {
"title": "Añadir etiquetas a múltiples modelos",
+16 -2
View File
@@ -810,7 +810,14 @@
"migrationDeferred": "Les fichiers sidecar existants n’ont pas été déplacés. Vous pouvez les migrer plus tard depuis Paramètres → Bibliothèque → Stockage des fichiers sidecar.",
"confirmToCentralized": "Le mode de stockage a changé, mais les fichiers sidecar .metadata.json et les images d’aperçu existants ne sont pas déplacés automatiquement. Les déplacer maintenant dans le dossier de stockage centralisé ? Vous pouvez aussi le faire plus tard avec le bouton « Migrer les fichiers sidecar maintenant ».",
"confirmToAlongside": "Le mode de stockage a changé, mais les fichiers sidecar .metadata.json et les images d’aperçu existants ne sont pas déplacés automatiquement. Les remettre maintenant à côté de leurs fichiers de modèle ? Vous pouvez aussi le faire plus tard avec le bouton « Migrer les fichiers sidecar maintenant ».",
"confirmRelocateRoot": "Le dossier de stockage centralisé a changé, mais les fichiers sidecar et les images d’aperçu existants se trouvent encore dans le dossier précédent. Les déplacer maintenant dans le nouveau dossier ?"
"confirmRelocateRoot": "Le dossier de stockage centralisé a changé, mais les fichiers sidecar et les images d’aperçu existants se trouvent encore dans le dossier précédent. Les déplacer maintenant dans le nouveau dossier ?",
"effectivePathLabel": "[TODO: Translate] Effective storage location:",
"openFolderButton": "[TODO: Translate] Open Folder",
"repoWarning": "[TODO: Translate] The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
"openLocationSuccess": "[TODO: Translate] Opened sidecar storage folder",
"openLocationCopied": "[TODO: Translate] Sidecar storage path copied to clipboard: {path}",
"openLocationClipboardFallback": "[TODO: Translate] Copy the sidecar storage path manually: {path}",
"openLocationFailed": "[TODO: Translate] Failed to open the sidecar storage folder"
},
"proxySettings": {
"enableProxy": "Activer le proxy au niveau de l'application",
@@ -1669,7 +1676,14 @@
"titleToCentralized": "Déplacer les fichiers sidecar vers le stockage centralisé ?",
"titleToAlongside": "Remettre les fichiers sidecar à côté des fichiers de modèle ?",
"confirmButton": "Migrer maintenant",
"titleRelocateRoot": "Déplacer les fichiers sidecar vers le nouveau dossier de stockage ?"
"titleRelocateRoot": "Déplacer les fichiers sidecar vers le nouveau dossier de stockage ?",
"destination": "[TODO: Translate] Destination: {path}"
},
"sidecarMigrationResult": {
"title": "[TODO: Translate] Sidecar migration completed",
"titleWithErrors": "[TODO: Translate] Sidecar migration completed with {count} error(s)",
"summary": "[TODO: Translate] Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
"location": "[TODO: Translate] Storage location: {path}"
},
"bulkAddTags": {
"title": "Ajouter des tags à plusieurs modèles",
+16 -2
View File
@@ -810,7 +810,14 @@
"migrationDeferred": "קובצי הלוואי הקיימים לא הועברו. ניתן להעביר אותם מאוחר יותר דרך הגדרות > ספרייה > אחסון קובצי לוואי.",
"confirmToCentralized": "מצב האחסון השתנה, אך קובצי הלוואי .metadata.json ותמונות התצוגה המקדימה הקיימים אינם מועברים אוטומטית. להעביר אותם כעת לתיקיית האחסון המרכזי? ניתן לעשות זאת גם מאוחר יותר באמצעות הכפתור «העבר קובצי לוואי כעת».",
"confirmToAlongside": "מצב האחסון השתנה, אך קובצי הלוואי .metadata.json ותמונות התצוגה המקדימה הקיימים אינם מועברים אוטומטית. להחזיר אותם כעת לצד קובצי המודל שלהם? ניתן לעשות זאת גם מאוחר יותר באמצעות הכפתור «העבר קובצי לוואי כעת».",
"confirmRelocateRoot": "תיקיית האחסון המרכזי השתנתה, אך קובצי הלוואי ותמונות התצוגה המקדימה הקיימים עדיין נמצאים בתיקייה הקודמת. להעביר אותם כעת לתיקייה החדשה?"
"confirmRelocateRoot": "תיקיית האחסון המרכזי השתנתה, אך קובצי הלוואי ותמונות התצוגה המקדימה הקיימים עדיין נמצאים בתיקייה הקודמת. להעביר אותם כעת לתיקייה החדשה?",
"effectivePathLabel": "[TODO: Translate] Effective storage location:",
"openFolderButton": "[TODO: Translate] Open Folder",
"repoWarning": "[TODO: Translate] The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
"openLocationSuccess": "[TODO: Translate] Opened sidecar storage folder",
"openLocationCopied": "[TODO: Translate] Sidecar storage path copied to clipboard: {path}",
"openLocationClipboardFallback": "[TODO: Translate] Copy the sidecar storage path manually: {path}",
"openLocationFailed": "[TODO: Translate] Failed to open the sidecar storage folder"
},
"proxySettings": {
"enableProxy": "הפעל פרוקסי ברמת האפליקציה",
@@ -1669,7 +1676,14 @@
"titleToCentralized": "להעביר את קובצי הלוואי לאחסון המרכזי?",
"titleToAlongside": "להחזיר את קובצי הלוואי לצד קובצי המודל?",
"confirmButton": "העבר כעת",
"titleRelocateRoot": "להעביר את קובצי הלוואי לתיקיית האחסון החדשה?"
"titleRelocateRoot": "להעביר את קובצי הלוואי לתיקיית האחסון החדשה?",
"destination": "[TODO: Translate] Destination: {path}"
},
"sidecarMigrationResult": {
"title": "[TODO: Translate] Sidecar migration completed",
"titleWithErrors": "[TODO: Translate] Sidecar migration completed with {count} error(s)",
"summary": "[TODO: Translate] Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
"location": "[TODO: Translate] Storage location: {path}"
},
"bulkAddTags": {
"title": "הוסף תגיות למספר מודלים",
+16 -2
View File
@@ -810,7 +810,14 @@
"migrationDeferred": "既存のサイドカーファイルは移動されませんでした。後で「設定 > ライブラリ > サイドカーファイルの保存」から移動できます。",
"confirmToCentralized": "保存モードが変更されましたが、既存の .metadata.json サイドカーファイルとプレビュー画像は自動では移動しません。今すぐ集中保存ディレクトリに移動しますか?「今すぐサイドカーファイルを移動」ボタンで後から実行することもできます。",
"confirmToAlongside": "保存モードが変更されましたが、既存の .metadata.json サイドカーファイルとプレビュー画像は自動では移動しません。今すぐ各モデルファイルの隣に戻しますか?「今すぐサイドカーファイルを移動」ボタンで後から実行することもできます。",
"confirmRelocateRoot": "集中保存ディレクトリが変更されましたが、既存のサイドカーファイルとプレビュー画像はまだ以前のディレクトリにあります。今すぐ新しいディレクトリに移動しますか?"
"confirmRelocateRoot": "集中保存ディレクトリが変更されましたが、既存のサイドカーファイルとプレビュー画像はまだ以前のディレクトリにあります。今すぐ新しいディレクトリに移動しますか?",
"effectivePathLabel": "[TODO: Translate] Effective storage location:",
"openFolderButton": "[TODO: Translate] Open Folder",
"repoWarning": "[TODO: Translate] The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
"openLocationSuccess": "[TODO: Translate] Opened sidecar storage folder",
"openLocationCopied": "[TODO: Translate] Sidecar storage path copied to clipboard: {path}",
"openLocationClipboardFallback": "[TODO: Translate] Copy the sidecar storage path manually: {path}",
"openLocationFailed": "[TODO: Translate] Failed to open the sidecar storage folder"
},
"proxySettings": {
"enableProxy": "アプリレベルのプロキシを有効化",
@@ -1669,7 +1676,14 @@
"titleToCentralized": "サイドカーファイルを集中保存に移動しますか?",
"titleToAlongside": "サイドカーファイルをモデルファイルの隣に戻しますか?",
"confirmButton": "今すぐ移動",
"titleRelocateRoot": "サイドカーファイルを新しい保存ディレクトリに移動しますか?"
"titleRelocateRoot": "サイドカーファイルを新しい保存ディレクトリに移動しますか?",
"destination": "[TODO: Translate] Destination: {path}"
},
"sidecarMigrationResult": {
"title": "[TODO: Translate] Sidecar migration completed",
"titleWithErrors": "[TODO: Translate] Sidecar migration completed with {count} error(s)",
"summary": "[TODO: Translate] Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
"location": "[TODO: Translate] Storage location: {path}"
},
"bulkAddTags": {
"title": "複数モデルにタグを追加",
+16 -2
View File
@@ -810,7 +810,14 @@
"migrationDeferred": "기존 사이드카 파일은 이동되지 않았습니다. 나중에 설정 → 라이브러리 → 사이드카 파일 저장에서 이동할 수 있습니다.",
"confirmToCentralized": "저장 모드가 변경되었지만 기존 .metadata.json 사이드카 파일과 미리보기 이미지는 자동으로 이동되지 않습니다. 지금 중앙 집중식 저장 디렉터리로 이동할까요? '지금 사이드카 파일 이동' 버튼으로 나중에 실행할 수도 있습니다.",
"confirmToAlongside": "저장 모드가 변경되었지만 기존 .metadata.json 사이드카 파일과 미리보기 이미지는 자동으로 이동되지 않습니다. 지금 각 모델 파일 옆으로 되돌릴까요? '지금 사이드카 파일 이동' 버튼으로 나중에 실행할 수도 있습니다.",
"confirmRelocateRoot": "중앙 집중식 저장 디렉터리가 변경되었지만 기존 사이드카 파일과 미리보기 이미지는 아직 이전 디렉터리에 있습니다. 지금 새 디렉터리로 이동할까요?"
"confirmRelocateRoot": "중앙 집중식 저장 디렉터리가 변경되었지만 기존 사이드카 파일과 미리보기 이미지는 아직 이전 디렉터리에 있습니다. 지금 새 디렉터리로 이동할까요?",
"effectivePathLabel": "[TODO: Translate] Effective storage location:",
"openFolderButton": "[TODO: Translate] Open Folder",
"repoWarning": "[TODO: Translate] The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
"openLocationSuccess": "[TODO: Translate] Opened sidecar storage folder",
"openLocationCopied": "[TODO: Translate] Sidecar storage path copied to clipboard: {path}",
"openLocationClipboardFallback": "[TODO: Translate] Copy the sidecar storage path manually: {path}",
"openLocationFailed": "[TODO: Translate] Failed to open the sidecar storage folder"
},
"proxySettings": {
"enableProxy": "앱 수준 프록시 활성화",
@@ -1669,7 +1676,14 @@
"titleToCentralized": "사이드카 파일을 중앙 집중식 저장으로 이동할까요?",
"titleToAlongside": "사이드카 파일을 모델 파일 옆으로 되돌릴까요?",
"confirmButton": "지금 이동",
"titleRelocateRoot": "사이드카 파일을 새 저장 디렉터리로 이동할까요?"
"titleRelocateRoot": "사이드카 파일을 새 저장 디렉터리로 이동할까요?",
"destination": "[TODO: Translate] Destination: {path}"
},
"sidecarMigrationResult": {
"title": "[TODO: Translate] Sidecar migration completed",
"titleWithErrors": "[TODO: Translate] Sidecar migration completed with {count} error(s)",
"summary": "[TODO: Translate] Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
"location": "[TODO: Translate] Storage location: {path}"
},
"bulkAddTags": {
"title": "여러 모델에 태그 추가",
+16 -2
View File
@@ -810,7 +810,14 @@
"migrationDeferred": "Существующие sidecar-файлы не были перенесены. Вы можете перенести их позже в разделе «Настройки → Библиотека → Хранилище sidecar-файлов».",
"confirmToCentralized": "Режим хранения изменён, но существующие sidecar-файлы .metadata.json и изображения превью не переносятся автоматически. Перенести их сейчас в централизованное хранилище? Это также можно сделать позже кнопкой «Перенести sidecar-файлы сейчас».",
"confirmToAlongside": "Режим хранения изменён, но существующие sidecar-файлы .metadata.json и изображения превью не переносятся автоматически. Вернуть их сейчас рядом с их файлами моделей? Это также можно сделать позже кнопкой «Перенести sidecar-файлы сейчас».",
"confirmRelocateRoot": "Каталог централизованного хранилища изменён, но существующие sidecar-файлы и изображения превью всё ещё находятся в прежнем каталоге. Перенести их сейчас в новый каталог?"
"confirmRelocateRoot": "Каталог централизованного хранилища изменён, но существующие sidecar-файлы и изображения превью всё ещё находятся в прежнем каталоге. Перенести их сейчас в новый каталог?",
"effectivePathLabel": "[TODO: Translate] Effective storage location:",
"openFolderButton": "[TODO: Translate] Open Folder",
"repoWarning": "[TODO: Translate] The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
"openLocationSuccess": "[TODO: Translate] Opened sidecar storage folder",
"openLocationCopied": "[TODO: Translate] Sidecar storage path copied to clipboard: {path}",
"openLocationClipboardFallback": "[TODO: Translate] Copy the sidecar storage path manually: {path}",
"openLocationFailed": "[TODO: Translate] Failed to open the sidecar storage folder"
},
"proxySettings": {
"enableProxy": "Включить прокси на уровне приложения",
@@ -1669,7 +1676,14 @@
"titleToCentralized": "Перенести sidecar-файлы в централизованное хранилище?",
"titleToAlongside": "Вернуть sidecar-файлы рядом с файлами моделей?",
"confirmButton": "Перенести сейчас",
"titleRelocateRoot": "Перенести sidecar-файлы в новый каталог хранилища?"
"titleRelocateRoot": "Перенести sidecar-файлы в новый каталог хранилища?",
"destination": "[TODO: Translate] Destination: {path}"
},
"sidecarMigrationResult": {
"title": "[TODO: Translate] Sidecar migration completed",
"titleWithErrors": "[TODO: Translate] Sidecar migration completed with {count} error(s)",
"summary": "[TODO: Translate] Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
"location": "[TODO: Translate] Storage location: {path}"
},
"bulkAddTags": {
"title": "Добавить теги к нескольким моделям",
+16 -2
View File
@@ -810,7 +810,14 @@
"migrationDeferred": "现有附属文件未迁移。你可以稍后在“设置 → 库 → 附属文件存储”中迁移它们。",
"confirmToCentralized": "存储模式已更改,但现有的 .metadata.json 附属文件和预览图片不会自动迁移。要现在将它们移入集中存储目录吗?你也可以稍后使用“立即迁移附属文件”按钮完成。",
"confirmToAlongside": "存储模式已更改,但现有的 .metadata.json 附属文件和预览图片不会自动迁移。要现在将它们移回各自的模型文件旁边吗?你也可以稍后使用“立即迁移附属文件”按钮完成。",
"confirmRelocateRoot": "集中存储目录已更改,但现有的附属文件和预览图片仍在原目录中。要现在将它们移到新目录吗?"
"confirmRelocateRoot": "集中存储目录已更改,但现有的附属文件和预览图片仍在原目录中。要现在将它们移到新目录吗?",
"effectivePathLabel": "[TODO: Translate] Effective storage location:",
"openFolderButton": "[TODO: Translate] Open Folder",
"repoWarning": "[TODO: Translate] The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
"openLocationSuccess": "[TODO: Translate] Opened sidecar storage folder",
"openLocationCopied": "[TODO: Translate] Sidecar storage path copied to clipboard: {path}",
"openLocationClipboardFallback": "[TODO: Translate] Copy the sidecar storage path manually: {path}",
"openLocationFailed": "[TODO: Translate] Failed to open the sidecar storage folder"
},
"proxySettings": {
"enableProxy": "启用应用级代理",
@@ -1669,7 +1676,14 @@
"titleToCentralized": "要将附属文件移到集中存储吗?",
"titleToAlongside": "要将附属文件移回模型文件旁边吗?",
"confirmButton": "立即迁移",
"titleRelocateRoot": "要将附属文件移到新的存储目录吗?"
"titleRelocateRoot": "要将附属文件移到新的存储目录吗?",
"destination": "[TODO: Translate] Destination: {path}"
},
"sidecarMigrationResult": {
"title": "[TODO: Translate] Sidecar migration completed",
"titleWithErrors": "[TODO: Translate] Sidecar migration completed with {count} error(s)",
"summary": "[TODO: Translate] Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
"location": "[TODO: Translate] Storage location: {path}"
},
"bulkAddTags": {
"title": "批量添加标签",
+16 -2
View File
@@ -810,7 +810,14 @@
"migrationDeferred": "現有附屬檔案未遷移。您稍後可以在「設定 > 模型庫 > 附屬檔案儲存」中遷移它們。",
"confirmToCentralized": "儲存模式已變更,但現有的 .metadata.json 附屬檔案與預覽圖片不會自動遷移。要現在將它們移入集中儲存目錄嗎?您也可以稍後使用「立即遷移附屬檔案」按鈕完成。",
"confirmToAlongside": "儲存模式已變更,但現有的 .metadata.json 附屬檔案與預覽圖片不會自動遷移。要現在將它們移回各自的模型檔案旁邊嗎?您也可以稍後使用「立即遷移附屬檔案」按鈕完成。",
"confirmRelocateRoot": "集中儲存目錄已變更,但現有的附屬檔案與預覽圖片仍在原目錄中。要現在將它們移到新目錄嗎?"
"confirmRelocateRoot": "集中儲存目錄已變更,但現有的附屬檔案與預覽圖片仍在原目錄中。要現在將它們移到新目錄嗎?",
"effectivePathLabel": "[TODO: Translate] Effective storage location:",
"openFolderButton": "[TODO: Translate] Open Folder",
"repoWarning": "[TODO: Translate] The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
"openLocationSuccess": "[TODO: Translate] Opened sidecar storage folder",
"openLocationCopied": "[TODO: Translate] Sidecar storage path copied to clipboard: {path}",
"openLocationClipboardFallback": "[TODO: Translate] Copy the sidecar storage path manually: {path}",
"openLocationFailed": "[TODO: Translate] Failed to open the sidecar storage folder"
},
"proxySettings": {
"enableProxy": "啟用應用程式代理",
@@ -1669,7 +1676,14 @@
"titleToCentralized": "要將附屬檔案移到集中儲存嗎?",
"titleToAlongside": "要將附屬檔案移回模型檔案旁邊嗎?",
"confirmButton": "立即遷移",
"titleRelocateRoot": "要將附屬檔案移到新的儲存目錄嗎?"
"titleRelocateRoot": "要將附屬檔案移到新的儲存目錄嗎?",
"destination": "[TODO: Translate] Destination: {path}"
},
"sidecarMigrationResult": {
"title": "[TODO: Translate] Sidecar migration completed",
"titleWithErrors": "[TODO: Translate] Sidecar migration completed with {count} error(s)",
"summary": "[TODO: Translate] Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
"location": "[TODO: Translate] Storage location: {path}"
},
"bulkAddTags": {
"title": "新增標籤到多個模型",
+38 -1
View File
@@ -70,7 +70,12 @@ from ...utils.example_images_paths import (
)
from ...utils.lora_metadata import extract_trained_words
from ...utils.session_logging import get_standalone_session_log_snapshot
from ...utils.sidecar_paths import get_metadata_path, get_preview_dir
from ...utils.sidecar_paths import (
describe_sidecar_root,
get_configured_sidecar_root,
get_metadata_path,
get_preview_dir,
)
from ...utils.usage_stats import UsageStats
from .base_model_handlers import BaseModelHandlerSet
@@ -1627,6 +1632,19 @@ class SettingsHandler:
settings_file = getattr(self._settings, "settings_file", None)
if settings_file:
response_data["settings_file"] = settings_file
# Resolved centralized sidecar root (mode-independent): lets the
# settings UI show where sidecars actually live, including when the
# path setting is empty and the default kicks in. inside_repo flags
# the portable-mode hazard (root inside the plugin folder).
try:
sidecar_info = describe_sidecar_root()
response_data["sidecar_storage_root"] = sidecar_info["root"]
response_data["sidecar_storage_root_is_default"] = sidecar_info["is_default"]
response_data["sidecar_storage_root_in_repo"] = sidecar_info["inside_repo"]
except Exception as sidecar_error: # pragma: no cover - defensive
logger.debug(
"Could not resolve sidecar storage info: %s", sidecar_error
)
messages_getter: Any = getattr(self._settings, "get_startup_messages", None)
messages = list(messages_getter()) if messages_getter else []
return web.json_response(
@@ -3516,6 +3534,24 @@ class FileSystemHandler:
logger.error("Failed to open wildcards location: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def open_sidecar_location(self, request: web.Request) -> web.Response:
"""Open the centralized sidecar storage root in the file manager."""
try:
root = get_configured_sidecar_root()
if not root:
return web.json_response(
{"success": False, "error": "Sidecar storage root is not resolvable"},
status=404,
)
# Create on demand so the button also works before the first
# migration/download has materialized the directory.
os.makedirs(root, exist_ok=True)
return await self._open_path(root)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to open sidecar location: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def browse_directory(self, request: web.Request) -> web.Response:
"""Browse a directory for the settings-UI directory picker."""
try:
@@ -4290,6 +4326,7 @@ class MiscHandlerSet:
"open_settings_location": self.filesystem.open_settings_location,
"open_backup_location": self.filesystem.open_backup_location,
"open_wildcards_location": self.filesystem.open_wildcards_location,
"open_sidecar_location": self.filesystem.open_sidecar_location,
"browse_directory": self.filesystem.browse_directory,
"validate_path": self.filesystem.validate_path,
"search_custom_words": self.custom_words.search_custom_words,
+3
View File
@@ -120,6 +120,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"GET", "/api/lm/sidecars/migrate", "migrate_sidecars"
),
RouteDefinition(
"POST", "/api/lm/sidecars/open-location", "open_sidecar_location"
),
RouteDefinition(
"POST", "/api/lm/download-model-source", "download_model_source"
),
@@ -276,6 +276,7 @@ class SidecarMigrationUseCase:
"conflicts": counters["conflicts"],
"errors": errors,
"error_count": len(errors),
"sidecar_root": new_root,
}
def _rewrite_root_prefix(
@@ -344,6 +345,7 @@ class SidecarMigrationUseCase:
"conflicts": 0,
"errors": [],
"error_count": 0,
"sidecar_root": get_configured_sidecar_root() or "",
}
def _active_scanner_factories(self) -> Tuple[Tuple[str, ScannerFactory], ...]:
@@ -356,7 +358,13 @@ class SidecarMigrationUseCase:
async def _collect_model_paths(
self, errors: List[Dict[str, str]]
) -> List[Tuple[Any, List[str]]]:
"""Enumerate model file paths grouped by the scanner that owns them."""
"""Enumerate model file paths grouped by the scanner that owns them.
Excluded models are included: they are absent from the cache but still
on disk, and leaving their sidecars behind would strand the metadata
if the user later un-excludes them (the scanner would then look the
sidecar up in the NEW layout and find nothing).
"""
groups: List[Tuple[Any, List[str]]] = []
for model_type, factory in self._active_scanner_factories():
@@ -376,6 +384,19 @@ class SidecarMigrationUseCase:
for entry in cache.raw_data
if entry.get("file_path")
]
get_excluded = getattr(scanner, "get_excluded_models", None)
if callable(get_excluded):
try:
known = set(paths)
paths.extend(
path for path in get_excluded() if path and path not in known
)
except Exception as exc:
self._logger.error(
"Sidecar migration: failed to enumerate excluded %s models: %s",
model_type,
exc,
)
groups.append((scanner, paths))
return groups
@@ -480,6 +501,9 @@ class SidecarMigrationUseCase:
"conflicts": conflicts,
"errors": errors,
"error_count": len(errors),
# Effective centralized root, so the UI can show/offer to open the
# destination (or, for to_alongside, the source) after the run.
"sidecar_root": root,
}
async def _migrate_model(
+41
View File
@@ -109,6 +109,47 @@ def get_configured_sidecar_root() -> str:
return _resolve_root_from_settings()
def _installation_root() -> str:
"""Return the plugin installation directory (repository root)."""
return os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
def _path_contains(base: str, path: str) -> bool:
"""Containment check tolerant of symlinked installs (custom_nodes links)."""
for candidate in (os.path.abspath(path), os.path.realpath(path)):
normalized = os.path.normcase(os.path.normpath(candidate))
for root_variant in (os.path.abspath(base), os.path.realpath(base)):
root_normalized = os.path.normcase(os.path.normpath(root_variant))
if (
normalized == root_normalized
or normalized.startswith(root_normalized + os.sep)
):
return True
return False
def describe_sidecar_root() -> dict:
"""Describe the effective centralized sidecar root for UI display.
``inside_repo`` flags the portable-mode hazard: when settings live in the
repository, the default root lands inside the plugin folder, where a
reinstall or ``git clean`` would silently delete every sidecar.
"""
configured = _get_settings_value("sidecar_storage_path", "")
is_default = not (isinstance(configured, str) and configured.strip())
root = _resolve_root_from_settings()
return {
"root": root,
"is_default": is_default,
"inside_repo": bool(root) and _path_contains(_installation_root(), root),
}
def sanitize_path_component(name: str) -> str:
"""Return a filesystem-safe single path component."""
+183 -4
View File
@@ -3404,9 +3404,71 @@ export class SettingsManager {
pathInput.value = state.global.settings.sidecar_storage_path || '';
}
this.renderSidecarStorageInfo();
this.updateSidecarStorageVisibility();
}
// Show the backend-resolved storage root (covers the default location,
// which the path input leaves blank) plus the portable-mode repo warning.
renderSidecarStorageInfo() {
const resolvedEl = document.getElementById('sidecarStorageResolvedPath');
if (resolvedEl) {
resolvedEl.textContent = state.global.settings.sidecar_storage_root || '';
}
const warningEl = document.getElementById('sidecarStorageRepoWarning');
if (warningEl) {
warningEl.style.display = state.global.settings.sidecar_storage_root_in_repo
? 'block'
: 'none';
}
}
// Re-pull just the derived sidecar fields after a path save: the resolved
// root is computed server-side (default location, absolutization).
async refreshSidecarStorageInfo() {
try {
const response = await fetch('/api/lm/settings');
const data = await response.json();
if (data.success && data.settings) {
state.global.settings.sidecar_storage_root = data.settings.sidecar_storage_root;
state.global.settings.sidecar_storage_root_is_default = data.settings.sidecar_storage_root_is_default;
state.global.settings.sidecar_storage_root_in_repo = data.settings.sidecar_storage_root_in_repo;
}
} catch (error) {
console.warn('Failed to refresh sidecar storage info:', error);
}
this.renderSidecarStorageInfo();
}
async openSidecarStorageLocation() {
try {
const response = await fetch('/api/lm/sidecars/open-location', {
method: 'POST'
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.json();
if (data.mode === 'clipboard' && data.path) {
try {
await navigator.clipboard.writeText(data.path);
showToast('settings.sidecarStorage.openLocationCopied', { path: data.path }, 'success');
} catch (clipboardErr) {
console.warn('Clipboard API not available:', clipboardErr);
showToast('settings.sidecarStorage.openLocationClipboardFallback', { path: data.path }, 'info');
}
} else {
showToast('settings.sidecarStorage.openLocationSuccess', {}, 'success');
}
} catch (error) {
console.error('Failed to open sidecar storage location:', error);
showToast('settings.sidecarStorage.openLocationFailed', {}, 'error');
}
}
updateSidecarStorageVisibility() {
const modeSelect = document.getElementById('sidecarStorageMode');
const pathSetting = document.getElementById('sidecarStoragePathSetting');
@@ -3453,6 +3515,10 @@ export class SettingsManager {
const newPath = pathInput.value.trim();
this._loadedSidecarStoragePath = newPath;
// The resolved root is server-side; refresh before any relocate
// confirm so the dialog can name the real destination.
await this.refreshSidecarStorageInfo();
const centralized = state.global.settings.sidecar_storage_mode === 'centralized';
if (centralized && previousPath && previousPath !== newPath) {
const confirmed = await this.confirmSidecarMigration('relocate_root');
@@ -3503,6 +3569,20 @@ export class SettingsManager {
: translate('settings.sidecarStorage.confirmToAlongside', {}, 'The storage mode changed, but existing .metadata.json sidecars and preview images are not moved automatically. Move them back next to their model files now? You can also do this later with the "Migrate Sidecars Now" button.');
}
// Name the destination so users know where the files are going.
const destinationElement = modalElement.querySelector('[data-role="destination"]');
if (destinationElement) {
const resolvedRoot = state.global.settings.sidecar_storage_root || '';
if (!isToCentralized && !isRelocate || !resolvedRoot) {
destinationElement.style.display = 'none';
} else {
destinationElement.textContent = translate(
'modals.sidecarMigrationConfirm.destination', { path: resolvedRoot }, `Destination: ${resolvedRoot}`
);
destinationElement.style.display = 'block';
}
}
const confirmButton = modalElement.querySelector('[data-action="confirm-sidecar-migration"]');
const cancelButton = modalElement.querySelector('[data-action="cancel-sidecar-migration"]');
if (!confirmButton || !cancelButton) {
@@ -3586,10 +3666,7 @@ export class SettingsManager {
}
state.loadingManager?.hide();
showToast('settings.sidecarStorage.migrateSuccess', {}, 'success');
// Reload so cards pick up metadata/preview paths from the new location
resetAndReload(true);
this.showSidecarMigrationResult(data);
} catch (error) {
console.error('Error migrating sidecars:', error);
state.loadingManager?.hide();
@@ -3602,6 +3679,108 @@ export class SettingsManager {
}
}
// Post-migration summary: counters + storage location, with an "open
// folder" shortcut. Closing reloads so cards pick up the new paths.
showSidecarMigrationResult(result) {
const modalElement = document.getElementById('sidecarMigrationResultModal');
if (!modalElement) {
showToast('settings.sidecarStorage.migrateSuccess', {}, 'success');
resetAndReload(true);
return;
}
const errorCount = result.error_count || 0;
const titleElement = modalElement.querySelector('[data-role="title"]');
if (titleElement) {
titleElement.textContent = errorCount
? translate('modals.sidecarMigrationResult.titleWithErrors', { count: errorCount }, `Sidecar migration completed with ${errorCount} error(s)`)
: translate('modals.sidecarMigrationResult.title', {}, 'Sidecar migration completed');
}
const messageElement = modalElement.querySelector('[data-role="message"]');
if (messageElement) {
messageElement.textContent = translate(
'modals.sidecarMigrationResult.summary',
{
moved: result.moved || 0,
models: result.models_moved || 0,
skipped: result.skipped || 0,
conflicts: result.conflicts || 0,
},
`Moved ${result.moved || 0} files for ${result.models_moved || 0} models. Skipped: ${result.skipped || 0}, conflicts resolved: ${result.conflicts || 0}.`
);
}
const showLocation = result.direction !== 'to_alongside' && !!result.sidecar_root;
const destinationElement = modalElement.querySelector('[data-role="destination"]');
if (destinationElement) {
if (showLocation) {
destinationElement.textContent = translate(
'modals.sidecarMigrationResult.location',
{ path: result.sidecar_root },
`Storage location: ${result.sidecar_root}`
);
destinationElement.style.display = 'block';
} else {
destinationElement.style.display = 'none';
}
}
const openButton = modalElement.querySelector('[data-action="open-sidecar-location"]');
const closeButton = modalElement.querySelector('[data-action="close-sidecar-result"]');
if (!closeButton) {
resetAndReload(true);
return;
}
if (openButton) {
openButton.style.display = showLocation ? '' : 'none';
}
const cleanup = () => {
closeButton.removeEventListener('click', handleClose);
if (openButton) {
openButton.removeEventListener('click', handleOpen);
}
document.removeEventListener('keydown', handleEscape, true);
};
const handleClose = (event) => {
event.preventDefault();
cleanup();
modalElement.classList.remove('show');
// Reload so cards pick up metadata/preview paths from the new location
resetAndReload(true);
};
// Opening the folder keeps the result modal open; the reload happens
// when the user closes it.
const handleOpen = (event) => {
event.preventDefault();
this.openSidecarStorageLocation();
};
// Capture phase + stopPropagation so ESC never reaches the settings
// modal's own ESC handler underneath.
const handleEscape = (event) => {
if (event.key === 'Escape') {
event.stopPropagation();
handleClose(event);
}
};
closeButton.addEventListener('click', handleClose);
if (openButton) {
openButton.addEventListener('click', handleOpen);
}
document.addEventListener('keydown', handleEscape, true);
modalElement.classList.add('show');
closeButton.focus();
}
async loadMetadataArchiveSettings() {
try {
// Load current settings from state
@@ -103,6 +103,7 @@
<div class="modal-content delete-modal-content">
<h2 data-role="title"></h2>
<p class="delete-message" data-role="message"></p>
<p class="delete-message sidecar-migration-destination" data-role="destination" style="display: none;"></p>
<div class="modal-actions">
<button class="cancel-btn" data-action="cancel-sidecar-migration">{{ t('common.actions.cancel') }}</button>
<button class="primary-btn" data-action="confirm-sidecar-migration"></button>
@@ -110,6 +111,22 @@
</div>
</div>
<!-- Sidecar Migration Result Modal
Self-managed by SettingsManager: shown after a migration run with the
outcome counters and the storage location; closing it reloads the page
so cards pick up the new paths. -->
<div id="sidecarMigrationResultModal" class="modal delete-modal">
<div class="modal-content delete-modal-content">
<h2 data-role="title"></h2>
<p class="delete-message" data-role="message"></p>
<p class="delete-message sidecar-migration-destination" data-role="destination" style="display: none;"></p>
<div class="modal-actions">
<button class="secondary-btn" data-action="open-sidecar-location" style="display: none;">{{ t('settings.sidecarStorage.openFolderButton') }}</button>
<button class="primary-btn" data-action="close-sidecar-result">{{ t('common.actions.close') }}</button>
</div>
</div>
</div>
<!-- Sidebar Folder Delete Confirmation Modal -->
<!-- Shared by two states: 'confirm' (model-free folder) and 'blocked' (the
subtree still holds models, so a cascade delete is refused). -->
@@ -359,6 +359,23 @@
</div>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="input-help sidecar-storage-location">
{{ t('settings.sidecarStorage.effectivePathLabel') }}
<code id="sidecarStorageResolvedPath" class="backup-location-path"></code>
</div>
<div class="input-help sidecar-storage-repo-warning" id="sidecarStorageRepoWarning" style="display: none;">
<i class="fas fa-exclamation-triangle"></i>
{{ t('settings.sidecarStorage.repoWarning') }}
</div>
</div>
<div class="setting-control">
<button type="button" class="secondary-btn" id="sidecarStorageOpenBtn" onclick="settingsManager.openSidecarStorageLocation()">
{{ t('settings.sidecarStorage.openFolderButton') }}
</button>
</div>
</div>
</div>
<div class="setting-item">
@@ -116,6 +116,7 @@ const appendMigrationModal = () => {
modal.innerHTML = `
<h2 data-role="title"></h2>
<p data-role="message"></p>
<p data-role="destination" style="display:none"></p>
<button data-action="confirm-sidecar-migration"></button>
<button data-action="cancel-sidecar-migration"></button>`;
document.body.appendChild(modal);
@@ -225,8 +226,30 @@ describe('SettingsManager sidecar storage', () => {
expect(modal.classList.contains('show')).toBe(false);
});
it('shows a deferred notice and skips migration when the user cancels', async () => {
it('names the resolved destination in the confirm dialog', async () => {
const manager = createManager();
const { select } = appendSidecarControls();
const modal = appendMigrationModal();
state.global.settings = {
sidecar_storage_mode: 'alongside',
sidecar_storage_root: '/data/sidecars',
};
manager._loadedSidecarStorageMode = 'alongside';
select.value = 'centralized';
mockFetchOk();
const changePromise = manager.handleSidecarStorageModeChange();
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
const destination = modal.querySelector('[data-role="destination"]');
expect(destination.textContent).toContain('/data/sidecars');
expect(destination.style.display).toBe('block');
modal.querySelector('[data-action="cancel-sidecar-migration"]').click();
await changePromise;
});
it('shows a deferred notice and skips migration when the user cancels', async () => { const manager = createManager();
const { select } = appendSidecarControls();
const modal = appendMigrationModal();
state.global.settings = { sidecar_storage_mode: 'centralized' };
@@ -282,8 +305,7 @@ describe('SettingsManager sidecar storage', () => {
});
});
describe('handleSidecarStoragePathChange', () => {
it('offers root relocation when the path changes in centralized mode', async () => {
describe('handleSidecarStoragePathChange', () => { it('offers root relocation when the path changes in centralized mode', async () => {
const manager = createManager();
const { pathInput } = appendSidecarControls();
const modal = appendMigrationModal();
@@ -337,4 +359,153 @@ describe('SettingsManager sidecar storage', () => {
expect(showToast).toHaveBeenCalledWith('settings.sidecarStorage.migrationDeferred', {}, 'info');
});
});
describe('renderSidecarStorageInfo', () => {
const appendStorageInfoElements = () => {
const resolved = document.createElement('code');
resolved.id = 'sidecarStorageResolvedPath';
const warning = document.createElement('div');
warning.id = 'sidecarStorageRepoWarning';
warning.style.display = 'none';
document.body.append(resolved, warning);
return { resolved, warning };
};
it('shows the resolved root and the repo warning when inside the install folder', () => {
const manager = createManager();
const { resolved, warning } = appendStorageInfoElements();
state.global.settings = {
sidecar_storage_root: '/repo/ComfyUI-Lora-Manager/sidecars',
sidecar_storage_root_in_repo: true,
};
manager.renderSidecarStorageInfo();
expect(resolved.textContent).toBe('/repo/ComfyUI-Lora-Manager/sidecars');
expect(warning.style.display).toBe('block');
});
it('hides the repo warning when the root lives outside the install folder', () => {
const manager = createManager();
const { resolved, warning } = appendStorageInfoElements();
state.global.settings = {
sidecar_storage_root: '/data/sidecars',
sidecar_storage_root_in_repo: false,
};
manager.renderSidecarStorageInfo();
expect(resolved.textContent).toBe('/data/sidecars');
expect(warning.style.display).toBe('none');
});
});
describe('openSidecarStorageLocation', () => {
it('posts to the open-location endpoint', async () => {
const manager = createManager();
mockFetchOk({ success: true });
await manager.openSidecarStorageLocation();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/sidecars/open-location', { method: 'POST' });
expect(showToast).toHaveBeenCalledWith('settings.sidecarStorage.openLocationSuccess', {}, 'success');
});
it('copies the path to the clipboard in clipboard mode', async () => {
const manager = createManager();
mockFetchOk({ success: true, mode: 'clipboard', path: '/data/sidecars' });
const writeText = vi.fn().mockResolvedValue();
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
});
await manager.openSidecarStorageLocation();
expect(writeText).toHaveBeenCalledWith('/data/sidecars');
expect(showToast).toHaveBeenCalledWith(
'settings.sidecarStorage.openLocationCopied',
{ path: '/data/sidecars' },
'success'
);
});
});
describe('showSidecarMigrationResult', () => {
const appendResultModal = () => {
const modal = document.createElement('div');
modal.id = 'sidecarMigrationResultModal';
modal.innerHTML = `
<h2 data-role="title"></h2>
<p data-role="message"></p>
<p data-role="destination" style="display:none"></p>
<button data-action="open-sidecar-location" style="display:none"></button>
<button data-action="close-sidecar-result"></button>`;
document.body.appendChild(modal);
return modal;
};
it('renders counters and location, reloads only when closed', async () => {
const manager = createManager();
const modal = appendResultModal();
mockFetchOk({ success: true });
manager.showSidecarMigrationResult({
success: true,
direction: 'to_centralized',
moved: 12,
models_moved: 5,
skipped: 1,
conflicts: 2,
error_count: 0,
sidecar_root: '/data/sidecars',
});
expect(modal.classList.contains('show')).toBe(true);
expect(modal.querySelector('[data-role="message"]').textContent).toContain('12');
expect(modal.querySelector('[data-role="destination"]').textContent).toContain('/data/sidecars');
expect(modal.querySelector('[data-action="open-sidecar-location"]').style.display).not.toBe('none');
expect(resetAndReload).not.toHaveBeenCalled();
// "Open Folder" keeps the result modal open.
modal.querySelector('[data-action="open-sidecar-location"]').click();
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/sidecars/open-location',
{ method: 'POST' }
));
expect(modal.classList.contains('show')).toBe(true);
modal.querySelector('[data-action="close-sidecar-result"]').click();
expect(modal.classList.contains('show')).toBe(false);
expect(resetAndReload).toHaveBeenCalledWith(true);
});
it('hides the location row and open button when migrating back alongside', () => {
const manager = createManager();
const modal = appendResultModal();
manager.showSidecarMigrationResult({
success: true,
direction: 'to_alongside',
moved: 3,
models_moved: 3,
skipped: 0,
conflicts: 0,
error_count: 0,
sidecar_root: '/data/sidecars',
});
expect(modal.querySelector('[data-role="destination"]').style.display).toBe('none');
expect(modal.querySelector('[data-action="open-sidecar-location"]').style.display).toBe('none');
});
it('falls back to toast plus reload when the modal is absent', () => {
const manager = createManager();
manager.showSidecarMigrationResult({ success: true, direction: 'to_centralized' });
expect(showToast).toHaveBeenCalledWith('settings.sidecarStorage.migrateSuccess', {}, 'success');
expect(resetAndReload).toHaveBeenCalledWith(true);
});
});
});
@@ -31,6 +31,9 @@
'language': 'en',
'llm_api_key_set': False,
'other_models_paths_available': False,
'sidecar_storage_root': '/sidecars',
'sidecar_storage_root_in_repo': False,
'sidecar_storage_root_is_default': True,
'standalone_mode': False,
'theme': 'dark',
}),
+13 -1
View File
@@ -117,8 +117,20 @@ class TestSettingsHandlerSnapshots:
"""Snapshot tests for SettingsHandler responses."""
@pytest.mark.asyncio
async def test_get_settings_response_format(self, snapshot: SnapshotAssertion):
async def test_get_settings_response_format(
self, snapshot: SnapshotAssertion, monkeypatch: pytest.MonkeyPatch
):
"""Verify get_settings response format matches snapshot."""
# Pin the resolved sidecar root: it derives from the machine-specific
# settings directory, which would make the snapshot non-deterministic.
monkeypatch.setattr(
"py.routes.handlers.misc_handlers.describe_sidecar_root",
lambda: {
"root": "/sidecars",
"is_default": True,
"inside_repo": False,
},
)
settings_service = DummySettings({
"civitai_api_key": "test-key",
"language": "en",
+52
View File
@@ -533,6 +533,58 @@ async def test_open_backup_location_uses_settings_directory(tmp_path, monkeypatc
assert calls == [["xdg-open", str(backup_dir)]]
@pytest.mark.asyncio
async def test_open_sidecar_location_opens_configured_root(tmp_path, monkeypatch):
from py.services.settings_manager import get_settings_manager
root = tmp_path / "sidecars"
get_settings_manager().set("sidecar_storage_path", str(root))
handler = FileSystemHandler(settings_service=SimpleNamespace())
calls = []
def fake_popen(args):
calls.append(args)
return MagicMock()
monkeypatch.setattr(subprocess, "Popen", fake_popen)
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False)
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
response = await handler.open_sidecar_location(FakeRequest()) # pyright: ignore[reportArgumentType]
payload = _json_payload(response)
assert response.status == 200
assert payload["success"] is True
assert payload["path"] == str(root)
# Created on demand so the button works before any migration ran.
assert root.is_dir()
assert calls == [["xdg-open", str(root)]]
@pytest.mark.asyncio
async def test_get_settings_includes_resolved_sidecar_root(tmp_path):
from py.services.settings_manager import get_settings_manager
root = tmp_path / "sidecars-custom"
get_settings_manager().set("sidecar_storage_path", str(root))
handler = SettingsHandler(
settings_service=DummySettings(),
metadata_provider_updater=noop_async,
downloader_factory=dummy_downloader_factory,
)
response = await handler.get_settings(FakeRequest()) # pyright: ignore[reportArgumentType]
payload = _json_payload(response)
assert payload["success"] is True
assert payload["settings"]["sidecar_storage_root"] == str(root)
assert payload["settings"]["sidecar_storage_root_is_default"] is False
assert payload["settings"]["sidecar_storage_root_in_repo"] is False
@pytest.mark.asyncio
async def test_open_settings_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
"""Without a GUI session xdg-open cannot work; the handler must hand the
@@ -97,19 +97,29 @@ class _FakeCache:
class _FakeScanner:
def __init__(self, raw_data: List[Dict[str, Any]]) -> None:
def __init__(
self, raw_data: List[Dict[str, Any]], excluded: List[str] | None = None
) -> None:
self._cache = _FakeCache(raw_data)
self._excluded = list(excluded or [])
self.persist_calls = 0
async def get_cached_data(self) -> _FakeCache:
return self._cache
def get_excluded_models(self) -> List[str]:
return list(self._excluded)
async def _persist_current_cache(self) -> None:
self.persist_calls += 1
def _make_use_case(model_paths: List[str]) -> SidecarMigrationUseCase:
scanner = _FakeScanner([{"file_path": path} for path in model_paths])
def _make_use_case(
model_paths: List[str], excluded: List[str] | None = None
) -> SidecarMigrationUseCase:
scanner = _FakeScanner(
[{"file_path": path} for path in model_paths], excluded=excluded
)
async def scanner_factory() -> _FakeScanner:
return scanner
@@ -504,3 +514,40 @@ async def test_migrate_root_missing_old_tree_is_noop(
assert summary["success"] is True
assert summary["moved"] == 0
@pytest.mark.asyncio
async def test_migrate_covers_excluded_models(
library_root: Path, sidecar_root: Path
):
"""Excluded models are absent from the cache; their sidecars still move.
Otherwise un-excluding a model later would leave the scanner looking for
a sidecar in the new layout that was never migrated.
"""
_set_mode("centralized")
cached = _write_model(library_root, "cached")
_write_sidecar(library_root, "cached", cached)
(library_root / "cached.preview.webp").write_bytes(b"preview")
excluded = _write_model(library_root / "hidden", "excluded")
_write_sidecar(library_root / "hidden", "excluded", excluded, preview_ext=None)
(library_root / "hidden" / "excluded.preview.webp").write_bytes(b"preview")
use_case = _make_use_case([str(cached)], excluded=[str(excluded)])
summary = await use_case.migrate_to_centralized(force=True)
assert summary["success"] is True
assert summary["models_total"] == 2
assert summary["moved"] == 4
assert summary["sidecar_root"] == str(sidecar_root)
mirror = _mirror_dir(library_root, sidecar_root, "hidden")
assert (mirror / "excluded.metadata.json").exists()
assert (mirror / "excluded.preview.webp").exists()
assert not (library_root / "hidden" / "excluded.metadata.json").exists()
assert not (library_root / "hidden" / "excluded.preview.webp").exists()
# The excluded model is not in the cache, so cache reconciliation is a
# no-op for it and only the cached entry gets persisted.
assert use_case._test_scanner.persist_calls == 1
+36
View File
@@ -283,3 +283,39 @@ class TestSettingsValidation:
settings = get_settings_manager()
settings.set("sidecar_storage_path", None)
assert settings.get("sidecar_storage_path") == ""
class TestDescribeSidecarRoot:
def test_configured_root(self, tmp_path: Path):
settings = get_settings_manager()
root = tmp_path / "sidecars-custom"
settings.set("sidecar_storage_path", str(root))
info = sidecar_paths.describe_sidecar_root()
assert info["root"] == os.path.abspath(str(root))
assert info["is_default"] is False
assert info["inside_repo"] is False
def test_default_root_marks_is_default(self):
settings = get_settings_manager()
settings.set("sidecar_storage_path", "")
info = sidecar_paths.describe_sidecar_root()
assert info["root"].endswith(os.sep + "sidecars")
assert info["is_default"] is True
def test_inside_repo_detection(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
sidecar_paths, "_installation_root", lambda: str(tmp_path / "repo")
)
settings = get_settings_manager()
settings.set(
"sidecar_storage_path", str(tmp_path / "repo" / "sidecars")
)
assert sidecar_paths.describe_sidecar_root()["inside_repo"] is True
settings.set("sidecar_storage_path", str(tmp_path / "elsewhere"))
assert sidecar_paths.describe_sidecar_root()["inside_repo"] is False