diff --git a/docs/i18n-translation-guidelines.md b/docs/i18n-translation-guidelines.md index 76efce47..663560df 100644 --- a/docs/i18n-translation-guidelines.md +++ b/docs/i18n-translation-guidelines.md @@ -195,6 +195,14 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL). > root** noun while translating (`library root` → `model root`, R5); the count-bearing > `successMulti` uses the singular `success` key when only one copy was removed. +> **Status (2026-10, model-modal sidecar location):** the model modal's Location row gained +> an inline "open metadata location" icon button (rendered only in centralized sidecar +> storage mode), adding 5 keys (`modals.model.actions.openSidecarLocation` and the 4 +> `modals.model.openSidecarLocation.*` toasts). The renderings mirror each locale's sibling +> `openFileLocation` block verbatim with the §5 "metadata" noun swapped in, so no new +> terminology was introduced. All 9 locales are translated, so the "no remaining +> placeholders" claim holds again. + --- ## 1. Hard rules (do not violate) diff --git a/locales/de.json b/locales/de.json index fd34ccec..9aaafe7e 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "Auf Hugging Face ansehen", "viewCreatorProfile": "Ersteller-Profil anzeigen", "openFileLocation": "Dateispeicherort öffnen", + "openSidecarLocation": "Metadaten-Speicherort öffnen", "sendToWorkflow": "An ComfyUI senden", "sendToWorkflowText": "An ComfyUI senden", "copyHash": "Hash kopieren", @@ -1905,6 +1906,12 @@ "copied": "Pfad in die Zwischenablage kopiert: {{path}}", "clipboardFallback": "Pfad: {{path}}" }, + "openSidecarLocation": { + "success": "Metadaten-Speicherort erfolgreich geöffnet", + "failed": "Fehler beim Öffnen des Metadaten-Speicherorts", + "copied": "Pfad in die Zwischenablage kopiert: {{path}}", + "clipboardFallback": "Pfad: {{path}}" + }, "sendToWorkflow": { "noFilePath": "Kann nicht an ComfyUI senden: Kein Dateipfad verfügbar" }, diff --git a/locales/en.json b/locales/en.json index 0db5e294..ec26e99e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "View on Hugging Face", "viewCreatorProfile": "View Creator Profile", "openFileLocation": "Open File Location", + "openSidecarLocation": "Open metadata location", "sendToWorkflow": "Send to ComfyUI", "sendToWorkflowText": "Send to ComfyUI", "copyHash": "Copy hash", @@ -1905,6 +1906,12 @@ "copied": "Path copied to clipboard: {{path}}", "clipboardFallback": "Path: {{path}}" }, + "openSidecarLocation": { + "success": "Metadata location opened successfully", + "failed": "Failed to open metadata location", + "copied": "Path copied to clipboard: {{path}}", + "clipboardFallback": "Path: {{path}}" + }, "sendToWorkflow": { "noFilePath": "Unable to send to ComfyUI: No file path available" }, diff --git a/locales/es.json b/locales/es.json index b76ff5d9..35f1cbd6 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "Ver en Hugging Face", "viewCreatorProfile": "Ver perfil del creador", "openFileLocation": "Abrir ubicación del archivo", + "openSidecarLocation": "Abrir ubicación de los metadatos", "sendToWorkflow": "Enviar a ComfyUI", "sendToWorkflowText": "Enviar a ComfyUI", "copyHash": "Copiar hash", @@ -1905,6 +1906,12 @@ "copied": "Ruta copiada al portapapeles: {{path}}", "clipboardFallback": "Ruta: {{path}}" }, + "openSidecarLocation": { + "success": "Ubicación de los metadatos abierta exitosamente", + "failed": "Error al abrir la ubicación de los metadatos", + "copied": "Ruta copiada al portapapeles: {{path}}", + "clipboardFallback": "Ruta: {{path}}" + }, "sendToWorkflow": { "noFilePath": "No se puede enviar a ComfyUI: no hay ruta de archivo disponible" }, diff --git a/locales/fr.json b/locales/fr.json index 3842415d..66392892 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "Voir sur Hugging Face", "viewCreatorProfile": "Voir le profil du créateur", "openFileLocation": "Ouvrir l'emplacement du fichier", + "openSidecarLocation": "Ouvrir l'emplacement des métadonnées", "sendToWorkflow": "Envoyer vers ComfyUI", "sendToWorkflowText": "Envoyer vers ComfyUI", "copyHash": "Copier le hash", @@ -1905,6 +1906,12 @@ "copied": "Chemin copié dans le presse-papiers: {{path}}", "clipboardFallback": "Chemin: {{path}}" }, + "openSidecarLocation": { + "success": "Emplacement des métadonnées ouvert avec succès", + "failed": "Échec de l'ouverture de l'emplacement des métadonnées", + "copied": "Chemin copié dans le presse-papiers: {{path}}", + "clipboardFallback": "Chemin: {{path}}" + }, "sendToWorkflow": { "noFilePath": "Impossible d'envoyer vers ComfyUI : aucun chemin de fichier disponible" }, diff --git a/locales/he.json b/locales/he.json index aa752516..2713af84 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "צפייה ב-Hugging Face", "viewCreatorProfile": "הצג פרופיל יוצר", "openFileLocation": "פתח מיקום קובץ", + "openSidecarLocation": "פתח מיקום מטא-נתונים", "sendToWorkflow": "שלח ל-ComfyUI", "sendToWorkflowText": "שלח ל-ComfyUI", "copyHash": "העתק hash", @@ -1905,6 +1906,12 @@ "copied": "הנתיב הועתק ללוח העריכה: {{path}}", "clipboardFallback": "נתיב: {{path}}" }, + "openSidecarLocation": { + "success": "מיקום המטא-נתונים נפתח בהצלחה", + "failed": "פתיחת מיקום המטא-נתונים נכשלה", + "copied": "הנתיב הועתק ללוח העריכה: {{path}}", + "clipboardFallback": "נתיב: {{path}}" + }, "sendToWorkflow": { "noFilePath": "לא ניתן לשלוח ל-ComfyUI: אין נתיב קובץ זמין" }, diff --git a/locales/ja.json b/locales/ja.json index 806345e9..ae3f37c3 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "Hugging Face で見る", "viewCreatorProfile": "作成者プロフィールを表示", "openFileLocation": "ファイルの場所を開く", + "openSidecarLocation": "メタデータの場所を開く", "sendToWorkflow": "ComfyUI に送信", "sendToWorkflowText": "ComfyUI に送信", "copyHash": "ハッシュをコピー", @@ -1905,6 +1906,12 @@ "copied": "パスをクリップボードにコピーしました: {{path}}", "clipboardFallback": "パス: {{path}}" }, + "openSidecarLocation": { + "success": "メタデータの場所を正常に開きました", + "failed": "メタデータの場所を開くのに失敗しました", + "copied": "パスをクリップボードにコピーしました: {{path}}", + "clipboardFallback": "パス: {{path}}" + }, "sendToWorkflow": { "noFilePath": "ComfyUI に送信できません:ファイルパスがありません" }, diff --git a/locales/ko.json b/locales/ko.json index 944faf2a..e132b7dc 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "Hugging Face에서 보기", "viewCreatorProfile": "제작자 프로필 보기", "openFileLocation": "파일 위치 열기", + "openSidecarLocation": "메타데이터 위치 열기", "sendToWorkflow": "ComfyUI로 보내기", "sendToWorkflowText": "ComfyUI로 보내기", "copyHash": "해시 복사", @@ -1905,6 +1906,12 @@ "copied": "경로가 클립보드에 복사되었습니다: {{path}}", "clipboardFallback": "경로: {{path}}" }, + "openSidecarLocation": { + "success": "메타데이터 위치가 성공적으로 열렸습니다", + "failed": "메타데이터 위치 열기에 실패했습니다", + "copied": "경로가 클립보드에 복사되었습니다: {{path}}", + "clipboardFallback": "경로: {{path}}" + }, "sendToWorkflow": { "noFilePath": "ComfyUI로 보낼 수 없습니다: 파일 경로가 없습니다" }, diff --git a/locales/ru.json b/locales/ru.json index 8eaf2477..59856844 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "Открыть Hugging Face", "viewCreatorProfile": "Посмотреть профиль создателя", "openFileLocation": "Открыть расположение файла", + "openSidecarLocation": "Открыть расположение метаданных", "sendToWorkflow": "Отправить в ComfyUI", "sendToWorkflowText": "Отправить в ComfyUI", "copyHash": "Копировать хеш", @@ -1905,6 +1906,12 @@ "copied": "Путь скопирован в буфер обмена: {{path}}", "clipboardFallback": "Путь: {{path}}" }, + "openSidecarLocation": { + "success": "Расположение метаданных успешно открыто", + "failed": "Не удалось открыть расположение метаданных", + "copied": "Путь скопирован в буфер обмена: {{path}}", + "clipboardFallback": "Путь: {{path}}" + }, "sendToWorkflow": { "noFilePath": "Невозможно отправить в ComfyUI: путь к файлу недоступен" }, diff --git a/locales/zh-CN.json b/locales/zh-CN.json index dbea1cf1..1960b297 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "在 Hugging Face 查看", "viewCreatorProfile": "查看创作者主页", "openFileLocation": "打开文件位置", + "openSidecarLocation": "打开元数据位置", "sendToWorkflow": "发送到 ComfyUI", "sendToWorkflowText": "发送到 ComfyUI", "copyHash": "复制哈希值", @@ -1905,6 +1906,12 @@ "copied": "路径已复制到剪贴板:{{path}}", "clipboardFallback": "路径:{{path}}" }, + "openSidecarLocation": { + "success": "元数据位置已成功打开", + "failed": "打开元数据位置失败", + "copied": "路径已复制到剪贴板:{{path}}", + "clipboardFallback": "路径:{{path}}" + }, "sendToWorkflow": { "noFilePath": "无法发送到 ComfyUI:没有可用的文件路径" }, diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 043c2cf7..6f1a8f6e 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "在 Hugging Face 查看", "viewCreatorProfile": "查看創作者個人檔案", "openFileLocation": "開啟檔案位置", + "openSidecarLocation": "開啟中繼資料位置", "sendToWorkflow": "傳送到 ComfyUI", "sendToWorkflowText": "傳送到 ComfyUI", "copyHash": "複製雜湊值", @@ -1905,6 +1906,12 @@ "copied": "路徑已複製到剪貼簿:{{path}}", "clipboardFallback": "路徑:{{path}}" }, + "openSidecarLocation": { + "success": "中繼資料位置已成功開啟", + "failed": "開啟中繼資料位置失敗", + "copied": "路徑已複製到剪貼簿:{{path}}", + "clipboardFallback": "路徑:{{path}}" + }, "sendToWorkflow": { "noFilePath": "無法傳送到 ComfyUI:沒有可用的檔案路徑" }, diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index e226c1ee..72465e84 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -77,6 +77,7 @@ from ...utils.sidecar_paths import ( get_configured_sidecar_root, get_metadata_path, get_preview_dir, + get_sidecar_dir, get_storage_mode, get_unmatched_sidecar_components, ) @@ -3497,6 +3498,63 @@ class FileSystemHandler: {"success": True, "message": f"Opened folder: {path}", "path": path} ) + async def _reveal_file(self, file_path: str) -> web.Response: + """Open the platform file manager with ``file_path`` selected.""" + if os.name == "nt": + subprocess.Popen(["explorer", "/select,", file_path]) + elif os.name == "posix": + if _is_docker(): + return web.json_response( + { + "success": True, + "message": "Running in Docker: Path available for copying", + "path": file_path, + "mode": "clipboard", + } + ) + elif _is_wsl(): + windows_path = _wsl_to_windows_path(file_path) + if windows_path: + subprocess.Popen(["explorer.exe", "/select,", windows_path]) + else: + logger.error( + "Failed to convert WSL path to Windows path: %s", file_path + ) + return web.json_response( + { + "success": False, + "error": "Failed to open file location: path conversion error", + }, + status=500, + ) + elif sys.platform == "darwin": + subprocess.Popen(["open", "-R", file_path]) + else: + if not _has_gui_display(): + # Headless/SSH session: no file manager to open, so + # hand the path to the browser for copying instead + # of reporting a success that never happened. + return web.json_response( + { + "success": True, + "message": "Headless session: path available for copying", + "path": file_path, + "mode": "clipboard", + } + ) + selected = _select_in_linux_file_manager(file_path) + if not selected: + folder = os.path.dirname(file_path) + subprocess.Popen(["xdg-open", folder]) + + return web.json_response( + { + "success": True, + "message": f"Opened folder and selected file: {file_path}", + "path": file_path, + } + ) + async def open_file_location(self, request: web.Request) -> web.Response: try: data = await request.json() @@ -3512,63 +3570,49 @@ class FileSystemHandler: {"success": False, "error": "File does not exist"}, status=404 ) - if os.name == "nt": - subprocess.Popen(["explorer", "/select,", file_path]) - elif os.name == "posix": - if _is_docker(): - return web.json_response( - { - "success": True, - "message": "Running in Docker: Path available for copying", - "path": file_path, - "mode": "clipboard", - } - ) - elif _is_wsl(): - windows_path = _wsl_to_windows_path(file_path) - if windows_path: - subprocess.Popen(["explorer.exe", "/select,", windows_path]) - else: - logger.error( - "Failed to convert WSL path to Windows path: %s", file_path - ) - return web.json_response( - { - "success": False, - "error": "Failed to open file location: path conversion error", - }, - status=500, - ) - elif sys.platform == "darwin": - subprocess.Popen(["open", "-R", file_path]) - else: - if not _has_gui_display(): - # Headless/SSH session: no file manager to open, so - # hand the path to the browser for copying instead - # of reporting a success that never happened. - return web.json_response( - { - "success": True, - "message": "Headless session: path available for copying", - "path": file_path, - "mode": "clipboard", - } - ) - selected = _select_in_linux_file_manager(file_path) - if not selected: - folder = os.path.dirname(file_path) - subprocess.Popen(["xdg-open", folder]) - - return web.json_response( - { - "success": True, - "message": f"Opened folder and selected file: {file_path}", - } - ) + return await self._reveal_file(file_path) except Exception as exc: # pragma: no cover - defensive logging logger.error("Failed to open file location: %s", exc, exc_info=True) return web.json_response({"success": False, "error": str(exc)}, status=500) + async def open_model_sidecar_location(self, request: web.Request) -> web.Response: + """Reveal the model's ``.metadata.json`` sidecar in the file manager. + + Sidecar/preview path derivation must go through + :mod:`py.utils.sidecar_paths` so centralized storage mode resolves to + the mirror tree. When the sidecar has not been written yet, the + containing sidecar directory is opened instead. + """ + try: + data = await request.json() + file_path = data.get("file_path") + if not file_path: + return web.json_response( + {"success": False, "error": "Missing file_path parameter"}, + status=400, + ) + file_path = os.path.abspath(file_path) + if not os.path.isfile(file_path): + return web.json_response( + {"success": False, "error": "File does not exist"}, status=404 + ) + + metadata_path = get_metadata_path(file_path) + if os.path.isfile(metadata_path): + return await self._reveal_file(metadata_path) + + sidecar_dir = get_sidecar_dir(file_path) + if os.path.isdir(sidecar_dir): + return await self._open_path(sidecar_dir) + + return web.json_response( + {"success": False, "error": "Sidecar does not exist yet"}, + status=404, + ) + 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 open_settings_location(self, request: web.Request) -> web.Response: try: settings_file = getattr(self._settings, "settings_file", None) @@ -4454,6 +4498,7 @@ class MiscHandlerSet: "open_backup_location": self.filesystem.open_backup_location, "open_wildcards_location": self.filesystem.open_wildcards_location, "open_sidecar_location": self.filesystem.open_sidecar_location, + "open_model_sidecar_location": self.filesystem.open_model_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 73e5311d..d43eaf5e 100644 --- a/py/routes/misc_route_registrar.py +++ b/py/routes/misc_route_registrar.py @@ -125,6 +125,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition( "POST", "/api/lm/sidecars/open-location", "open_sidecar_location" ), + RouteDefinition( + "POST", "/api/lm/models/open-sidecar-location", "open_model_sidecar_location" + ), RouteDefinition( "POST", "/api/lm/download-model-source", "download_model_source" ), diff --git a/static/css/components/lora-modal/lora-modal.css b/static/css/components/lora-modal/lora-modal.css index 4604fea2..cc2b77d9 100644 --- a/static/css/components/lora-modal/lora-modal.css +++ b/static/css/components/lora-modal/lora-modal.css @@ -349,6 +349,34 @@ font-size: 0.9em; } +.file-path-wrapper { + display: flex; + align-items: baseline; + gap: 6px; +} + +.file-path-wrapper .file-path { + flex: 1; + min-width: 0; + word-break: break-all; +} + +.location-action-btn { + background: transparent; + border: none; + color: var(--lora-accent); + cursor: pointer; + padding: 2px 5px; + border-radius: var(--border-radius-xs); + flex-shrink: 0; + opacity: 0.7; +} + +.location-action-btn:hover { + opacity: 1; + background: rgba(66, 153, 225, 0.1); +} + .file-path[data-action="open-file-location"] { cursor: pointer; text-decoration: underline; diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js index 9e48ebea..2a306daa 100644 --- a/static/js/components/shared/ModelModal.js +++ b/static/js/components/shared/ModelModal.js @@ -365,6 +365,16 @@ export async function showModelModal(model, modelType) { }; const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || ''); const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A'); + // Centralized sidecar storage moves .metadata.json into a mirror tree, so + // the model path link can no longer reveal it — offer a dedicated entry + // only in that mode (alongside mode keeps sidecars next to the model). + const sidecarLocationButton = state.global.settings.sidecar_storage_mode === 'centralized' + ? `` + : ''; // De-emphasized hash display: a borderless full-width footnote line below // the info grid — sha256 middle-truncated (first 10 + last 6), autov3 in // full (12 chars); the full value is copied via data-hash. Civitai model / @@ -697,11 +707,14 @@ export async function showModelModal(model, modelType) {
- - ${escapedFolderPath} - +
+ + ${escapedFolderPath} + + ${sidecarLocationButton} +
@@ -1014,6 +1027,12 @@ function setupEventHandlers(filePath, modelType) { openFileLocation(filePath); } break; + case 'open-sidecar-location': + const sidecarModelPath = target.dataset.filepath || getModalFilePath(); + if (sidecarModelPath) { + openSidecarLocation(sidecarModelPath); + } + break; case 'nav-prev': handleDirectionalNavigation('prev', modelType); break; @@ -1412,6 +1431,37 @@ async function openFileLocation(filePath) { } } +/** + * Call backend to open the model's .metadata.json sidecar location + * @param {string} filePath model file path (backend derives the sidecar path) + */ +async function openSidecarLocation(filePath) { + try { + const resp = await fetch('/api/lm/models/open-sidecar-location', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ 'file_path': filePath }) + }); + if (!resp.ok) throw new Error('Failed to open sidecar location'); + + const data = await resp.json(); + + if (data.mode === 'clipboard' && data.path) { + try { + await navigator.clipboard.writeText(data.path); + showToast('modals.model.openSidecarLocation.copied', { path: data.path }, 'success'); + } catch (clipboardErr) { + console.warn('Clipboard API not available:', clipboardErr); + showToast('modals.model.openSidecarLocation.clipboardFallback', { path: data.path }, 'info'); + } + } else { + showToast('modals.model.openSidecarLocation.success', {}, 'success'); + } + } catch (err) { + showToast('modals.model.openSidecarLocation.failed', {}, 'error'); + } +} + async function handleSendToWorkflow(target, modelType) { const filePath = getModalFilePath(); if (!filePath) { diff --git a/tests/frontend/components/modelModal.sidecarLocation.test.js b/tests/frontend/components/modelModal.sidecarLocation.test.js new file mode 100644 index 00000000..12a6a37e --- /dev/null +++ b/tests/frontend/components/modelModal.sidecarLocation.test.js @@ -0,0 +1,215 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { + MODAL_MODULE, + API_FACTORY, + UI_HELPERS_MODULE, + MODAL_MANAGER_MODULE, + SHOWCASE_MODULE, + MODEL_TAGS_MODULE, + UTILS_MODULE, + TRIGGER_WORDS_MODULE, + PRESET_TAGS_MODULE, + MODEL_VERSIONS_MODULE, + RECIPE_TAB_MODULE, + I18N_HELPERS_MODULE, + STATE_MODULE, +} = vi.hoisted(() => ({ + MODAL_MODULE: new URL('../../../static/js/components/shared/ModelModal.js', import.meta.url).pathname, + API_FACTORY: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, + MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname, + SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname, + MODEL_TAGS_MODULE: new URL('../../../static/js/components/shared/ModelTags.js', import.meta.url).pathname, + UTILS_MODULE: new URL('../../../static/js/components/shared/utils.js', import.meta.url).pathname, + TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.js', import.meta.url).pathname, + PRESET_TAGS_MODULE: new URL('../../../static/js/components/shared/PresetTags.js', import.meta.url).pathname, + MODEL_VERSIONS_MODULE: new URL('../../../static/js/components/shared/ModelVersionsTab.js', import.meta.url).pathname, + RECIPE_TAB_MODULE: new URL('../../../static/js/components/shared/RecipeTab.js', import.meta.url).pathname, + I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, + STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname, +})); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: vi.fn(), + openCivitai: vi.fn(), + copyToClipboard: vi.fn(), +})); + +vi.mock(MODAL_MANAGER_MODULE, () => ({ + modalManager: { + showModal: vi.fn((id, html) => { + document.body.innerHTML = `
${html}
`; + }), + closeModal: vi.fn(), + }, +})); + +vi.mock(SHOWCASE_MODULE, () => ({ + scrollToTop: vi.fn(), + loadExampleImages: vi.fn(), +})); + +vi.mock(MODEL_TAGS_MODULE, () => ({ + setupTagEditMode: vi.fn(), +})); + +vi.mock(UTILS_MODULE, async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + renderCompactTags: vi.fn(() => ''), + setupTagTooltip: vi.fn(), + formatFileSize: vi.fn(() => '1 MB'), + }; +}); + +vi.mock(TRIGGER_WORDS_MODULE, () => ({ + renderTriggerWords: vi.fn(() => ''), + setupTriggerWordsEditMode: vi.fn(), +})); + +vi.mock(PRESET_TAGS_MODULE, () => ({ + parsePresets: vi.fn(() => ({})), + renderPresetTags: vi.fn(() => ''), +})); + +vi.mock(MODEL_VERSIONS_MODULE, () => ({ + initVersionsTab: vi.fn(() => ({ + load: vi.fn().mockResolvedValue(undefined), + })), +})); + +vi.mock(RECIPE_TAB_MODULE, () => ({ + loadRecipesForModel: vi.fn(), +})); + +vi.mock(I18N_HELPERS_MODULE, () => ({ + translate: vi.fn((_, __, fallback) => fallback || ''), +})); + +vi.mock('../../../static/js/api/apiConfig.js', () => ({ + MODEL_TYPES: { + LORA: 'loras', + CHECKPOINT: 'checkpoints', + EMBEDDING: 'embeddings' + } +})); + +vi.mock(API_FACTORY, () => ({ + getModelApiClient: vi.fn(), +})); + +const MODEL_PATH = '/data/loras/flux/model.safetensors'; + +function makeModel(overrides = {}) { + return { + model_name: 'Sidecar Model', + file_path: MODEL_PATH, + file_name: 'model.safetensors', + civitai: {}, + ...overrides, + }; +} + +describe('Model modal sidecar location entry', () => { + let getModelApiClient; + let showToast; + let state; + let originalMode; + + beforeEach(async () => { + document.body.innerHTML = ''; + ({ getModelApiClient } = await import(API_FACTORY)); + ({ showToast } = await import(UI_HELPERS_MODULE)); + ({ state } = await import(STATE_MODULE)); + getModelApiClient.mockReset(); + showToast.mockReset(); + getModelApiClient.mockReturnValue({ + fetchModelMetadata: vi.fn().mockResolvedValue(null), + saveModelMetadata: vi.fn(), + }); + originalMode = state.global.settings.sidecar_storage_mode; + global.fetch = vi.fn(); + }); + + afterEach(() => { + state.global.settings.sidecar_storage_mode = originalMode; + vi.restoreAllMocks(); + }); + + async function renderModal(model) { + const { showModelModal } = await import(MODAL_MODULE); + await showModelModal(model, 'loras'); + } + + it('renders the metadata location button only in centralized sidecar mode', async () => { + state.global.settings.sidecar_storage_mode = 'centralized'; + await renderModal(makeModel()); + + const button = document.querySelector('[data-action="open-sidecar-location"]'); + expect(button).not.toBeNull(); + expect(button.dataset.filepath).toBe(MODEL_PATH); + }); + + it.each(['alongside', '', undefined])('hides the button when sidecar mode is %s', async (mode) => { + state.global.settings.sidecar_storage_mode = mode; + await renderModal(makeModel()); + + expect(document.querySelector('[data-action="open-sidecar-location"]')).toBeNull(); + }); + + it('posts the model file path to the sidecar endpoint on click', async () => { + state.global.settings.sidecar_storage_mode = 'centralized'; + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + await renderModal(makeModel()); + + document.querySelector('[data-action="open-sidecar-location"]').click(); + await vi.waitFor(() => expect(showToast).toHaveBeenCalled()); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/lm/models/open-sidecar-location', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ file_path: MODEL_PATH }), + }) + ); + expect(showToast).toHaveBeenCalledWith('modals.model.openSidecarLocation.success', {}, 'success'); + }); + + it('copies the resolved sidecar path in clipboard mode', async () => { + state.global.settings.sidecar_storage_mode = 'centralized'; + const sidecarPath = '/sidecars/loras-abcd1234/flux/model.metadata.json'; + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true, mode: 'clipboard', path: sidecarPath }), + }); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + await renderModal(makeModel()); + + document.querySelector('[data-action="open-sidecar-location"]').click(); + await vi.waitFor(() => expect(showToast).toHaveBeenCalled()); + + expect(writeText).toHaveBeenCalledWith(sidecarPath); + expect(showToast).toHaveBeenCalledWith( + 'modals.model.openSidecarLocation.copied', + { path: sidecarPath }, + 'success' + ); + }); + + it('shows an error toast when the endpoint fails', async () => { + state.global.settings.sidecar_storage_mode = 'centralized'; + global.fetch.mockResolvedValue({ ok: false, status: 404 }); + await renderModal(makeModel()); + + document.querySelector('[data-action="open-sidecar-location"]').click(); + await vi.waitFor(() => expect(showToast).toHaveBeenCalled()); + + expect(showToast).toHaveBeenCalledWith('modals.model.openSidecarLocation.failed', {}, 'error'); + }); +}); diff --git a/tests/routes/test_misc_routes.py b/tests/routes/test_misc_routes.py index 9d02e7a6..488e9079 100644 --- a/tests/routes/test_misc_routes.py +++ b/tests/routes/test_misc_routes.py @@ -911,6 +911,151 @@ async def test_open_file_location_headless_returns_clipboard_mode(tmp_path, monk assert run_calls == [] +def _patch_linux_desktop(monkeypatch): + monkeypatch.setenv("DISPLAY", ":0") + monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False) + monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False) + + +@pytest.mark.asyncio +async def test_open_model_sidecar_location_reveals_metadata_file(tmp_path, monkeypatch): + """Centralized mode: the resolved .metadata.json is revealed selected, + even though it lives in the mirror tree rather than next to the model.""" + model_file = tmp_path / "model.safetensors" + model_file.write_text("x", encoding="utf-8") + metadata_file = tmp_path / "mirror" / "model.metadata.json" + metadata_file.parent.mkdir(parents=True) + metadata_file.write_text("{}", encoding="utf-8") + + handler = FileSystemHandler(settings_service=SimpleNamespace()) + _patch_linux_desktop(monkeypatch) + monkeypatch.setattr( + "py.routes.handlers.misc_handlers.get_metadata_path", + lambda path: str(metadata_file), + ) + + run_calls = [] + popen_calls = [] + monkeypatch.setattr( + subprocess, "run", lambda *args, **kwargs: run_calls.append(args[0]) or SimpleNamespace(returncode=0) + ) + monkeypatch.setattr(subprocess, "Popen", lambda *args, **kwargs: popen_calls.append(args)) + + request = FakeRequest(json_data={"file_path": str(model_file)}) + response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType] + payload = _json_payload(response) + + assert response.status == 200 + assert payload["success"] is True + assert payload["path"] == str(metadata_file) + assert len(run_calls) == 1 + assert any("model.metadata.json" in arg for arg in run_calls[0]) + assert popen_calls == [] + + +@pytest.mark.asyncio +async def test_open_model_sidecar_location_falls_back_to_sidecar_dir(tmp_path, monkeypatch): + """When the sidecar has not been written yet, open the sidecar directory + itself instead of reporting an error.""" + model_file = tmp_path / "model.safetensors" + model_file.write_text("x", encoding="utf-8") + sidecar_dir = tmp_path / "mirror" + sidecar_dir.mkdir() + + handler = FileSystemHandler(settings_service=SimpleNamespace()) + _patch_linux_desktop(monkeypatch) + monkeypatch.setattr( + "py.routes.handlers.misc_handlers.get_metadata_path", + lambda path: str(sidecar_dir / "model.metadata.json"), + ) + monkeypatch.setattr( + "py.routes.handlers.misc_handlers.get_sidecar_dir", + lambda path: str(sidecar_dir), + ) + + popen_calls = [] + monkeypatch.setattr( + subprocess, "Popen", lambda *args, **kwargs: popen_calls.append(args[0]) + ) + + request = FakeRequest(json_data={"file_path": str(model_file)}) + response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType] + payload = _json_payload(response) + + assert response.status == 200 + assert payload["success"] is True + assert payload["path"] == str(sidecar_dir) + assert popen_calls == [["xdg-open", str(sidecar_dir)]] + + +@pytest.mark.asyncio +async def test_open_model_sidecar_location_missing_sidecar_returns_404(tmp_path, monkeypatch): + model_file = tmp_path / "model.safetensors" + model_file.write_text("x", encoding="utf-8") + missing_dir = tmp_path / "missing" + + handler = FileSystemHandler(settings_service=SimpleNamespace()) + monkeypatch.setattr( + "py.routes.handlers.misc_handlers.get_metadata_path", + lambda path: str(missing_dir / "model.metadata.json"), + ) + monkeypatch.setattr( + "py.routes.handlers.misc_handlers.get_sidecar_dir", + lambda path: str(missing_dir), + ) + + request = FakeRequest(json_data={"file_path": str(model_file)}) + response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType] + payload = _json_payload(response) + + assert response.status == 404 + assert payload["success"] is False + + +@pytest.mark.asyncio +async def test_open_model_sidecar_location_headless_returns_clipboard_mode(tmp_path, monkeypatch): + model_file = tmp_path / "model.safetensors" + model_file.write_text("x", encoding="utf-8") + metadata_file = tmp_path / "mirror" / "model.metadata.json" + metadata_file.parent.mkdir(parents=True) + metadata_file.write_text("{}", encoding="utf-8") + + handler = FileSystemHandler(settings_service=SimpleNamespace()) + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False) + monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False) + monkeypatch.setattr( + "py.routes.handlers.misc_handlers.get_metadata_path", + lambda path: str(metadata_file), + ) + + popen_calls = [] + monkeypatch.setattr(subprocess, "Popen", lambda *args, **kwargs: popen_calls.append(args)) + + request = FakeRequest(json_data={"file_path": str(model_file)}) + response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType] + payload = _json_payload(response) + + assert response.status == 200 + assert payload["success"] is True + assert payload["mode"] == "clipboard" + assert payload["path"] == str(metadata_file) + assert popen_calls == [] + + +@pytest.mark.asyncio +async def test_open_model_sidecar_location_validates_input(tmp_path): + handler = FileSystemHandler(settings_service=SimpleNamespace()) + + response = await handler.open_model_sidecar_location(FakeRequest()) # pyright: ignore[reportArgumentType] + assert response.status == 400 + + request = FakeRequest(json_data={"file_path": str(tmp_path / "missing.safetensors")}) + response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType] + assert response.status == 404 + + class RecordingRouter: def __init__(self): self.calls = []