mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-10 03:22:12 -03:00
feat(modal): open model metadata sidecar location from the model modal
Centralized sidecar storage moves .metadata.json into a mirror tree, so the Location link in the model modal could no longer reveal a model's sidecar. The Location row now shows an inline metadata icon button (centralized mode only) that calls a new POST /api/lm/models/open-sidecar-location endpoint: the backend resolves the sidecar through py/utils/sidecar_paths, reveals the .metadata.json selected in the file manager (falling back to the sidecar directory when it has not been written yet), and returns the path for clipboard mode on headless/Docker sessions. The platform reveal logic is factored into a shared FileSystemHandler._reveal_file helper. All 9 locales translated.
This commit is contained in:
@@ -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
|
> root** noun while translating (`library root` → `model root`, R5); the count-bearing
|
||||||
> `successMulti` uses the singular `success` key when only one copy was removed.
|
> `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)
|
## 1. Hard rules (do not violate)
|
||||||
|
|||||||
@@ -1892,6 +1892,7 @@
|
|||||||
"viewOnHuggingFaceText": "Auf Hugging Face ansehen",
|
"viewOnHuggingFaceText": "Auf Hugging Face ansehen",
|
||||||
"viewCreatorProfile": "Ersteller-Profil anzeigen",
|
"viewCreatorProfile": "Ersteller-Profil anzeigen",
|
||||||
"openFileLocation": "Dateispeicherort öffnen",
|
"openFileLocation": "Dateispeicherort öffnen",
|
||||||
|
"openSidecarLocation": "Metadaten-Speicherort öffnen",
|
||||||
"sendToWorkflow": "An ComfyUI senden",
|
"sendToWorkflow": "An ComfyUI senden",
|
||||||
"sendToWorkflowText": "An ComfyUI senden",
|
"sendToWorkflowText": "An ComfyUI senden",
|
||||||
"copyHash": "Hash kopieren",
|
"copyHash": "Hash kopieren",
|
||||||
@@ -1905,6 +1906,12 @@
|
|||||||
"copied": "Pfad in die Zwischenablage kopiert: {{path}}",
|
"copied": "Pfad in die Zwischenablage kopiert: {{path}}",
|
||||||
"clipboardFallback": "Pfad: {{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": {
|
"sendToWorkflow": {
|
||||||
"noFilePath": "Kann nicht an ComfyUI senden: Kein Dateipfad verfügbar"
|
"noFilePath": "Kann nicht an ComfyUI senden: Kein Dateipfad verfügbar"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1892,6 +1892,7 @@
|
|||||||
"viewOnHuggingFaceText": "View on Hugging Face",
|
"viewOnHuggingFaceText": "View on Hugging Face",
|
||||||
"viewCreatorProfile": "View Creator Profile",
|
"viewCreatorProfile": "View Creator Profile",
|
||||||
"openFileLocation": "Open File Location",
|
"openFileLocation": "Open File Location",
|
||||||
|
"openSidecarLocation": "Open metadata location",
|
||||||
"sendToWorkflow": "Send to ComfyUI",
|
"sendToWorkflow": "Send to ComfyUI",
|
||||||
"sendToWorkflowText": "Send to ComfyUI",
|
"sendToWorkflowText": "Send to ComfyUI",
|
||||||
"copyHash": "Copy hash",
|
"copyHash": "Copy hash",
|
||||||
@@ -1905,6 +1906,12 @@
|
|||||||
"copied": "Path copied to clipboard: {{path}}",
|
"copied": "Path copied to clipboard: {{path}}",
|
||||||
"clipboardFallback": "Path: {{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": {
|
"sendToWorkflow": {
|
||||||
"noFilePath": "Unable to send to ComfyUI: No file path available"
|
"noFilePath": "Unable to send to ComfyUI: No file path available"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1892,6 +1892,7 @@
|
|||||||
"viewOnHuggingFaceText": "Ver en Hugging Face",
|
"viewOnHuggingFaceText": "Ver en Hugging Face",
|
||||||
"viewCreatorProfile": "Ver perfil del creador",
|
"viewCreatorProfile": "Ver perfil del creador",
|
||||||
"openFileLocation": "Abrir ubicación del archivo",
|
"openFileLocation": "Abrir ubicación del archivo",
|
||||||
|
"openSidecarLocation": "Abrir ubicación de los metadatos",
|
||||||
"sendToWorkflow": "Enviar a ComfyUI",
|
"sendToWorkflow": "Enviar a ComfyUI",
|
||||||
"sendToWorkflowText": "Enviar a ComfyUI",
|
"sendToWorkflowText": "Enviar a ComfyUI",
|
||||||
"copyHash": "Copiar hash",
|
"copyHash": "Copiar hash",
|
||||||
@@ -1905,6 +1906,12 @@
|
|||||||
"copied": "Ruta copiada al portapapeles: {{path}}",
|
"copied": "Ruta copiada al portapapeles: {{path}}",
|
||||||
"clipboardFallback": "Ruta: {{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": {
|
"sendToWorkflow": {
|
||||||
"noFilePath": "No se puede enviar a ComfyUI: no hay ruta de archivo disponible"
|
"noFilePath": "No se puede enviar a ComfyUI: no hay ruta de archivo disponible"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1892,6 +1892,7 @@
|
|||||||
"viewOnHuggingFaceText": "Voir sur Hugging Face",
|
"viewOnHuggingFaceText": "Voir sur Hugging Face",
|
||||||
"viewCreatorProfile": "Voir le profil du créateur",
|
"viewCreatorProfile": "Voir le profil du créateur",
|
||||||
"openFileLocation": "Ouvrir l'emplacement du fichier",
|
"openFileLocation": "Ouvrir l'emplacement du fichier",
|
||||||
|
"openSidecarLocation": "Ouvrir l'emplacement des métadonnées",
|
||||||
"sendToWorkflow": "Envoyer vers ComfyUI",
|
"sendToWorkflow": "Envoyer vers ComfyUI",
|
||||||
"sendToWorkflowText": "Envoyer vers ComfyUI",
|
"sendToWorkflowText": "Envoyer vers ComfyUI",
|
||||||
"copyHash": "Copier le hash",
|
"copyHash": "Copier le hash",
|
||||||
@@ -1905,6 +1906,12 @@
|
|||||||
"copied": "Chemin copié dans le presse-papiers: {{path}}",
|
"copied": "Chemin copié dans le presse-papiers: {{path}}",
|
||||||
"clipboardFallback": "Chemin: {{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": {
|
"sendToWorkflow": {
|
||||||
"noFilePath": "Impossible d'envoyer vers ComfyUI : aucun chemin de fichier disponible"
|
"noFilePath": "Impossible d'envoyer vers ComfyUI : aucun chemin de fichier disponible"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1892,6 +1892,7 @@
|
|||||||
"viewOnHuggingFaceText": "צפייה ב-Hugging Face",
|
"viewOnHuggingFaceText": "צפייה ב-Hugging Face",
|
||||||
"viewCreatorProfile": "הצג פרופיל יוצר",
|
"viewCreatorProfile": "הצג פרופיל יוצר",
|
||||||
"openFileLocation": "פתח מיקום קובץ",
|
"openFileLocation": "פתח מיקום קובץ",
|
||||||
|
"openSidecarLocation": "פתח מיקום מטא-נתונים",
|
||||||
"sendToWorkflow": "שלח ל-ComfyUI",
|
"sendToWorkflow": "שלח ל-ComfyUI",
|
||||||
"sendToWorkflowText": "שלח ל-ComfyUI",
|
"sendToWorkflowText": "שלח ל-ComfyUI",
|
||||||
"copyHash": "העתק hash",
|
"copyHash": "העתק hash",
|
||||||
@@ -1905,6 +1906,12 @@
|
|||||||
"copied": "הנתיב הועתק ללוח העריכה: {{path}}",
|
"copied": "הנתיב הועתק ללוח העריכה: {{path}}",
|
||||||
"clipboardFallback": "נתיב: {{path}}"
|
"clipboardFallback": "נתיב: {{path}}"
|
||||||
},
|
},
|
||||||
|
"openSidecarLocation": {
|
||||||
|
"success": "מיקום המטא-נתונים נפתח בהצלחה",
|
||||||
|
"failed": "פתיחת מיקום המטא-נתונים נכשלה",
|
||||||
|
"copied": "הנתיב הועתק ללוח העריכה: {{path}}",
|
||||||
|
"clipboardFallback": "נתיב: {{path}}"
|
||||||
|
},
|
||||||
"sendToWorkflow": {
|
"sendToWorkflow": {
|
||||||
"noFilePath": "לא ניתן לשלוח ל-ComfyUI: אין נתיב קובץ זמין"
|
"noFilePath": "לא ניתן לשלוח ל-ComfyUI: אין נתיב קובץ זמין"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1892,6 +1892,7 @@
|
|||||||
"viewOnHuggingFaceText": "Hugging Face で見る",
|
"viewOnHuggingFaceText": "Hugging Face で見る",
|
||||||
"viewCreatorProfile": "作成者プロフィールを表示",
|
"viewCreatorProfile": "作成者プロフィールを表示",
|
||||||
"openFileLocation": "ファイルの場所を開く",
|
"openFileLocation": "ファイルの場所を開く",
|
||||||
|
"openSidecarLocation": "メタデータの場所を開く",
|
||||||
"sendToWorkflow": "ComfyUI に送信",
|
"sendToWorkflow": "ComfyUI に送信",
|
||||||
"sendToWorkflowText": "ComfyUI に送信",
|
"sendToWorkflowText": "ComfyUI に送信",
|
||||||
"copyHash": "ハッシュをコピー",
|
"copyHash": "ハッシュをコピー",
|
||||||
@@ -1905,6 +1906,12 @@
|
|||||||
"copied": "パスをクリップボードにコピーしました: {{path}}",
|
"copied": "パスをクリップボードにコピーしました: {{path}}",
|
||||||
"clipboardFallback": "パス: {{path}}"
|
"clipboardFallback": "パス: {{path}}"
|
||||||
},
|
},
|
||||||
|
"openSidecarLocation": {
|
||||||
|
"success": "メタデータの場所を正常に開きました",
|
||||||
|
"failed": "メタデータの場所を開くのに失敗しました",
|
||||||
|
"copied": "パスをクリップボードにコピーしました: {{path}}",
|
||||||
|
"clipboardFallback": "パス: {{path}}"
|
||||||
|
},
|
||||||
"sendToWorkflow": {
|
"sendToWorkflow": {
|
||||||
"noFilePath": "ComfyUI に送信できません:ファイルパスがありません"
|
"noFilePath": "ComfyUI に送信できません:ファイルパスがありません"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1892,6 +1892,7 @@
|
|||||||
"viewOnHuggingFaceText": "Hugging Face에서 보기",
|
"viewOnHuggingFaceText": "Hugging Face에서 보기",
|
||||||
"viewCreatorProfile": "제작자 프로필 보기",
|
"viewCreatorProfile": "제작자 프로필 보기",
|
||||||
"openFileLocation": "파일 위치 열기",
|
"openFileLocation": "파일 위치 열기",
|
||||||
|
"openSidecarLocation": "메타데이터 위치 열기",
|
||||||
"sendToWorkflow": "ComfyUI로 보내기",
|
"sendToWorkflow": "ComfyUI로 보내기",
|
||||||
"sendToWorkflowText": "ComfyUI로 보내기",
|
"sendToWorkflowText": "ComfyUI로 보내기",
|
||||||
"copyHash": "해시 복사",
|
"copyHash": "해시 복사",
|
||||||
@@ -1905,6 +1906,12 @@
|
|||||||
"copied": "경로가 클립보드에 복사되었습니다: {{path}}",
|
"copied": "경로가 클립보드에 복사되었습니다: {{path}}",
|
||||||
"clipboardFallback": "경로: {{path}}"
|
"clipboardFallback": "경로: {{path}}"
|
||||||
},
|
},
|
||||||
|
"openSidecarLocation": {
|
||||||
|
"success": "메타데이터 위치가 성공적으로 열렸습니다",
|
||||||
|
"failed": "메타데이터 위치 열기에 실패했습니다",
|
||||||
|
"copied": "경로가 클립보드에 복사되었습니다: {{path}}",
|
||||||
|
"clipboardFallback": "경로: {{path}}"
|
||||||
|
},
|
||||||
"sendToWorkflow": {
|
"sendToWorkflow": {
|
||||||
"noFilePath": "ComfyUI로 보낼 수 없습니다: 파일 경로가 없습니다"
|
"noFilePath": "ComfyUI로 보낼 수 없습니다: 파일 경로가 없습니다"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1892,6 +1892,7 @@
|
|||||||
"viewOnHuggingFaceText": "Открыть Hugging Face",
|
"viewOnHuggingFaceText": "Открыть Hugging Face",
|
||||||
"viewCreatorProfile": "Посмотреть профиль создателя",
|
"viewCreatorProfile": "Посмотреть профиль создателя",
|
||||||
"openFileLocation": "Открыть расположение файла",
|
"openFileLocation": "Открыть расположение файла",
|
||||||
|
"openSidecarLocation": "Открыть расположение метаданных",
|
||||||
"sendToWorkflow": "Отправить в ComfyUI",
|
"sendToWorkflow": "Отправить в ComfyUI",
|
||||||
"sendToWorkflowText": "Отправить в ComfyUI",
|
"sendToWorkflowText": "Отправить в ComfyUI",
|
||||||
"copyHash": "Копировать хеш",
|
"copyHash": "Копировать хеш",
|
||||||
@@ -1905,6 +1906,12 @@
|
|||||||
"copied": "Путь скопирован в буфер обмена: {{path}}",
|
"copied": "Путь скопирован в буфер обмена: {{path}}",
|
||||||
"clipboardFallback": "Путь: {{path}}"
|
"clipboardFallback": "Путь: {{path}}"
|
||||||
},
|
},
|
||||||
|
"openSidecarLocation": {
|
||||||
|
"success": "Расположение метаданных успешно открыто",
|
||||||
|
"failed": "Не удалось открыть расположение метаданных",
|
||||||
|
"copied": "Путь скопирован в буфер обмена: {{path}}",
|
||||||
|
"clipboardFallback": "Путь: {{path}}"
|
||||||
|
},
|
||||||
"sendToWorkflow": {
|
"sendToWorkflow": {
|
||||||
"noFilePath": "Невозможно отправить в ComfyUI: путь к файлу недоступен"
|
"noFilePath": "Невозможно отправить в ComfyUI: путь к файлу недоступен"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1892,6 +1892,7 @@
|
|||||||
"viewOnHuggingFaceText": "在 Hugging Face 查看",
|
"viewOnHuggingFaceText": "在 Hugging Face 查看",
|
||||||
"viewCreatorProfile": "查看创作者主页",
|
"viewCreatorProfile": "查看创作者主页",
|
||||||
"openFileLocation": "打开文件位置",
|
"openFileLocation": "打开文件位置",
|
||||||
|
"openSidecarLocation": "打开元数据位置",
|
||||||
"sendToWorkflow": "发送到 ComfyUI",
|
"sendToWorkflow": "发送到 ComfyUI",
|
||||||
"sendToWorkflowText": "发送到 ComfyUI",
|
"sendToWorkflowText": "发送到 ComfyUI",
|
||||||
"copyHash": "复制哈希值",
|
"copyHash": "复制哈希值",
|
||||||
@@ -1905,6 +1906,12 @@
|
|||||||
"copied": "路径已复制到剪贴板:{{path}}",
|
"copied": "路径已复制到剪贴板:{{path}}",
|
||||||
"clipboardFallback": "路径:{{path}}"
|
"clipboardFallback": "路径:{{path}}"
|
||||||
},
|
},
|
||||||
|
"openSidecarLocation": {
|
||||||
|
"success": "元数据位置已成功打开",
|
||||||
|
"failed": "打开元数据位置失败",
|
||||||
|
"copied": "路径已复制到剪贴板:{{path}}",
|
||||||
|
"clipboardFallback": "路径:{{path}}"
|
||||||
|
},
|
||||||
"sendToWorkflow": {
|
"sendToWorkflow": {
|
||||||
"noFilePath": "无法发送到 ComfyUI:没有可用的文件路径"
|
"noFilePath": "无法发送到 ComfyUI:没有可用的文件路径"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1892,6 +1892,7 @@
|
|||||||
"viewOnHuggingFaceText": "在 Hugging Face 查看",
|
"viewOnHuggingFaceText": "在 Hugging Face 查看",
|
||||||
"viewCreatorProfile": "查看創作者個人檔案",
|
"viewCreatorProfile": "查看創作者個人檔案",
|
||||||
"openFileLocation": "開啟檔案位置",
|
"openFileLocation": "開啟檔案位置",
|
||||||
|
"openSidecarLocation": "開啟中繼資料位置",
|
||||||
"sendToWorkflow": "傳送到 ComfyUI",
|
"sendToWorkflow": "傳送到 ComfyUI",
|
||||||
"sendToWorkflowText": "傳送到 ComfyUI",
|
"sendToWorkflowText": "傳送到 ComfyUI",
|
||||||
"copyHash": "複製雜湊值",
|
"copyHash": "複製雜湊值",
|
||||||
@@ -1905,6 +1906,12 @@
|
|||||||
"copied": "路徑已複製到剪貼簿:{{path}}",
|
"copied": "路徑已複製到剪貼簿:{{path}}",
|
||||||
"clipboardFallback": "路徑:{{path}}"
|
"clipboardFallback": "路徑:{{path}}"
|
||||||
},
|
},
|
||||||
|
"openSidecarLocation": {
|
||||||
|
"success": "中繼資料位置已成功開啟",
|
||||||
|
"failed": "開啟中繼資料位置失敗",
|
||||||
|
"copied": "路徑已複製到剪貼簿:{{path}}",
|
||||||
|
"clipboardFallback": "路徑:{{path}}"
|
||||||
|
},
|
||||||
"sendToWorkflow": {
|
"sendToWorkflow": {
|
||||||
"noFilePath": "無法傳送到 ComfyUI:沒有可用的檔案路徑"
|
"noFilePath": "無法傳送到 ComfyUI:沒有可用的檔案路徑"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ from ...utils.sidecar_paths import (
|
|||||||
get_configured_sidecar_root,
|
get_configured_sidecar_root,
|
||||||
get_metadata_path,
|
get_metadata_path,
|
||||||
get_preview_dir,
|
get_preview_dir,
|
||||||
|
get_sidecar_dir,
|
||||||
get_storage_mode,
|
get_storage_mode,
|
||||||
get_unmatched_sidecar_components,
|
get_unmatched_sidecar_components,
|
||||||
)
|
)
|
||||||
@@ -3497,21 +3498,8 @@ class FileSystemHandler:
|
|||||||
{"success": True, "message": f"Opened folder: {path}", "path": path}
|
{"success": True, "message": f"Opened folder: {path}", "path": path}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def open_file_location(self, request: web.Request) -> web.Response:
|
async def _reveal_file(self, file_path: str) -> web.Response:
|
||||||
try:
|
"""Open the platform file manager with ``file_path`` selected."""
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
subprocess.Popen(["explorer", "/select,", file_path])
|
subprocess.Popen(["explorer", "/select,", file_path])
|
||||||
elif os.name == "posix":
|
elif os.name == "posix":
|
||||||
@@ -3563,12 +3551,68 @@ class FileSystemHandler:
|
|||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Opened folder and selected file: {file_path}",
|
"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()
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self._reveal_file(file_path)
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Failed to open file location: %s", exc, exc_info=True)
|
logger.error("Failed to open file location: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
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:
|
async def open_settings_location(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
settings_file = getattr(self._settings, "settings_file", None)
|
settings_file = getattr(self._settings, "settings_file", None)
|
||||||
@@ -4454,6 +4498,7 @@ class MiscHandlerSet:
|
|||||||
"open_backup_location": self.filesystem.open_backup_location,
|
"open_backup_location": self.filesystem.open_backup_location,
|
||||||
"open_wildcards_location": self.filesystem.open_wildcards_location,
|
"open_wildcards_location": self.filesystem.open_wildcards_location,
|
||||||
"open_sidecar_location": self.filesystem.open_sidecar_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,
|
"browse_directory": self.filesystem.browse_directory,
|
||||||
"validate_path": self.filesystem.validate_path,
|
"validate_path": self.filesystem.validate_path,
|
||||||
"search_custom_words": self.custom_words.search_custom_words,
|
"search_custom_words": self.custom_words.search_custom_words,
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"POST", "/api/lm/sidecars/open-location", "open_sidecar_location"
|
"POST", "/api/lm/sidecars/open-location", "open_sidecar_location"
|
||||||
),
|
),
|
||||||
|
RouteDefinition(
|
||||||
|
"POST", "/api/lm/models/open-sidecar-location", "open_model_sidecar_location"
|
||||||
|
),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"POST", "/api/lm/download-model-source", "download_model_source"
|
"POST", "/api/lm/download-model-source", "download_model_source"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -349,6 +349,34 @@
|
|||||||
font-size: 0.9em;
|
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"] {
|
.file-path[data-action="open-file-location"] {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|||||||
@@ -365,6 +365,16 @@ export async function showModelModal(model, modelType) {
|
|||||||
};
|
};
|
||||||
const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || '');
|
const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || '');
|
||||||
const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A');
|
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'
|
||||||
|
? `<button class="location-action-btn" title="${translate('modals.model.actions.openSidecarLocation', {}, 'Open metadata location')}"
|
||||||
|
data-action="open-sidecar-location"
|
||||||
|
data-filepath="${escapedFilePathAttr}">
|
||||||
|
<i class="fas fa-file-alt"></i>
|
||||||
|
</button>`
|
||||||
|
: '';
|
||||||
// De-emphasized hash display: a borderless full-width footnote line below
|
// De-emphasized hash display: a borderless full-width footnote line below
|
||||||
// the info grid — sha256 middle-truncated (first 10 + last 6), autov3 in
|
// 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 /
|
// full (12 chars); the full value is copied via data-hash. Civitai model /
|
||||||
@@ -697,11 +707,14 @@ export async function showModelModal(model, modelType) {
|
|||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<div class="location-wrapper">
|
<div class="location-wrapper">
|
||||||
<label>${translate('modals.model.metadata.location', {}, 'Location')}</label>
|
<label>${translate('modals.model.metadata.location', {}, 'Location')}</label>
|
||||||
|
<div class="file-path-wrapper">
|
||||||
<span class="file-path" title="${translate('modals.model.actions.openFileLocation', {}, 'Open file location')}"
|
<span class="file-path" title="${translate('modals.model.actions.openFileLocation', {}, 'Open file location')}"
|
||||||
data-action="open-file-location"
|
data-action="open-file-location"
|
||||||
data-filepath="${escapedFilePathAttr}">
|
data-filepath="${escapedFilePathAttr}">
|
||||||
${escapedFolderPath}
|
${escapedFolderPath}
|
||||||
</span>
|
</span>
|
||||||
|
${sidecarLocationButton}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item base-size">
|
<div class="info-item base-size">
|
||||||
@@ -1014,6 +1027,12 @@ function setupEventHandlers(filePath, modelType) {
|
|||||||
openFileLocation(filePath);
|
openFileLocation(filePath);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'open-sidecar-location':
|
||||||
|
const sidecarModelPath = target.dataset.filepath || getModalFilePath();
|
||||||
|
if (sidecarModelPath) {
|
||||||
|
openSidecarLocation(sidecarModelPath);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'nav-prev':
|
case 'nav-prev':
|
||||||
handleDirectionalNavigation('prev', modelType);
|
handleDirectionalNavigation('prev', modelType);
|
||||||
break;
|
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) {
|
async function handleSendToWorkflow(target, modelType) {
|
||||||
const filePath = getModalFilePath();
|
const filePath = getModalFilePath();
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
|
|||||||
@@ -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 = `<div id="${id}">${html}</div>`;
|
||||||
|
}),
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -911,6 +911,151 @@ async def test_open_file_location_headless_returns_clipboard_mode(tmp_path, monk
|
|||||||
assert run_calls == []
|
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:
|
class RecordingRouter:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
|||||||
Reference in New Issue
Block a user