mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
feat(settings): directory picker and live validation for path settings
Add a reusable directory-picker modal backed by a new generic POST /api/lm/browse-directory endpoint (browse logic extracted from the recipe batch-import handler into py/utils/directory_browser.py) and wire a browse button plus advisory validate-path feedback (POST /api/lm/validate-path) into the settings path inputs: recipes path, example images path/local root, and the extra-folder/model-path rows. The browse button insets into the right edge of static inputs so narrow settings rows keep their single-control layout. Translations for the new settings.directoryPicker and settings.pathValidation keys are filled in for all 9 locales.
This commit is contained in:
@@ -608,6 +608,22 @@
|
|||||||
"controlnet": "ControlNet-Pfade"
|
"controlnet": "ControlNet-Pfade"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "Ordner durchsuchen",
|
||||||
|
"selectFolder": "Diesen Ordner auswählen",
|
||||||
|
"goUp": "Nach oben",
|
||||||
|
"pathPlaceholder": "Pfad eingeben...",
|
||||||
|
"go": "Los",
|
||||||
|
"emptyFolder": "Keine Unterordner",
|
||||||
|
"loadError": "Verzeichnis konnte nicht geladen werden"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "Pfad ist gültig",
|
||||||
|
"pathNotFound": "Pfad existiert nicht",
|
||||||
|
"notADirectory": "Kein Verzeichnis",
|
||||||
|
"notReadable": "Pfad ist nicht lesbar",
|
||||||
|
"notWritable": "Pfad ist nicht beschreibbar"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "Prioritäts-Tags",
|
"title": "Prioritäts-Tags",
|
||||||
"description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))",
|
"description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))",
|
||||||
|
|||||||
@@ -608,6 +608,22 @@
|
|||||||
"controlnet": "ControlNet Paths"
|
"controlnet": "ControlNet Paths"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "Browse Folders",
|
||||||
|
"selectFolder": "Select This Folder",
|
||||||
|
"goUp": "Up",
|
||||||
|
"pathPlaceholder": "Enter path...",
|
||||||
|
"go": "Go",
|
||||||
|
"emptyFolder": "No subfolders",
|
||||||
|
"loadError": "Failed to load directory"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "Path is valid",
|
||||||
|
"pathNotFound": "Path does not exist",
|
||||||
|
"notADirectory": "Not a directory",
|
||||||
|
"notReadable": "Path is not readable",
|
||||||
|
"notWritable": "Path is not writable"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "Priority Tags",
|
"title": "Priority Tags",
|
||||||
"description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))",
|
"description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))",
|
||||||
|
|||||||
@@ -608,6 +608,22 @@
|
|||||||
"controlnet": "Rutas de ControlNet"
|
"controlnet": "Rutas de ControlNet"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "Explorar carpetas",
|
||||||
|
"selectFolder": "Seleccionar esta carpeta",
|
||||||
|
"goUp": "Subir",
|
||||||
|
"pathPlaceholder": "Introducir ruta...",
|
||||||
|
"go": "Ir",
|
||||||
|
"emptyFolder": "No hay subcarpetas",
|
||||||
|
"loadError": "Error al cargar el directorio"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "La ruta es válida",
|
||||||
|
"pathNotFound": "La ruta no existe",
|
||||||
|
"notADirectory": "No es un directorio",
|
||||||
|
"notReadable": "La ruta no es legible",
|
||||||
|
"notWritable": "La ruta no es escribible"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "Etiquetas prioritarias",
|
"title": "Etiquetas prioritarias",
|
||||||
"description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))",
|
"description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))",
|
||||||
|
|||||||
@@ -608,6 +608,22 @@
|
|||||||
"controlnet": "Chemins ControlNet"
|
"controlnet": "Chemins ControlNet"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "Parcourir les dossiers",
|
||||||
|
"selectFolder": "Sélectionner ce dossier",
|
||||||
|
"goUp": "Remonter",
|
||||||
|
"pathPlaceholder": "Saisir un chemin...",
|
||||||
|
"go": "Aller",
|
||||||
|
"emptyFolder": "Aucun sous-dossier",
|
||||||
|
"loadError": "Échec du chargement du dossier"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "Le chemin est valide",
|
||||||
|
"pathNotFound": "Le chemin n’existe pas",
|
||||||
|
"notADirectory": "N’est pas un dossier",
|
||||||
|
"notReadable": "Le chemin n’est pas lisible",
|
||||||
|
"notWritable": "Le chemin n’est pas accessible en écriture"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "Tags prioritaires",
|
"title": "Tags prioritaires",
|
||||||
"description": "Personnalisez l'ordre de priorité des tags pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))",
|
"description": "Personnalisez l'ordre de priorité des tags pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))",
|
||||||
|
|||||||
@@ -608,6 +608,22 @@
|
|||||||
"controlnet": "נתיבי ControlNet"
|
"controlnet": "נתיבי ControlNet"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "עיון בתיקיות",
|
||||||
|
"selectFolder": "בחר תיקייה זו",
|
||||||
|
"goUp": "למעלה",
|
||||||
|
"pathPlaceholder": "הזן נתיב...",
|
||||||
|
"go": "עבור",
|
||||||
|
"emptyFolder": "אין תתי-תיקיות",
|
||||||
|
"loadError": "טעינת התיקייה נכשלה"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "הנתיב תקין",
|
||||||
|
"pathNotFound": "הנתיב לא קיים",
|
||||||
|
"notADirectory": "לא תיקייה",
|
||||||
|
"notReadable": "הנתיב לא ניתן לקריאה",
|
||||||
|
"notWritable": "הנתיב לא ניתן לכתיבה"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "תגיות עדיפות",
|
"title": "תגיות עדיפות",
|
||||||
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
|
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
|
||||||
|
|||||||
@@ -608,6 +608,22 @@
|
|||||||
"controlnet": "ControlNetパス"
|
"controlnet": "ControlNetパス"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "フォルダを参照",
|
||||||
|
"selectFolder": "このフォルダを選択",
|
||||||
|
"goUp": "上へ",
|
||||||
|
"pathPlaceholder": "パスを入力...",
|
||||||
|
"go": "移動",
|
||||||
|
"emptyFolder": "サブフォルダがありません",
|
||||||
|
"loadError": "ディレクトリの読み込みに失敗しました"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "パスは有効です",
|
||||||
|
"pathNotFound": "パスが存在しません",
|
||||||
|
"notADirectory": "ディレクトリではありません",
|
||||||
|
"notReadable": "パスは読み取れません",
|
||||||
|
"notWritable": "パスは書き込めません"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "優先タグ",
|
"title": "優先タグ",
|
||||||
"description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))",
|
"description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))",
|
||||||
|
|||||||
@@ -608,6 +608,22 @@
|
|||||||
"controlnet": "ControlNet 경로"
|
"controlnet": "ControlNet 경로"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "폴더 찾아보기",
|
||||||
|
"selectFolder": "이 폴더 선택",
|
||||||
|
"goUp": "위로",
|
||||||
|
"pathPlaceholder": "경로 입력...",
|
||||||
|
"go": "이동",
|
||||||
|
"emptyFolder": "하위 폴더 없음",
|
||||||
|
"loadError": "디렉터리를 불러오지 못했습니다"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "유효한 경로입니다",
|
||||||
|
"pathNotFound": "경로가 존재하지 않습니다",
|
||||||
|
"notADirectory": "디렉터리가 아닙니다",
|
||||||
|
"notReadable": "경로를 읽을 수 없습니다",
|
||||||
|
"notWritable": "경로에 쓸 수 없습니다"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "우선순위 태그",
|
"title": "우선순위 태그",
|
||||||
"description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).",
|
"description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).",
|
||||||
|
|||||||
@@ -608,6 +608,22 @@
|
|||||||
"controlnet": "Пути ControlNet"
|
"controlnet": "Пути ControlNet"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "Обзор папок",
|
||||||
|
"selectFolder": "Выбрать эту папку",
|
||||||
|
"goUp": "Вверх",
|
||||||
|
"pathPlaceholder": "Введите путь...",
|
||||||
|
"go": "Перейти",
|
||||||
|
"emptyFolder": "Нет подпапок",
|
||||||
|
"loadError": "Не удалось загрузить каталог"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "Путь действителен",
|
||||||
|
"pathNotFound": "Путь не существует",
|
||||||
|
"notADirectory": "Не является каталогом",
|
||||||
|
"notReadable": "Путь недоступен для чтения",
|
||||||
|
"notWritable": "Путь недоступен для записи"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "Приоритетные теги",
|
"title": "Приоритетные теги",
|
||||||
"description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).",
|
"description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).",
|
||||||
|
|||||||
@@ -608,6 +608,22 @@
|
|||||||
"controlnet": "ControlNet 路径"
|
"controlnet": "ControlNet 路径"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "浏览文件夹",
|
||||||
|
"selectFolder": "选择此文件夹",
|
||||||
|
"goUp": "上级目录",
|
||||||
|
"pathPlaceholder": "输入路径...",
|
||||||
|
"go": "跳转",
|
||||||
|
"emptyFolder": "没有子文件夹",
|
||||||
|
"loadError": "目录加载失败"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "路径有效",
|
||||||
|
"pathNotFound": "路径不存在",
|
||||||
|
"notADirectory": "不是一个目录",
|
||||||
|
"notReadable": "路径不可读",
|
||||||
|
"notWritable": "路径不可写"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "优先标签",
|
"title": "优先标签",
|
||||||
"description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))",
|
"description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))",
|
||||||
|
|||||||
@@ -608,6 +608,22 @@
|
|||||||
"controlnet": "ControlNet 路徑"
|
"controlnet": "ControlNet 路徑"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "瀏覽資料夾",
|
||||||
|
"selectFolder": "選擇此資料夾",
|
||||||
|
"goUp": "上一層",
|
||||||
|
"pathPlaceholder": "輸入路徑...",
|
||||||
|
"go": "前往",
|
||||||
|
"emptyFolder": "沒有子資料夾",
|
||||||
|
"loadError": "目錄載入失敗"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "路徑有效",
|
||||||
|
"pathNotFound": "路徑不存在",
|
||||||
|
"notADirectory": "不是目錄",
|
||||||
|
"notReadable": "路徑無法讀取",
|
||||||
|
"notWritable": "路徑無法寫入"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "優先標籤",
|
"title": "優先標籤",
|
||||||
"description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))",
|
"description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))",
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ from .agent_handlers import AgentHandler
|
|||||||
from .download_routing_handlers import DownloadRoutingHandler
|
from .download_routing_handlers import DownloadRoutingHandler
|
||||||
from .model_handlers import ModelCivitaiHandler
|
from .model_handlers import ModelCivitaiHandler
|
||||||
from ...utils.civitai_utils import rewrite_preview_url
|
from ...utils.civitai_utils import rewrite_preview_url
|
||||||
|
from ...utils.directory_browser import browse_directory
|
||||||
from ...utils.example_images_paths import (
|
from ...utils.example_images_paths import (
|
||||||
find_non_compliant_items_in_example_images_root,
|
find_non_compliant_items_in_example_images_root,
|
||||||
is_valid_example_images_root,
|
is_valid_example_images_root,
|
||||||
@@ -3497,6 +3498,76 @@ class FileSystemHandler:
|
|||||||
logger.error("Failed to open wildcards location: %s", exc, exc_info=True)
|
logger.error("Failed to open wildcards 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 browse_directory(self, request: web.Request) -> web.Response:
|
||||||
|
"""Browse a directory for the settings-UI directory picker."""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
payload, status = browse_directory(data.get("path", ""))
|
||||||
|
return web.json_response(payload, status=status)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Invalid JSON"}, status=400
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Failed to browse directory: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def validate_path(self, request: web.Request) -> web.Response:
|
||||||
|
"""Validate a filesystem path for the settings UI.
|
||||||
|
|
||||||
|
A well-formed request always returns HTTP 200; invalid paths are
|
||||||
|
reported via ``error_code`` in the payload. HTTP 400 is reserved for
|
||||||
|
malformed requests (missing path, invalid JSON).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
raw_path = data.get("path")
|
||||||
|
expect = data.get("expect", "directory")
|
||||||
|
|
||||||
|
if not raw_path or not isinstance(raw_path, str):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing path parameter"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
# Business path convention: abspath only, never realpath.
|
||||||
|
path = os.path.abspath(os.path.expanduser(raw_path))
|
||||||
|
|
||||||
|
exists = os.path.exists(path)
|
||||||
|
is_directory = os.path.isdir(path) if exists else False
|
||||||
|
readable = bool(exists and os.access(path, os.R_OK))
|
||||||
|
writable = bool(exists and os.access(path, os.W_OK))
|
||||||
|
|
||||||
|
error_code = None
|
||||||
|
if not exists:
|
||||||
|
error_code = "path_not_found"
|
||||||
|
elif expect == "directory" and not is_directory:
|
||||||
|
error_code = "not_a_directory"
|
||||||
|
elif expect == "file" and not os.path.isfile(path):
|
||||||
|
error_code = "not_a_file"
|
||||||
|
elif not readable:
|
||||||
|
error_code = "not_readable"
|
||||||
|
elif not writable:
|
||||||
|
error_code = "not_writable"
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"path": path,
|
||||||
|
"exists": exists,
|
||||||
|
"is_directory": is_directory,
|
||||||
|
"readable": readable,
|
||||||
|
"writable": writable,
|
||||||
|
"error_code": error_code,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Invalid JSON"}, status=400
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Failed to validate path: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class CustomWordsHandler:
|
class CustomWordsHandler:
|
||||||
"""Handler for autocomplete via TagFTSIndex."""
|
"""Handler for autocomplete via TagFTSIndex."""
|
||||||
@@ -4141,6 +4212,8 @@ class MiscHandlerSet:
|
|||||||
"open_settings_location": self.filesystem.open_settings_location,
|
"open_settings_location": self.filesystem.open_settings_location,
|
||||||
"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,
|
||||||
|
"browse_directory": self.filesystem.browse_directory,
|
||||||
|
"validate_path": self.filesystem.validate_path,
|
||||||
"search_custom_words": self.custom_words.search_custom_words,
|
"search_custom_words": self.custom_words.search_custom_words,
|
||||||
"search_wildcards": self.wildcards.search_wildcards,
|
"search_wildcards": self.wildcards.search_wildcards,
|
||||||
"get_supporters": self.supporters.get_supporters,
|
"get_supporters": self.supporters.get_supporters,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import re
|
|||||||
import asyncio
|
import asyncio
|
||||||
import tempfile
|
import tempfile
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Protocol, Tuple
|
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Protocol, Tuple
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
@@ -34,6 +33,7 @@ from ...utils.civitai_utils import (
|
|||||||
rewrite_preview_url,
|
rewrite_preview_url,
|
||||||
)
|
)
|
||||||
from ...utils.constants import NSFW_LEVELS
|
from ...utils.constants import NSFW_LEVELS
|
||||||
|
from ...utils.directory_browser import WINDOWS_DRIVES_TOKEN, browse_directory
|
||||||
from ...utils.exif_utils import ExifUtils
|
from ...utils.exif_utils import ExifUtils
|
||||||
from ...utils.recipe_open_stats import RecipeOpenStats
|
from ...utils.recipe_open_stats import RecipeOpenStats
|
||||||
from ...recipes.merger import GenParamsMerger
|
from ...recipes.merger import GenParamsMerger
|
||||||
@@ -3124,11 +3124,10 @@ class RecipeWorkflowHandler:
|
|||||||
class BatchImportHandler:
|
class BatchImportHandler:
|
||||||
"""Handle batch import operations for recipes."""
|
"""Handle batch import operations for recipes."""
|
||||||
|
|
||||||
# Virtual path token for the Windows drive list. Browsing up from a drive
|
# Virtual path token for the Windows drive list. Kept as a class
|
||||||
# root (e.g. C:\) lands here so users can switch drives without typing a
|
# attribute for backwards compatibility; the canonical definition lives
|
||||||
# path. Only meaningful on Windows; elsewhere it falls through to normal
|
# in py/utils/directory_browser.py.
|
||||||
# path handling and fails the existence check.
|
WINDOWS_DRIVES_TOKEN = WINDOWS_DRIVES_TOKEN
|
||||||
WINDOWS_DRIVES_TOKEN = "__drives__"
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -3301,131 +3300,8 @@ class BatchImportHandler:
|
|||||||
"""Browse a directory and return its contents (subdirectories and files)."""
|
"""Browse a directory and return its contents (subdirectories and files)."""
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
directory_path = data.get("path", "")
|
payload, status = browse_directory(data.get("path", ""))
|
||||||
|
return web.json_response(payload, status=status)
|
||||||
if os.name == "nt" and directory_path == self.WINDOWS_DRIVES_TOKEN:
|
|
||||||
return self._windows_drives_response()
|
|
||||||
|
|
||||||
# Default to the user's home directory. The frontend previously
|
|
||||||
# sent "/" as the initial path, which is POSIX-only: on Windows it
|
|
||||||
# resolves to the current drive root and then fails the access
|
|
||||||
# check below.
|
|
||||||
if not directory_path:
|
|
||||||
path = Path.home()
|
|
||||||
else:
|
|
||||||
path = Path(directory_path).expanduser().resolve()
|
|
||||||
|
|
||||||
# Access check: browsing intentionally covers the whole server
|
|
||||||
# filesystem (the server operator browses their own machine). On
|
|
||||||
# POSIX every absolute path is under "/", but Path("/") has no
|
|
||||||
# drive letter on Windows and can never anchor a drive-qualified
|
|
||||||
# path in relative_to(), so test for a drive there instead.
|
|
||||||
if os.name == "nt":
|
|
||||||
is_allowed = bool(path.drive)
|
|
||||||
else:
|
|
||||||
is_allowed = path.is_absolute()
|
|
||||||
|
|
||||||
if not is_allowed:
|
|
||||||
return web.json_response(
|
|
||||||
{"success": False, "error": "Access denied to this directory"},
|
|
||||||
status=403,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not path.exists():
|
|
||||||
return web.json_response(
|
|
||||||
{"success": False, "error": "Directory does not exist"},
|
|
||||||
status=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not path.is_dir():
|
|
||||||
return web.json_response(
|
|
||||||
{"success": False, "error": "Path is not a directory"},
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
# List directory contents
|
|
||||||
directories = []
|
|
||||||
image_files = []
|
|
||||||
|
|
||||||
image_extensions = {
|
|
||||||
".jpg",
|
|
||||||
".jpeg",
|
|
||||||
".png",
|
|
||||||
".gif",
|
|
||||||
".webp",
|
|
||||||
".bmp",
|
|
||||||
".tiff",
|
|
||||||
".tif",
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
for item in path.iterdir():
|
|
||||||
try:
|
|
||||||
if item.is_dir():
|
|
||||||
# Skip hidden directories and common system folders
|
|
||||||
if not item.name.startswith(".") and item.name not in [
|
|
||||||
"__pycache__",
|
|
||||||
"node_modules",
|
|
||||||
]:
|
|
||||||
directories.append(
|
|
||||||
{
|
|
||||||
"name": item.name,
|
|
||||||
"path": str(item),
|
|
||||||
"is_parent": False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
elif item.is_file() and item.suffix.lower() in image_extensions:
|
|
||||||
image_files.append(
|
|
||||||
{
|
|
||||||
"name": item.name,
|
|
||||||
"path": str(item),
|
|
||||||
"size": item.stat().st_size,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except (PermissionError, OSError):
|
|
||||||
# Skip files/directories we can't access
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Sort directories and files alphabetically
|
|
||||||
directories.sort(key=lambda x: x["name"].lower())
|
|
||||||
image_files.sort(key=lambda x: x["name"].lower())
|
|
||||||
|
|
||||||
# Parent directory. A filesystem root is its own parent
|
|
||||||
# (parent == path): POSIX "/" gets no parent, while a Windows
|
|
||||||
# drive root (C:\) links up to the virtual drive list so users
|
|
||||||
# can switch drives. The previous str(path) != str(path.root)
|
|
||||||
# check misfired on Windows, where a drive root's parent is
|
|
||||||
# itself, producing an infinite self-loop.
|
|
||||||
if path.parent == path:
|
|
||||||
parent_path = (
|
|
||||||
self.WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
parent_path = str(path.parent)
|
|
||||||
|
|
||||||
return web.json_response(
|
|
||||||
{
|
|
||||||
"success": True,
|
|
||||||
"current_path": str(path),
|
|
||||||
"parent_path": parent_path,
|
|
||||||
"directories": directories,
|
|
||||||
"image_files": image_files,
|
|
||||||
"image_count": len(image_files),
|
|
||||||
"directory_count": len(directories),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
except PermissionError:
|
|
||||||
return web.json_response(
|
|
||||||
{"success": False, "error": "Permission denied"},
|
|
||||||
status=403,
|
|
||||||
)
|
|
||||||
except OSError as exc:
|
|
||||||
return web.json_response(
|
|
||||||
{"success": False, "error": f"Error reading directory: {str(exc)}"},
|
|
||||||
status=500,
|
|
||||||
)
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "Invalid JSON"},
|
{"success": False, "error": "Invalid JSON"},
|
||||||
@@ -3434,30 +3310,3 @@ class BatchImportHandler:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
|
self._logger.error("Error browsing directory: %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)
|
||||||
|
|
||||||
def _windows_drives_response(self) -> web.Response:
|
|
||||||
"""List available drive letters as a virtual directory (Windows only)."""
|
|
||||||
try:
|
|
||||||
drives = os.listdrives()
|
|
||||||
except AttributeError: # Python < 3.12
|
|
||||||
drives = [
|
|
||||||
f"{letter}:\\"
|
|
||||||
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
||||||
if os.path.exists(f"{letter}:\\")
|
|
||||||
]
|
|
||||||
directories = [
|
|
||||||
{"name": drive, "path": drive, "is_parent": False} for drive in drives
|
|
||||||
]
|
|
||||||
return web.json_response(
|
|
||||||
{
|
|
||||||
"success": True,
|
|
||||||
# Empty current_path marks the virtual level; the frontend
|
|
||||||
# disables folder selection there.
|
|
||||||
"current_path": "",
|
|
||||||
"parent_path": None,
|
|
||||||
"directories": directories,
|
|
||||||
"image_files": [],
|
|
||||||
"image_count": 0,
|
|
||||||
"directory_count": len(directories),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
|
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
|
||||||
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"),
|
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"),
|
||||||
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
|
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
|
||||||
|
RouteDefinition("POST", "/api/lm/browse-directory", "browse_directory"),
|
||||||
|
RouteDefinition("POST", "/api/lm/validate-path", "validate_path"),
|
||||||
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
|
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
|
||||||
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
|
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
|
||||||
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
|
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Shared directory-browsing logic for HTTP directory pickers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Tuple
|
||||||
|
|
||||||
|
# Virtual path token for the Windows drive list. Browsing up from a drive
|
||||||
|
# root (e.g. C:\) lands here so users can switch drives without typing a
|
||||||
|
# path. Only meaningful on Windows; elsewhere it falls through to normal
|
||||||
|
# path handling and fails the existence check.
|
||||||
|
WINDOWS_DRIVES_TOKEN = "__drives__"
|
||||||
|
|
||||||
|
_IMAGE_EXTENSIONS = {
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".png",
|
||||||
|
".gif",
|
||||||
|
".webp",
|
||||||
|
".bmp",
|
||||||
|
".tiff",
|
||||||
|
".tif",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def browse_directory(directory_path: str) -> Tuple[Dict[str, Any], int]:
|
||||||
|
"""Browse a directory and return (payload, http_status).
|
||||||
|
|
||||||
|
The payload shape matches the JSON responses historically produced by
|
||||||
|
``BatchImportHandler.browse_directory``: on success a dict with
|
||||||
|
``success``, ``current_path``, ``parent_path``, ``directories``,
|
||||||
|
``image_files``, ``image_count`` and ``directory_count``; on failure a
|
||||||
|
``{"success": False, "error": ...}`` dict with a 400/403/404/500 status.
|
||||||
|
"""
|
||||||
|
if os.name == "nt" and directory_path == WINDOWS_DRIVES_TOKEN:
|
||||||
|
return _windows_drives_payload(), 200
|
||||||
|
|
||||||
|
# Default to the user's home directory. The frontend previously
|
||||||
|
# sent "/" as the initial path, which is POSIX-only: on Windows it
|
||||||
|
# resolves to the current drive root and then fails the access
|
||||||
|
# check below.
|
||||||
|
if not directory_path:
|
||||||
|
path = Path.home()
|
||||||
|
else:
|
||||||
|
path = Path(directory_path).expanduser().resolve()
|
||||||
|
|
||||||
|
# Access check: browsing intentionally covers the whole server
|
||||||
|
# filesystem (the server operator browses their own machine). On
|
||||||
|
# POSIX every absolute path is under "/", but Path("/") has no
|
||||||
|
# drive letter on Windows and can never anchor a drive-qualified
|
||||||
|
# path in relative_to(), so test for a drive there instead.
|
||||||
|
if os.name == "nt":
|
||||||
|
is_allowed = bool(path.drive)
|
||||||
|
else:
|
||||||
|
is_allowed = path.is_absolute()
|
||||||
|
|
||||||
|
if not is_allowed:
|
||||||
|
return {"success": False, "error": "Access denied to this directory"}, 403
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
return {"success": False, "error": "Directory does not exist"}, 404
|
||||||
|
|
||||||
|
if not path.is_dir():
|
||||||
|
return {"success": False, "error": "Path is not a directory"}, 400
|
||||||
|
|
||||||
|
directories = []
|
||||||
|
image_files = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for item in path.iterdir():
|
||||||
|
try:
|
||||||
|
if item.is_dir():
|
||||||
|
# Skip hidden directories and common system folders
|
||||||
|
if not item.name.startswith(".") and item.name not in [
|
||||||
|
"__pycache__",
|
||||||
|
"node_modules",
|
||||||
|
]:
|
||||||
|
directories.append(
|
||||||
|
{
|
||||||
|
"name": item.name,
|
||||||
|
"path": str(item),
|
||||||
|
"is_parent": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif item.is_file() and item.suffix.lower() in _IMAGE_EXTENSIONS:
|
||||||
|
image_files.append(
|
||||||
|
{
|
||||||
|
"name": item.name,
|
||||||
|
"path": str(item),
|
||||||
|
"size": item.stat().st_size,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
# Skip files/directories we can't access
|
||||||
|
continue
|
||||||
|
|
||||||
|
directories.sort(key=lambda x: x["name"].lower())
|
||||||
|
image_files.sort(key=lambda x: x["name"].lower())
|
||||||
|
|
||||||
|
# Parent directory. A filesystem root is its own parent
|
||||||
|
# (parent == path): POSIX "/" gets no parent, while a Windows
|
||||||
|
# drive root (C:\) links up to the virtual drive list so users
|
||||||
|
# can switch drives. The previous str(path) != str(path.root)
|
||||||
|
# check misfired on Windows, where a drive root's parent is
|
||||||
|
# itself, producing an infinite self-loop.
|
||||||
|
if path.parent == path:
|
||||||
|
parent_path = WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
|
||||||
|
else:
|
||||||
|
parent_path = str(path.parent)
|
||||||
|
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"current_path": str(path),
|
||||||
|
"parent_path": parent_path,
|
||||||
|
"directories": directories,
|
||||||
|
"image_files": image_files,
|
||||||
|
"image_count": len(image_files),
|
||||||
|
"directory_count": len(directories),
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
|
||||||
|
except PermissionError:
|
||||||
|
return {"success": False, "error": "Permission denied"}, 403
|
||||||
|
except OSError as exc:
|
||||||
|
return {"success": False, "error": f"Error reading directory: {str(exc)}"}, 500
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_drives_payload() -> Dict[str, Any]:
|
||||||
|
"""List available drive letters as a virtual directory (Windows only)."""
|
||||||
|
try:
|
||||||
|
drives = os.listdrives()
|
||||||
|
except AttributeError: # Python < 3.12
|
||||||
|
drives = [
|
||||||
|
f"{letter}:\\"
|
||||||
|
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
if os.path.exists(f"{letter}:\\")
|
||||||
|
]
|
||||||
|
directories = [{"name": drive, "path": drive, "is_parent": False} for drive in drives]
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
# Empty current_path marks the virtual level; the frontend
|
||||||
|
# disables folder selection there.
|
||||||
|
"current_path": "",
|
||||||
|
"parent_path": None,
|
||||||
|
"directories": directories,
|
||||||
|
"image_files": [],
|
||||||
|
"image_count": 0,
|
||||||
|
"directory_count": len(directories),
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/* Directory Picker Modal */
|
||||||
|
/* Stacks above the settings modal: settings tooltips/combobox panels sit at
|
||||||
|
10000/10002, so 10010 keeps the picker on top of everything settings-side. */
|
||||||
|
#directoryPickerModal {
|
||||||
|
z-index: 10010;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-picker-content {
|
||||||
|
max-width: 560px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-picker-content h3 {
|
||||||
|
color: var(--text-color);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manual path row */
|
||||||
|
#directoryPickerModal .directory-picker-path-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .directory-picker-path-row input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
background: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .directory-picker-path-row input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Directory browser (class names shared with the batch import browser) */
|
||||||
|
#directoryPickerModal .directory-browser {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .browser-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .back-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .back-btn:hover {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .back-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .current-path {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .browser-content {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .folder-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .folder-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .folder-item:hover {
|
||||||
|
background: var(--lora-surface-hover, oklch(from var(--lora-accent) l c h / 0.1));
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .folder-item i {
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .item-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .browser-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .directory-picker-error {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
background: oklch(from var(--lora-error) l c h / 0.12);
|
||||||
|
color: var(--lora-error);
|
||||||
|
font-size: 0.85em;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .directory-picker-empty {
|
||||||
|
padding: var(--space-2);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme adjustments */
|
||||||
|
[data-theme="dark"] #directoryPickerModal .directory-browser {
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] #directoryPickerModal .browser-header,
|
||||||
|
[data-theme="dark"] #directoryPickerModal .browser-footer {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] #directoryPickerModal .folder-item i {
|
||||||
|
color: #fcd34d;
|
||||||
|
}
|
||||||
@@ -1692,6 +1692,87 @@ input:checked + .toggle-slider:before {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Browse (directory picker) button — boxed accent style used on the dynamic
|
||||||
|
extra-folder-path / model-path rows, mirroring .remove-path-btn. Static
|
||||||
|
path fields use the .inset variant below instead. */
|
||||||
|
#settingsModal .browse-path-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
border: 1px solid var(--lora-accent);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsModal .browse-path-btn:hover {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inset variant (static path fields): the button floats inside the right
|
||||||
|
edge of the input, so the setting row keeps its single-control look and
|
||||||
|
narrow columns never push it onto a second line. */
|
||||||
|
#settingsModal .browse-path-btn.inset {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsModal .browse-path-btn.inset:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsModal input.has-inset-browse {
|
||||||
|
padding-right: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Advisory path validation feedback (wraps below the input row) */
|
||||||
|
#settingsModal .text-input-wrapper,
|
||||||
|
#settingsModal .path-control {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsModal .path-control > .text-input-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-validation {
|
||||||
|
display: none;
|
||||||
|
flex-basis: 100%;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--lora-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-validation.visible {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-validation.valid {
|
||||||
|
color: var(--lora-success);
|
||||||
|
}
|
||||||
|
|
||||||
/* Highlight animation for setting items targeted from Doctor actions */
|
/* Highlight animation for setting items targeted from Doctor actions */
|
||||||
@keyframes settings-highlight-pulse {
|
@keyframes settings-highlight-pulse {
|
||||||
0%, 100% { box-shadow: 0 0 0 0 rgba(from var(--lora-accent) r g b / 0.4); }
|
0%, 100% { box-shadow: 0 0 0 0 rgba(from var(--lora-accent) r g b / 0.4); }
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
@import 'components/modal/example-access-modal.css';
|
@import 'components/modal/example-access-modal.css';
|
||||||
@import 'components/modal/support-modal.css';
|
@import 'components/modal/support-modal.css';
|
||||||
@import 'components/modal/download-modal.css';
|
@import 'components/modal/download-modal.css';
|
||||||
|
@import 'components/modal/directory-picker-modal.css';
|
||||||
@import 'components/toast.css';
|
@import 'components/toast.css';
|
||||||
@import 'components/loading.css';
|
@import 'components/loading.css';
|
||||||
@import 'components/menu.css';
|
@import 'components/menu.css';
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable directory picker modal backed by POST /api/lm/browse-directory.
|
||||||
|
* Self-managed (NOT registered with ModalManager): it stacks above the
|
||||||
|
* settings modal, so ModalManager's "close current modal on open" behavior
|
||||||
|
* would kill the modal underneath.
|
||||||
|
*/
|
||||||
|
class DirectoryPickerModal {
|
||||||
|
constructor() {
|
||||||
|
this.isOpen = false;
|
||||||
|
this.currentPath = '';
|
||||||
|
this.parentPath = null;
|
||||||
|
this.onSelect = null;
|
||||||
|
this.elements = {};
|
||||||
|
this._bindings = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
open({ initialPath = '', onSelect } = {}) {
|
||||||
|
this._cacheElements();
|
||||||
|
if (!this.elements.modal) {
|
||||||
|
console.warn('DirectoryPickerModal: #directoryPickerModal not found in DOM');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._unbindEvents();
|
||||||
|
this.onSelect = typeof onSelect === 'function' ? onSelect : null;
|
||||||
|
this.currentPath = '';
|
||||||
|
this.parentPath = null;
|
||||||
|
this._clearError();
|
||||||
|
this.elements.folderList.innerHTML = '';
|
||||||
|
this.elements.currentPathEl.textContent = '';
|
||||||
|
this.elements.upBtn.disabled = true;
|
||||||
|
this.elements.pathInput.value = initialPath || '';
|
||||||
|
|
||||||
|
this._bindEvents();
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
this.elements.modal.style.display = 'block';
|
||||||
|
this.isOpen = true;
|
||||||
|
|
||||||
|
// An empty path lets the server pick its default (user home).
|
||||||
|
this.loadDirectory(initialPath || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (!this.isOpen) return;
|
||||||
|
this.isOpen = false;
|
||||||
|
this._unbindEvents();
|
||||||
|
if (this.elements.modal) {
|
||||||
|
this.elements.modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
this.onSelect = null;
|
||||||
|
// Keep body.modal-open: the settings modal underneath may still be open.
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadDirectory(path) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/lm/browse-directory', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this._clearError();
|
||||||
|
this._renderDirectory(data);
|
||||||
|
} else {
|
||||||
|
this._showError(data.error || translate('settings.directoryPicker.loadError', {}, 'Failed to load directory'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading directory:', error);
|
||||||
|
this._showError(translate('settings.directoryPicker.loadError', {}, 'Failed to load directory'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_cacheElements() {
|
||||||
|
const modal = document.getElementById('directoryPickerModal');
|
||||||
|
this.elements = {
|
||||||
|
modal,
|
||||||
|
closeBtn: document.getElementById('directoryPickerCloseBtn'),
|
||||||
|
pathInput: document.getElementById('directoryPickerPathInput'),
|
||||||
|
goBtn: document.getElementById('directoryPickerGoBtn'),
|
||||||
|
upBtn: document.getElementById('directoryPickerUpBtn'),
|
||||||
|
currentPathEl: document.getElementById('directoryPickerCurrentPath'),
|
||||||
|
folderList: document.getElementById('directoryPickerFolderList'),
|
||||||
|
errorEl: document.getElementById('directoryPickerError'),
|
||||||
|
selectBtn: document.getElementById('directoryPickerSelectBtn')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_bind(target, type, handler, options) {
|
||||||
|
target.addEventListener(type, handler, options);
|
||||||
|
this._bindings.push([target, type, handler, options]);
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindEvents() {
|
||||||
|
const { modal, closeBtn, pathInput, goBtn, upBtn, selectBtn } = this.elements;
|
||||||
|
|
||||||
|
this._bind(closeBtn, 'click', () => this.close());
|
||||||
|
this._bind(goBtn, 'click', () => this.loadDirectory(pathInput.value.trim()));
|
||||||
|
this._bind(pathInput, 'keydown', (event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
this.loadDirectory(pathInput.value.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._bind(upBtn, 'click', () => {
|
||||||
|
// Server-provided parent_path: Windows paths cannot be derived client-side.
|
||||||
|
if (this.parentPath) {
|
||||||
|
this.loadDirectory(this.parentPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._bind(selectBtn, 'click', () => this._selectCurrent());
|
||||||
|
|
||||||
|
// Capture phase + stopPropagation so an ESC here never reaches the
|
||||||
|
// settings modal's own ESC handler underneath.
|
||||||
|
this._bind(document, 'keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// Backdrop click (the .modal element itself, not its content).
|
||||||
|
this._bind(modal, 'click', (event) => {
|
||||||
|
if (event.target === modal) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_unbindEvents() {
|
||||||
|
for (const [target, type, handler, options] of this._bindings) {
|
||||||
|
target.removeEventListener(type, handler, options);
|
||||||
|
}
|
||||||
|
this._bindings = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderDirectory(data) {
|
||||||
|
this.currentPath = data.current_path || '';
|
||||||
|
this.parentPath = data.parent_path || null;
|
||||||
|
|
||||||
|
this.elements.currentPathEl.textContent = this.currentPath;
|
||||||
|
this.elements.pathInput.value = this.currentPath;
|
||||||
|
this.elements.upBtn.disabled = !this.parentPath;
|
||||||
|
|
||||||
|
const folderList = this.elements.folderList;
|
||||||
|
folderList.innerHTML = '';
|
||||||
|
|
||||||
|
const directories = data.directories || [];
|
||||||
|
if (directories.length === 0) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'directory-picker-empty';
|
||||||
|
empty.textContent = translate('settings.directoryPicker.emptyFolder', {}, 'This folder is empty');
|
||||||
|
folderList.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
directories.forEach((entry) => {
|
||||||
|
folderList.appendChild(this._createFolderItem(entry));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each entry is { name, path, is_parent }; the server supplies the full
|
||||||
|
// child path, so navigation never joins path segments client-side.
|
||||||
|
_createFolderItem(entry) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'folder-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<i class="fas fa-folder"></i>
|
||||||
|
<span class="item-name">${this._escapeHtml(entry.name)}</span>
|
||||||
|
`;
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
this.loadDirectory(entry.path);
|
||||||
|
});
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectCurrent() {
|
||||||
|
if (!this.currentPath) return;
|
||||||
|
if (this.onSelect) {
|
||||||
|
this.onSelect(this.currentPath);
|
||||||
|
}
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
_showError(message) {
|
||||||
|
this.elements.errorEl.textContent = message;
|
||||||
|
this.elements.errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearError() {
|
||||||
|
this.elements.errorEl.textContent = '';
|
||||||
|
this.elements.errorEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
_escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const directoryPickerModal = new DirectoryPickerModal();
|
||||||
|
export { DirectoryPickerModal };
|
||||||
@@ -15,9 +15,17 @@ import { i18n } from '../i18n/index.js';
|
|||||||
import { configureModelCardVideo } from '../components/shared/ModelCard.js';
|
import { configureModelCardVideo } from '../components/shared/ModelCard.js';
|
||||||
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
|
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
|
||||||
import { bannerService } from './BannerService.js';
|
import { bannerService } from './BannerService.js';
|
||||||
|
import { directoryPickerModal } from '../components/DirectoryPickerModal.js';
|
||||||
|
|
||||||
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
|
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
|
||||||
|
|
||||||
|
const PATH_VALIDATION_ERROR_I18N = {
|
||||||
|
path_not_found: { key: 'settings.pathValidation.pathNotFound', fallback: 'Path does not exist' },
|
||||||
|
not_a_directory: { key: 'settings.pathValidation.notADirectory', fallback: 'Not a directory' },
|
||||||
|
not_readable: { key: 'settings.pathValidation.notReadable', fallback: 'Path is not readable' },
|
||||||
|
not_writable: { key: 'settings.pathValidation.notWritable', fallback: 'Path is not writable' },
|
||||||
|
};
|
||||||
|
|
||||||
// Other-model sub_type -> i18n label key, mirroring the checkbox list in
|
// Other-model sub_type -> i18n label key, mirroring the checkbox list in
|
||||||
// templates/components/modals/settings/library.html.
|
// templates/components/modals/settings/library.html.
|
||||||
const OTHER_SUB_TYPE_LABEL_KEYS = {
|
const OTHER_SUB_TYPE_LABEL_KEYS = {
|
||||||
@@ -1198,6 +1206,24 @@ export class SettingsManager {
|
|||||||
if (useNewLicenseIconsCheckbox) {
|
if (useNewLicenseIconsCheckbox) {
|
||||||
useNewLicenseIconsCheckbox.checked = state.global.settings.use_new_license_icons !== false;
|
useNewLicenseIconsCheckbox.checked = state.global.settings.use_new_license_icons !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Directory browse buttons + advisory path validation (idempotent,
|
||||||
|
// safe to call on every modal open).
|
||||||
|
this.attachPathField('recipesPath', {
|
||||||
|
onAfterSelect: () => this.saveInputSetting('recipesPath', 'recipes_path'),
|
||||||
|
});
|
||||||
|
this.attachPathField('exampleImagesPath', {
|
||||||
|
onAfterSelect: (pickedPath) => {
|
||||||
|
// Mirror ExampleImagesManager's blur-save flow.
|
||||||
|
window.exampleImagesManager?.updateDownloadButtonState?.(pickedPath.trim() !== '');
|
||||||
|
this.saveSetting('example_images_path', pickedPath)
|
||||||
|
.then(() => showToast('toast.exampleImages.pathUpdated', {}, 'success'))
|
||||||
|
.catch((error) => showToast('toast.exampleImages.pathUpdateFailed', { message: error.message }, 'error'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.attachPathField('exampleImagesLocalRoot', {
|
||||||
|
onAfterSelect: () => this.saveInputSetting('exampleImagesLocalRoot', 'example_images_local_root'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDownloadBackendSettings() {
|
loadDownloadBackendSettings() {
|
||||||
@@ -1806,6 +1832,11 @@ export class SettingsManager {
|
|||||||
onblur="settingsManager.updateExtraFolderPaths('${modelType}')"
|
onblur="settingsManager.updateExtraFolderPaths('${modelType}')"
|
||||||
onfocus="settingsManager.clearExtraFolderPathError(this)"
|
onfocus="settingsManager.clearExtraFolderPathError(this)"
|
||||||
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||||
|
<button type="button" class="browse-path-btn"
|
||||||
|
onclick="settingsManager.browseForPathRow(this, '${modelType}')"
|
||||||
|
title="${translate('settings.directoryPicker.title', {}, 'Browse Folders')}">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
</button>
|
||||||
<button type="button" class="remove-path-btn"
|
<button type="button" class="remove-path-btn"
|
||||||
onclick="settingsManager.removeExtraFolderPathRow(this, '${modelType}')"
|
onclick="settingsManager.removeExtraFolderPathRow(this, '${modelType}')"
|
||||||
title="${translate('common.actions.delete', {}, 'Delete')}">
|
title="${translate('common.actions.delete', {}, 'Delete')}">
|
||||||
@@ -2242,6 +2273,11 @@ export class SettingsManager {
|
|||||||
onblur="settingsManager.updateModelFolderPaths('${key}')"
|
onblur="settingsManager.updateModelFolderPaths('${key}')"
|
||||||
onfocus="settingsManager.clearModelFolderPathError(this)"
|
onfocus="settingsManager.clearModelFolderPathError(this)"
|
||||||
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||||
|
<button type="button" class="browse-path-btn"
|
||||||
|
onclick="settingsManager.browseForPathRow(this, '${key}', true)"
|
||||||
|
title="${translate('settings.directoryPicker.title', {}, 'Browse Folders')}">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
</button>
|
||||||
<button type="button" class="remove-path-btn"
|
<button type="button" class="remove-path-btn"
|
||||||
onclick="settingsManager.removeModelFolderPathRow(this, '${key}')"
|
onclick="settingsManager.removeModelFolderPathRow(this, '${key}')"
|
||||||
title="${translate('common.actions.delete', {}, 'Delete')}">
|
title="${translate('common.actions.delete', {}, 'Delete')}">
|
||||||
@@ -3763,6 +3799,161 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Directory picker + live path validation ─────────────────────────
|
||||||
|
// Validation is advisory only: it never blocks or alters save flows.
|
||||||
|
|
||||||
|
attachPathField(inputId, { expect = 'directory', onAfterSelect } = {}) {
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
if (!input) {
|
||||||
|
console.warn(`SettingsManager.attachPathField: #${inputId} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input.dataset.pathFieldAttached === '1') return;
|
||||||
|
input.dataset.pathFieldAttached = '1';
|
||||||
|
|
||||||
|
const browseBtn = document.createElement('button');
|
||||||
|
browseBtn.type = 'button';
|
||||||
|
browseBtn.className = 'browse-path-btn inset';
|
||||||
|
browseBtn.title = translate('settings.directoryPicker.title', {}, 'Browse Folders');
|
||||||
|
browseBtn.innerHTML = '<i class="fas fa-folder-open"></i>';
|
||||||
|
|
||||||
|
// Inset layout: the button is absolutely positioned inside the right
|
||||||
|
// edge of the input, so the row keeps its original single-control look.
|
||||||
|
const parent = input.parentElement;
|
||||||
|
let wrapper;
|
||||||
|
let statusHost;
|
||||||
|
if (parent && parent.classList.contains('path-control')) {
|
||||||
|
// e.g. #exampleImagesPath sits beside a Download button: wrap only
|
||||||
|
// the input so the button insets into it and Download stays beside it.
|
||||||
|
wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'text-input-wrapper';
|
||||||
|
parent.insertBefore(wrapper, input);
|
||||||
|
wrapper.appendChild(input);
|
||||||
|
statusHost = parent;
|
||||||
|
} else {
|
||||||
|
// .text-input-wrapper provided by the setting_input macro
|
||||||
|
wrapper = parent;
|
||||||
|
statusHost = parent;
|
||||||
|
}
|
||||||
|
wrapper.appendChild(browseBtn);
|
||||||
|
input.classList.add('has-inset-browse');
|
||||||
|
|
||||||
|
const statusEl = document.createElement('div');
|
||||||
|
statusEl.className = 'path-validation';
|
||||||
|
statusHost.appendChild(statusEl);
|
||||||
|
|
||||||
|
input._pathFieldConfig = { expect, onAfterSelect, statusEl };
|
||||||
|
|
||||||
|
browseBtn.addEventListener('click', () => this.browseForPath(inputId));
|
||||||
|
input.addEventListener('blur', () => {
|
||||||
|
clearTimeout(input._pathValidationTimer);
|
||||||
|
this.validatePath(input, statusEl, expect);
|
||||||
|
});
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
clearTimeout(input._pathValidationTimer);
|
||||||
|
input._pathValidationTimer = setTimeout(() => {
|
||||||
|
this.validatePath(input, statusEl, expect);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validatePath(input, statusEl, expect = 'directory') {
|
||||||
|
const value = input.value.trim();
|
||||||
|
const seq = input._pathValidationSeq = (input._pathValidationSeq || 0) + 1;
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
this._clearPathStatus(statusEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/lm/validate-path', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: value, expect }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
data = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (seq === input._pathValidationSeq) {
|
||||||
|
this._clearPathStatus(statusEl);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore responses overtaken by newer input or validation runs.
|
||||||
|
if (seq !== input._pathValidationSeq || input.value.trim() !== value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.success && !data.error_code) {
|
||||||
|
statusEl.innerHTML = `<i class="fas fa-check-circle"></i><span>${translate('settings.pathValidation.valid', {}, 'Path is valid')}</span>`;
|
||||||
|
statusEl.classList.add('visible', 'valid');
|
||||||
|
} else {
|
||||||
|
const message = this._getPathValidationMessage(data);
|
||||||
|
statusEl.innerHTML = '<i class="fas fa-exclamation-circle"></i><span></span>';
|
||||||
|
statusEl.querySelector('span').textContent = message;
|
||||||
|
statusEl.classList.add('visible');
|
||||||
|
statusEl.classList.remove('valid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_getPathValidationMessage(data) {
|
||||||
|
const entry = PATH_VALIDATION_ERROR_I18N[data?.error_code];
|
||||||
|
if (entry) {
|
||||||
|
return translate(entry.key, {}, entry.fallback);
|
||||||
|
}
|
||||||
|
return data?.error || translate('settings.pathValidation.pathNotFound', {}, 'Path does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearPathStatus(statusEl) {
|
||||||
|
statusEl.classList.remove('visible', 'valid');
|
||||||
|
statusEl.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
browseForPath(inputId, { onAfterSelect } = {}) {
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
if (!input) return;
|
||||||
|
const config = input._pathFieldConfig || {};
|
||||||
|
const afterSelect = onAfterSelect || config.onAfterSelect;
|
||||||
|
|
||||||
|
directoryPickerModal.open({
|
||||||
|
initialPath: input.value.trim(),
|
||||||
|
onSelect: (pickedPath) => {
|
||||||
|
input.value = pickedPath;
|
||||||
|
if (config.statusEl) {
|
||||||
|
this.validatePath(input, config.statusEl, config.expect || 'directory');
|
||||||
|
}
|
||||||
|
if (typeof afterSelect === 'function') {
|
||||||
|
afterSelect(pickedPath);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browse variant for the dynamic extra/model folder path rows: picking a
|
||||||
|
// folder routes through the row's existing validation + save logic.
|
||||||
|
browseForPathRow(btn, key, isModelPath = false) {
|
||||||
|
const row = btn.closest('.extra-folder-path-row');
|
||||||
|
const input = row ? row.querySelector('.extra-folder-path-input') : null;
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
directoryPickerModal.open({
|
||||||
|
initialPath: input.value.trim(),
|
||||||
|
onSelect: (pickedPath) => {
|
||||||
|
input.value = pickedPath;
|
||||||
|
if (isModelPath) {
|
||||||
|
this.updateModelFolderPaths(key);
|
||||||
|
} else {
|
||||||
|
this.updateExtraFolderPaths(key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async saveInputSetting(elementId, settingKey) {
|
async saveInputSetting(elementId, settingKey) {
|
||||||
const element = document.getElementById(elementId);
|
const element = document.getElementById(elementId);
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
|||||||
@@ -14,3 +14,4 @@
|
|||||||
{% include 'components/modals/move_modal.html' %}
|
{% include 'components/modals/move_modal.html' %}
|
||||||
{% include 'components/modals/bulk_add_tags_modal.html' %}
|
{% include 'components/modals/bulk_add_tags_modal.html' %}
|
||||||
{% include 'components/modals/bulk_base_model_modal.html' %}
|
{% include 'components/modals/bulk_base_model_modal.html' %}
|
||||||
|
{% include 'components/modals/directory_picker_modal.html' %}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!-- Directory Picker Modal (self-managed by DirectoryPickerModal.js, stacked above the settings modal) -->
|
||||||
|
<div id="directoryPickerModal" class="modal directory-picker-modal" style="display: none;">
|
||||||
|
<div class="modal-content directory-picker-content">
|
||||||
|
<button class="close" id="directoryPickerCloseBtn">×</button>
|
||||||
|
<h3>{{ t('settings.directoryPicker.title') }}</h3>
|
||||||
|
|
||||||
|
<div class="directory-picker-path-row">
|
||||||
|
<input type="text" id="directoryPickerPathInput" placeholder="{{ t('settings.directoryPicker.pathPlaceholder') }}" autocomplete="off">
|
||||||
|
<button class="secondary-btn" id="directoryPickerGoBtn">
|
||||||
|
<i class="fas fa-arrow-right"></i> {{ t('settings.directoryPicker.go') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="directory-browser" id="directoryPickerBrowser">
|
||||||
|
<div class="browser-header">
|
||||||
|
<button class="back-btn" id="directoryPickerUpBtn" title="{{ t('settings.directoryPicker.goUp') }}" disabled>
|
||||||
|
<i class="fas fa-arrow-up"></i>
|
||||||
|
</button>
|
||||||
|
<div class="current-path" id="directoryPickerCurrentPath"></div>
|
||||||
|
</div>
|
||||||
|
<div class="browser-content">
|
||||||
|
<div class="folder-list" id="directoryPickerFolderList"></div>
|
||||||
|
<div class="directory-picker-error" id="directoryPickerError" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="browser-footer">
|
||||||
|
<button class="primary-btn" id="directoryPickerSelectBtn">
|
||||||
|
<i class="fas fa-check"></i> {{ t('settings.directoryPicker.selectFolder') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: (key, params = {}, fallback = null) => fallback ?? key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { directoryPickerModal } from '../../../static/js/components/DirectoryPickerModal.js';
|
||||||
|
|
||||||
|
function buildModalDom() {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="directoryPickerModal" class="modal directory-picker-modal" style="display: none;">
|
||||||
|
<div class="modal-content directory-picker-content">
|
||||||
|
<button class="close" id="directoryPickerCloseBtn">×</button>
|
||||||
|
<h3>Select folder</h3>
|
||||||
|
<div class="directory-picker-path-row">
|
||||||
|
<input type="text" id="directoryPickerPathInput">
|
||||||
|
<button id="directoryPickerGoBtn">Go</button>
|
||||||
|
</div>
|
||||||
|
<div class="directory-browser" id="directoryPickerBrowser">
|
||||||
|
<div class="browser-header">
|
||||||
|
<button class="back-btn" id="directoryPickerUpBtn"></button>
|
||||||
|
<div class="current-path" id="directoryPickerCurrentPath"></div>
|
||||||
|
</div>
|
||||||
|
<div class="browser-content">
|
||||||
|
<div class="folder-list" id="directoryPickerFolderList"></div>
|
||||||
|
<div class="directory-picker-error" id="directoryPickerError" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="browser-footer">
|
||||||
|
<button class="primary-btn" id="directoryPickerSelectBtn">Select</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function okResponse(payload) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ success: true, ...payload }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DirectoryPickerModal', () => {
|
||||||
|
let fetchMock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
buildModalDom();
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
|
||||||
|
fetchMock = vi.fn(async () => okResponse({
|
||||||
|
current_path: '/home/user',
|
||||||
|
parent_path: '/home',
|
||||||
|
directories: [
|
||||||
|
{ name: 'photos', path: '/home/user/photos', is_parent: false },
|
||||||
|
{ name: 'models', path: '/home/user/models', is_parent: false },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
directoryPickerModal.close();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function lastRequestBody() {
|
||||||
|
return JSON.parse(fetchMock.mock.calls.at(-1)[1].body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modalEl() {
|
||||||
|
return document.getElementById('directoryPickerModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('open() loads the initial path via POST /api/lm/browse-directory', async () => {
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
const [url, options] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe('/api/lm/browse-directory');
|
||||||
|
expect(options.method).toBe('POST');
|
||||||
|
expect(lastRequestBody().path).toBe('/home/user');
|
||||||
|
expect(modalEl().style.display).toBe('block');
|
||||||
|
expect(document.body.classList.contains('modal-open')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the folder list and current path', async () => {
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/home/user');
|
||||||
|
const names = [...document.querySelectorAll('#directoryPickerFolderList .item-name')].map((el) => el.textContent);
|
||||||
|
expect(names).toEqual(['photos', 'models']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drills down on folder click using the server-provided entry path', async () => {
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
fetchMock.mockClear();
|
||||||
|
|
||||||
|
document.querySelectorAll('#directoryPickerFolderList .folder-item')[0].click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
expect(lastRequestBody().path).toBe('/home/user/photos');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drills down from a Windows path using the server-provided entry path', async () => {
|
||||||
|
fetchMock.mockImplementation(async () => okResponse({
|
||||||
|
current_path: 'C:\\Users\\miao',
|
||||||
|
parent_path: 'C:\\Users',
|
||||||
|
directories: [
|
||||||
|
{ name: 'models', path: 'C:\\Users\\miao\\models', is_parent: false },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
directoryPickerModal.open({ initialPath: 'C:\\Users\\miao', onSelect: vi.fn() });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
fetchMock.mockClear();
|
||||||
|
|
||||||
|
document.querySelector('#directoryPickerFolderList .folder-item').click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
expect(lastRequestBody().path).toBe('C:\\Users\\miao\\models');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates up via the server-provided parent_path', async () => {
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/home/user');
|
||||||
|
});
|
||||||
|
fetchMock.mockClear();
|
||||||
|
|
||||||
|
document.getElementById('directoryPickerUpBtn').click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
expect(lastRequestBody().path).toBe('/home');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the Up button when parent_path is null', async () => {
|
||||||
|
fetchMock.mockImplementation(async () => okResponse({
|
||||||
|
current_path: '/',
|
||||||
|
parent_path: null,
|
||||||
|
directories: [],
|
||||||
|
}));
|
||||||
|
directoryPickerModal.open({ initialPath: '/', onSelect: vi.fn() });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/');
|
||||||
|
});
|
||||||
|
const upBtn = document.getElementById('directoryPickerUpBtn');
|
||||||
|
expect(upBtn.disabled).toBe(true);
|
||||||
|
fetchMock.mockClear();
|
||||||
|
|
||||||
|
upBtn.click();
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an empty-folder message for a directory without subfolders', async () => {
|
||||||
|
fetchMock.mockImplementation(async () => okResponse({
|
||||||
|
current_path: '/home/user/empty',
|
||||||
|
parent_path: '/home/user',
|
||||||
|
directories: [],
|
||||||
|
}));
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user/empty', onSelect: vi.fn() });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('#directoryPickerFolderList .directory-picker-empty')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSelect with current_path and closes on Select', async () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user', onSelect });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/home/user');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('directoryPickerSelectBtn').click();
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('/home/user');
|
||||||
|
expect(modalEl().style.display).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the backend error message and keeps the previous listing', async () => {
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockImplementation(async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({ success: false, error: 'Directory not found' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await directoryPickerModal.loadDirectory('/gone');
|
||||||
|
|
||||||
|
const errorEl = document.getElementById('directoryPickerError');
|
||||||
|
expect(errorEl.textContent).toBe('Directory not found');
|
||||||
|
expect(errorEl.style.display).toBe('block');
|
||||||
|
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(2);
|
||||||
|
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/home/user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes on ESC and stops propagation to modals underneath', async () => {
|
||||||
|
const underlyingEscSpy = vi.fn();
|
||||||
|
document.addEventListener('keydown', underlyingEscSpy);
|
||||||
|
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
|
||||||
|
document.getElementById('directoryPickerPathInput').dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(modalEl().style.display).toBe('none');
|
||||||
|
expect(underlyingEscSpy).not.toHaveBeenCalled();
|
||||||
|
// The settings modal's body lock must survive the picker closing.
|
||||||
|
expect(document.body.classList.contains('modal-open')).toBe(true);
|
||||||
|
|
||||||
|
document.removeEventListener('keydown', underlyingEscSpy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the typed path on Go click and on Enter', async () => {
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
fetchMock.mockClear();
|
||||||
|
|
||||||
|
const input = document.getElementById('directoryPickerPathInput');
|
||||||
|
input.value = '/var/models';
|
||||||
|
document.getElementById('directoryPickerGoBtn').click();
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
expect(lastRequestBody().path).toBe('/var/models');
|
||||||
|
|
||||||
|
fetchMock.mockClear();
|
||||||
|
input.value = '/tmp/other';
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
expect(lastRequestBody().path).toBe('/tmp/other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes on backdrop click but not on content click', async () => {
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
|
||||||
|
modalEl().querySelector('.directory-picker-content').click();
|
||||||
|
expect(modalEl().style.display).toBe('block');
|
||||||
|
|
||||||
|
modalEl().click();
|
||||||
|
expect(modalEl().style.display).toBe('none');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||||
|
modalManager: {
|
||||||
|
closeModal: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/state/index.js', () => ({
|
||||||
|
state: {
|
||||||
|
global: {
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
|
loadingManager: {
|
||||||
|
showSimpleLoading: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createDefaultSettings: () => ({
|
||||||
|
language: 'en',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||||
|
resetAndReload: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/constants.js', () => ({
|
||||||
|
DOWNLOAD_PATH_TEMPLATES: {},
|
||||||
|
DEFAULT_PATH_TEMPLATES: {},
|
||||||
|
MAPPABLE_BASE_MODELS: [],
|
||||||
|
PATH_TEMPLATE_PLACEHOLDERS: {},
|
||||||
|
DEFAULT_PRIORITY_TAG_CONFIG: {},
|
||||||
|
getMappableBaseModelsDynamic: () => [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: (_key, _params, fallback) => fallback ?? '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/i18n/index.js', () => ({
|
||||||
|
i18n: {
|
||||||
|
getCurrentLocale: () => 'en',
|
||||||
|
setLanguage: vi.fn().mockResolvedValue(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
|
||||||
|
configureModelCardVideo: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/DirectoryPickerModal.js', () => ({
|
||||||
|
directoryPickerModal: {
|
||||||
|
open: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
|
||||||
|
import { directoryPickerModal } from '../../../static/js/components/DirectoryPickerModal.js';
|
||||||
|
|
||||||
|
const createManager = () => {
|
||||||
|
const initSettingsSpy = vi
|
||||||
|
.spyOn(SettingsManager.prototype, 'initializeSettings')
|
||||||
|
.mockResolvedValue();
|
||||||
|
const initializeSpy = vi
|
||||||
|
.spyOn(SettingsManager.prototype, 'initialize')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const manager = new SettingsManager();
|
||||||
|
|
||||||
|
initSettingsSpy.mockRestore();
|
||||||
|
initializeSpy.mockRestore();
|
||||||
|
|
||||||
|
return manager;
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendPathInput = (id = 'recipesPath') => {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'text-input-wrapper';
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.id = id;
|
||||||
|
wrapper.appendChild(input);
|
||||||
|
document.body.appendChild(wrapper);
|
||||||
|
return input;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validResponse = (path) => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
path,
|
||||||
|
exists: true,
|
||||||
|
is_directory: true,
|
||||||
|
readable: true,
|
||||||
|
writable: true,
|
||||||
|
error_code: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidResponse = (errorCode) => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
path: '/missing',
|
||||||
|
exists: false,
|
||||||
|
is_directory: false,
|
||||||
|
readable: false,
|
||||||
|
writable: false,
|
||||||
|
error_code: errorCode,
|
||||||
|
error: `server: ${errorCode}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SettingsManager.attachPathField', () => {
|
||||||
|
it('keeps the input in its wrapper and injects an inset browse button and a status element', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
|
||||||
|
const wrapper = input.parentElement;
|
||||||
|
expect(wrapper.classList.contains('text-input-wrapper')).toBe(true);
|
||||||
|
const browseBtn = wrapper.querySelector('.browse-path-btn.inset');
|
||||||
|
expect(browseBtn).not.toBeNull();
|
||||||
|
expect(browseBtn.querySelector('i.fas.fa-folder-open')).not.toBeNull();
|
||||||
|
expect(input.classList.contains('has-inset-browse')).toBe(true);
|
||||||
|
expect(wrapper.querySelector('.path-validation')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — a second call does not duplicate the button', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
|
||||||
|
expect(document.querySelectorAll('.browse-path-btn')).toHaveLength(1);
|
||||||
|
expect(document.querySelectorAll('.path-validation')).toHaveLength(1);
|
||||||
|
expect(input.dataset.pathFieldAttached).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps only the input for insetting when inside .path-control, leaving siblings in place', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'setting-control path-control';
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.id = 'exampleImagesPath';
|
||||||
|
const downloadBtn = document.createElement('button');
|
||||||
|
downloadBtn.id = 'exampleImagesDownloadBtn';
|
||||||
|
container.appendChild(input);
|
||||||
|
container.appendChild(downloadBtn);
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
manager.attachPathField('exampleImagesPath');
|
||||||
|
|
||||||
|
const wrapper = input.parentElement;
|
||||||
|
expect(wrapper.classList.contains('text-input-wrapper')).toBe(true);
|
||||||
|
expect(wrapper.parentElement).toBe(container);
|
||||||
|
const browseBtn = wrapper.querySelector('.browse-path-btn.inset');
|
||||||
|
expect(browseBtn).not.toBeNull();
|
||||||
|
expect(wrapper.nextElementSibling).toBe(downloadBtn);
|
||||||
|
expect(container.querySelector('.path-validation')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns and no-ops when the input is missing', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
manager.attachPathField('doesNotExist');
|
||||||
|
|
||||||
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SettingsManager.validatePath', () => {
|
||||||
|
it('posts to /api/lm/validate-path on blur with expect directory', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
input.value = '/data/recipes';
|
||||||
|
global.fetch = vi.fn().mockResolvedValue(validResponse('/data/recipes'));
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
input.dispatchEvent(new Event('blur'));
|
||||||
|
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
const [url, options] = global.fetch.mock.calls[0];
|
||||||
|
expect(url).toBe('/api/lm/validate-path');
|
||||||
|
expect(options.method).toBe('POST');
|
||||||
|
expect(JSON.parse(options.body)).toEqual({ path: '/data/recipes', expect: 'directory' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debounces rapid input events into a single validation call', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
global.fetch = vi.fn().mockResolvedValue(validResponse('/data'));
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
input.value = '/d';
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
input.value = '/da';
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
input.value = '/data';
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(499);
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a valid status for a valid path', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
input.value = '/data/recipes';
|
||||||
|
global.fetch = vi.fn().mockResolvedValue(validResponse('/data/recipes'));
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
input.dispatchEvent(new Event('blur'));
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('.path-validation').classList.contains('visible')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusEl = document.querySelector('.path-validation');
|
||||||
|
expect(statusEl.classList.contains('valid')).toBe(true);
|
||||||
|
expect(statusEl.textContent).toContain('Path is valid');
|
||||||
|
expect(statusEl.querySelector('i.fas.fa-check-circle')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an error status mapped from error_code', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
input.value = '/missing';
|
||||||
|
global.fetch = vi.fn().mockResolvedValue(invalidResponse('path_not_found'));
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
input.dispatchEvent(new Event('blur'));
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('.path-validation').classList.contains('visible')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusEl = document.querySelector('.path-validation');
|
||||||
|
expect(statusEl.classList.contains('valid')).toBe(false);
|
||||||
|
expect(statusEl.textContent).toContain('Path does not exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores stale responses overtaken by a newer value', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
|
||||||
|
const deferreds = [];
|
||||||
|
global.fetch = vi.fn().mockImplementation(() => new Promise((resolve) => {
|
||||||
|
deferreds.push(resolve);
|
||||||
|
}));
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
|
||||||
|
input.value = '/old-path';
|
||||||
|
input.dispatchEvent(new Event('blur'));
|
||||||
|
input.value = '/new-path';
|
||||||
|
input.dispatchEvent(new Event('blur'));
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Newer request resolves first and renders valid status.
|
||||||
|
deferreds[1](validResponse('/new-path'));
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('.path-validation').classList.contains('valid')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Older request resolves late and must not overwrite the status.
|
||||||
|
deferreds[0](invalidResponse('path_not_found'));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const statusEl = document.querySelector('.path-validation');
|
||||||
|
expect(statusEl.classList.contains('valid')).toBe(true);
|
||||||
|
expect(statusEl.textContent).toContain('Path is valid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the status and skips fetch when the value is empty', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
input.value = '/data';
|
||||||
|
global.fetch = vi.fn().mockResolvedValue(validResponse('/data'));
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
input.dispatchEvent(new Event('blur'));
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('.path-validation').classList.contains('visible')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch.mockClear();
|
||||||
|
input.value = '';
|
||||||
|
input.dispatchEvent(new Event('blur'));
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const statusEl = document.querySelector('.path-validation');
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
expect(statusEl.classList.contains('visible')).toBe(false);
|
||||||
|
expect(statusEl.textContent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the status silently on network failure', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
input.value = '/data';
|
||||||
|
global.fetch = vi.fn().mockRejectedValue(new Error('network down'));
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
input.dispatchEvent(new Event('blur'));
|
||||||
|
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const statusEl = document.querySelector('.path-validation');
|
||||||
|
expect(statusEl.classList.contains('visible')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SettingsManager.browseForPath', () => {
|
||||||
|
it('opens the picker with the current value and applies the selection', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
input.value = '/initial';
|
||||||
|
const onAfterSelect = vi.fn();
|
||||||
|
global.fetch = vi.fn().mockResolvedValue(validResponse('/picked'));
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath', { onAfterSelect });
|
||||||
|
manager.browseForPath('recipesPath');
|
||||||
|
|
||||||
|
expect(directoryPickerModal.open).toHaveBeenCalledTimes(1);
|
||||||
|
const openArgs = directoryPickerModal.open.mock.calls[0][0];
|
||||||
|
expect(openArgs.initialPath).toBe('/initial');
|
||||||
|
|
||||||
|
openArgs.onSelect('/picked');
|
||||||
|
|
||||||
|
expect(input.value).toBe('/picked');
|
||||||
|
expect(onAfterSelect).toHaveBeenCalledWith('/picked');
|
||||||
|
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1));
|
||||||
|
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({
|
||||||
|
path: '/picked',
|
||||||
|
expect: 'directory',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SettingsManager dynamic path rows', () => {
|
||||||
|
const appendExtraFolderContainer = (modelType = 'loras') => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.id = `extraFolderPaths-${modelType}`;
|
||||||
|
document.body.appendChild(container);
|
||||||
|
return container;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('renders a browse button in extra folder path rows', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
appendExtraFolderContainer('loras');
|
||||||
|
|
||||||
|
manager.addExtraFolderPathRow('loras', '/models/loras', false);
|
||||||
|
|
||||||
|
const row = document.querySelector('.extra-folder-path-row');
|
||||||
|
const browseBtn = row.querySelector('.browse-path-btn');
|
||||||
|
expect(browseBtn).not.toBeNull();
|
||||||
|
expect(browseBtn.querySelector('i.fas.fa-folder-open')).not.toBeNull();
|
||||||
|
// Browse button sits before the remove button.
|
||||||
|
expect(browseBtn.nextElementSibling.classList.contains('remove-path-btn')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picker selection routes through updateExtraFolderPaths', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
appendExtraFolderContainer('loras');
|
||||||
|
const updateSpy = vi
|
||||||
|
.spyOn(manager, 'updateExtraFolderPaths')
|
||||||
|
.mockResolvedValue();
|
||||||
|
|
||||||
|
manager.addExtraFolderPathRow('loras', '/models/loras', false);
|
||||||
|
const row = document.querySelector('.extra-folder-path-row');
|
||||||
|
const input = row.querySelector('.extra-folder-path-input');
|
||||||
|
const browseBtn = row.querySelector('.browse-path-btn');
|
||||||
|
|
||||||
|
manager.browseForPathRow(browseBtn, 'loras');
|
||||||
|
|
||||||
|
expect(directoryPickerModal.open).toHaveBeenCalledTimes(1);
|
||||||
|
const openArgs = directoryPickerModal.open.mock.calls[0][0];
|
||||||
|
expect(openArgs.initialPath).toBe('/models/loras');
|
||||||
|
|
||||||
|
openArgs.onSelect('/picked/loras');
|
||||||
|
|
||||||
|
expect(input.value).toBe('/picked/loras');
|
||||||
|
expect(updateSpy).toHaveBeenCalledWith('loras');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('model path rows route through updateModelFolderPaths', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.id = 'modelFolderPaths-loras';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const updateSpy = vi
|
||||||
|
.spyOn(manager, 'updateModelFolderPaths')
|
||||||
|
.mockResolvedValue();
|
||||||
|
|
||||||
|
manager.addModelFolderPathRow('loras', '/models/loras', false);
|
||||||
|
const row = container.querySelector('.extra-folder-path-row');
|
||||||
|
const input = row.querySelector('.extra-folder-path-input');
|
||||||
|
const browseBtn = row.querySelector('.browse-path-btn');
|
||||||
|
expect(browseBtn).not.toBeNull();
|
||||||
|
|
||||||
|
manager.browseForPathRow(browseBtn, 'loras', true);
|
||||||
|
const openArgs = directoryPickerModal.open.mock.calls[0][0];
|
||||||
|
openArgs.onSelect('/picked/loras');
|
||||||
|
|
||||||
|
expect(input.value).toBe('/picked/loras');
|
||||||
|
expect(updateSpy).toHaveBeenCalledWith('loras');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.routes.handlers.misc_handlers import FileSystemHandler
|
||||||
|
|
||||||
|
|
||||||
|
def _make_handler() -> FileSystemHandler:
|
||||||
|
# browse_directory/validate_path never touch the settings service
|
||||||
|
return FileSystemHandler(settings_service=SimpleNamespace())
|
||||||
|
|
||||||
|
|
||||||
|
class _Request:
|
||||||
|
def __init__(self, body: dict) -> None:
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
async def json(self):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
async def _browse(handler: FileSystemHandler, path: str):
|
||||||
|
response = await handler.browse_directory(_Request({"path": path}))
|
||||||
|
return response, json.loads(response.text)
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate(handler: FileSystemHandler, path: str, expect: str = "directory"):
|
||||||
|
response = await handler.validate_path(
|
||||||
|
_Request({"path": path, "expect": expect})
|
||||||
|
)
|
||||||
|
return response, json.loads(response.text)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_directory_empty_path_defaults_to_home(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||||
|
|
||||||
|
response, payload = await _browse(_make_handler(), "")
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["current_path"] == str(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_directory_lists_subdirs_sorted_and_filters(tmp_path):
|
||||||
|
(tmp_path / "zeta").mkdir()
|
||||||
|
(tmp_path / "alpha").mkdir()
|
||||||
|
(tmp_path / ".hidden").mkdir()
|
||||||
|
(tmp_path / "node_modules").mkdir()
|
||||||
|
(tmp_path / "__pycache__").mkdir()
|
||||||
|
|
||||||
|
response, payload = await _browse(_make_handler(), str(tmp_path))
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert [d["name"] for d in payload["directories"]] == ["alpha", "zeta"]
|
||||||
|
assert payload["directory_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_directory_missing_returns_404(tmp_path):
|
||||||
|
response, payload = await _browse(_make_handler(), str(tmp_path / "nope"))
|
||||||
|
|
||||||
|
assert response.status == 404
|
||||||
|
assert payload["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_directory_file_path_returns_400(tmp_path):
|
||||||
|
file_path = tmp_path / "file.txt"
|
||||||
|
file_path.write_text("x")
|
||||||
|
|
||||||
|
response, payload = await _browse(_make_handler(), str(file_path))
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
assert payload["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_directory_relative_path_returns_403(monkeypatch):
|
||||||
|
# resolve() normally absolutizes relative paths against the cwd; bypass it
|
||||||
|
# to exercise the access-denied branch directly.
|
||||||
|
monkeypatch.setattr(Path, "resolve", lambda self: self)
|
||||||
|
|
||||||
|
response, payload = await _browse(_make_handler(), "relative/path")
|
||||||
|
|
||||||
|
assert response.status == 403
|
||||||
|
assert payload["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_path_existing_directory(tmp_path):
|
||||||
|
response, payload = await _validate(_make_handler(), str(tmp_path))
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload == {
|
||||||
|
"success": True,
|
||||||
|
"path": os.path.abspath(str(tmp_path)),
|
||||||
|
"exists": True,
|
||||||
|
"is_directory": True,
|
||||||
|
"readable": True,
|
||||||
|
"writable": True,
|
||||||
|
"error_code": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_path_not_found(tmp_path):
|
||||||
|
response, payload = await _validate(_make_handler(), str(tmp_path / "missing"))
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["exists"] is False
|
||||||
|
assert payload["error_code"] == "path_not_found"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_path_file_when_directory_expected(tmp_path):
|
||||||
|
file_path = tmp_path / "file.txt"
|
||||||
|
file_path.write_text("x")
|
||||||
|
|
||||||
|
response, payload = await _validate(_make_handler(), str(file_path))
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["error_code"] == "not_a_directory"
|
||||||
|
assert payload["exists"] is True
|
||||||
|
assert payload["is_directory"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_path_expect_file_on_file(tmp_path):
|
||||||
|
file_path = tmp_path / "file.txt"
|
||||||
|
file_path.write_text("x")
|
||||||
|
|
||||||
|
response, payload = await _validate(_make_handler(), str(file_path), expect="file")
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["error_code"] is None
|
||||||
|
assert payload["exists"] is True
|
||||||
|
assert payload["is_directory"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not hasattr(os, "geteuid") or os.geteuid() == 0,
|
||||||
|
reason="root bypasses permission checks",
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_path_unreadable_directory(tmp_path):
|
||||||
|
locked = tmp_path / "locked"
|
||||||
|
locked.mkdir()
|
||||||
|
locked.chmod(0o000)
|
||||||
|
try:
|
||||||
|
response, payload = await _validate(_make_handler(), str(locked))
|
||||||
|
finally:
|
||||||
|
locked.chmod(0o755)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["error_code"] == "not_readable"
|
||||||
|
assert payload["readable"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_path_empty_path_returns_400():
|
||||||
|
response, payload = await _validate(_make_handler(), "")
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
assert payload["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_path_expands_user(tmp_path, monkeypatch):
|
||||||
|
subdir = tmp_path / "subdir"
|
||||||
|
subdir.mkdir()
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
|
||||||
|
response, payload = await _validate(_make_handler(), "~/subdir")
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["error_code"] is None
|
||||||
|
assert payload["path"] == os.path.abspath(str(subdir))
|
||||||
Reference in New Issue
Block a user