From 7aee9644481e25fb294edc663fa0fb9b8dab133f Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sat, 26 Sep 2026 23:08:29 +0800 Subject: [PATCH] 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 --- .gitignore | 3 + docs/metadata-json-schema.md | 3 +- locales/de.json | 18 +- locales/en.json | 18 +- locales/es.json | 18 +- locales/fr.json | 18 +- locales/he.json | 18 +- locales/ja.json | 18 +- locales/ko.json | 18 +- locales/ru.json | 18 +- locales/zh-CN.json | 18 +- locales/zh-TW.json | 18 +- py/routes/handlers/misc_handlers.py | 39 +++- py/routes/misc_route_registrar.py | 3 + .../use_cases/sidecar_migration_use_case.py | 26 ++- py/utils/sidecar_paths.py | 41 ++++ static/js/managers/SettingsManager.js | 187 +++++++++++++++++- .../components/modals/confirm_modals.html | 17 ++ .../components/modals/settings/library.html | 17 ++ .../settingsManager.sidecarStorage.test.js | 177 ++++++++++++++++- .../__snapshots__/test_api_snapshots.ambr | 3 + tests/routes/test_api_snapshots.py | 14 +- tests/routes/test_misc_routes.py | 52 +++++ .../test_sidecar_migration_use_case.py | 53 ++++- tests/utils/test_sidecar_paths.py | 36 ++++ 25 files changed, 817 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index beaa6cf9..14f9ca82 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ civitai/ stats/ wildcards/ backups/ +# Portable-mode centralized sidecar storage (/sidecars): user data that +# must survive pulls and stay out of git status +/sidecars/ logs/ node_modules/ coverage/ diff --git a/docs/metadata-json-schema.md b/docs/metadata-json-schema.md index cb98d24b..9f268f3e 100644 --- a/docs/metadata-json-schema.md +++ b/docs/metadata-json-schema.md @@ -28,8 +28,9 @@ In centralized mode, sidecars and previews mirror the library-relative directory - `` is the active library name and `` the model's directory relative to the model root containing the file. `` 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": ""}` 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. --- diff --git a/locales/de.json b/locales/de.json index 2d18358c..109164d2 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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", diff --git a/locales/en.json b/locales/en.json index fa297ad5..0cf091ef 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/es.json b/locales/es.json index 3b252774..c4401a92 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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", diff --git a/locales/fr.json b/locales/fr.json index 15cbc86f..c4fde150 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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", diff --git a/locales/he.json b/locales/he.json index 10c77b93..816f39e6 100644 --- a/locales/he.json +++ b/locales/he.json @@ -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": "הוסף תגיות למספר מודלים", diff --git a/locales/ja.json b/locales/ja.json index 2bfb4cf2..e6f6539f 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -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": "複数モデルにタグを追加", diff --git a/locales/ko.json b/locales/ko.json index 96c44d3f..91b2130d 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -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": "여러 모델에 태그 추가", diff --git a/locales/ru.json b/locales/ru.json index e4b27e07..fe7ec4e5 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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": "Добавить теги к нескольким моделям", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index f7454cd4..941a59ee 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -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": "批量添加标签", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 88a4da16..642f6a0b 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -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": "新增標籤到多個模型", diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index 27616aa4..190c3124 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -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, diff --git a/py/routes/misc_route_registrar.py b/py/routes/misc_route_registrar.py index ccfa440a..ef6c9e9d 100644 --- a/py/routes/misc_route_registrar.py +++ b/py/routes/misc_route_registrar.py @@ -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" ), diff --git a/py/services/use_cases/sidecar_migration_use_case.py b/py/services/use_cases/sidecar_migration_use_case.py index 04e78994..215dc42d 100644 --- a/py/services/use_cases/sidecar_migration_use_case.py +++ b/py/services/use_cases/sidecar_migration_use_case.py @@ -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( diff --git a/py/utils/sidecar_paths.py b/py/utils/sidecar_paths.py index ad03f6de..ff1628e7 100644 --- a/py/utils/sidecar_paths.py +++ b/py/utils/sidecar_paths.py @@ -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.""" diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 55e373a9..b2261f51 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -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 diff --git a/templates/components/modals/confirm_modals.html b/templates/components/modals/confirm_modals.html index 6ca24b9d..773e3fb2 100644 --- a/templates/components/modals/confirm_modals.html +++ b/templates/components/modals/confirm_modals.html @@ -103,6 +103,7 @@ + + + diff --git a/templates/components/modals/settings/library.html b/templates/components/modals/settings/library.html index aa5424ef..78a318ed 100644 --- a/templates/components/modals/settings/library.html +++ b/templates/components/modals/settings/library.html @@ -359,6 +359,23 @@ +
+
+
+ {{ t('settings.sidecarStorage.effectivePathLabel') }} + +
+ +
+
+ +
+
diff --git a/tests/frontend/managers/settingsManager.sidecarStorage.test.js b/tests/frontend/managers/settingsManager.sidecarStorage.test.js index 3f20d3f7..ba357751 100644 --- a/tests/frontend/managers/settingsManager.sidecarStorage.test.js +++ b/tests/frontend/managers/settingsManager.sidecarStorage.test.js @@ -116,6 +116,7 @@ const appendMigrationModal = () => { modal.innerHTML = `

+

`; 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 = ` +

+

+

+ + `; + 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); + }); + }); }); diff --git a/tests/routes/__snapshots__/test_api_snapshots.ambr b/tests/routes/__snapshots__/test_api_snapshots.ambr index c2bab914..3a77ede1 100644 --- a/tests/routes/__snapshots__/test_api_snapshots.ambr +++ b/tests/routes/__snapshots__/test_api_snapshots.ambr @@ -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', }), diff --git a/tests/routes/test_api_snapshots.py b/tests/routes/test_api_snapshots.py index 1e400d04..d74f1ba1 100644 --- a/tests/routes/test_api_snapshots.py +++ b/tests/routes/test_api_snapshots.py @@ -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", diff --git a/tests/routes/test_misc_routes.py b/tests/routes/test_misc_routes.py index e693be48..d11bf226 100644 --- a/tests/routes/test_misc_routes.py +++ b/tests/routes/test_misc_routes.py @@ -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 diff --git a/tests/services/use_cases/test_sidecar_migration_use_case.py b/tests/services/use_cases/test_sidecar_migration_use_case.py index 7db41409..007a87d0 100644 --- a/tests/services/use_cases/test_sidecar_migration_use_case.py +++ b/tests/services/use_cases/test_sidecar_migration_use_case.py @@ -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 diff --git a/tests/utils/test_sidecar_paths.py b/tests/utils/test_sidecar_paths.py index a5a4f6dd..18e301bb 100644 --- a/tests/utils/test_sidecar_paths.py +++ b/tests/utils/test_sidecar_paths.py @@ -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