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:
Will Miao
2026-09-18 21:05:32 +08:00
parent 6dc9f34f7d
commit d45a523fb5
24 changed files with 1958 additions and 158 deletions
+16
View File
@@ -608,6 +608,22 @@
"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": {
"title": "Prioritäts-Tags",
"description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))",
+16
View File
@@ -608,6 +608,22 @@
"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": {
"title": "Priority Tags",
"description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))",
+16
View File
@@ -608,6 +608,22 @@
"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": {
"title": "Etiquetas prioritarias",
"description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))",
+16
View File
@@ -608,6 +608,22 @@
"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 nexiste pas",
"notADirectory": "Nest pas un dossier",
"notReadable": "Le chemin nest pas lisible",
"notWritable": "Le chemin nest pas accessible en écriture"
},
"priorityTags": {
"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))",
+16
View File
@@ -608,6 +608,22 @@
"controlnet": "נתיבי ControlNet"
}
},
"directoryPicker": {
"title": "עיון בתיקיות",
"selectFolder": "בחר תיקייה זו",
"goUp": "למעלה",
"pathPlaceholder": "הזן נתיב...",
"go": "עבור",
"emptyFolder": "אין תתי-תיקיות",
"loadError": "טעינת התיקייה נכשלה"
},
"pathValidation": {
"valid": "הנתיב תקין",
"pathNotFound": "הנתיב לא קיים",
"notADirectory": "לא תיקייה",
"notReadable": "הנתיב לא ניתן לקריאה",
"notWritable": "הנתיב לא ניתן לכתיבה"
},
"priorityTags": {
"title": "תגיות עדיפות",
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
+16
View File
@@ -608,6 +608,22 @@
"controlnet": "ControlNetパス"
}
},
"directoryPicker": {
"title": "フォルダを参照",
"selectFolder": "このフォルダを選択",
"goUp": "上へ",
"pathPlaceholder": "パスを入力...",
"go": "移動",
"emptyFolder": "サブフォルダがありません",
"loadError": "ディレクトリの読み込みに失敗しました"
},
"pathValidation": {
"valid": "パスは有効です",
"pathNotFound": "パスが存在しません",
"notADirectory": "ディレクトリではありません",
"notReadable": "パスは読み取れません",
"notWritable": "パスは書き込めません"
},
"priorityTags": {
"title": "優先タグ",
"description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))",
+16
View File
@@ -608,6 +608,22 @@
"controlnet": "ControlNet 경로"
}
},
"directoryPicker": {
"title": "폴더 찾아보기",
"selectFolder": "이 폴더 선택",
"goUp": "위로",
"pathPlaceholder": "경로 입력...",
"go": "이동",
"emptyFolder": "하위 폴더 없음",
"loadError": "디렉터리를 불러오지 못했습니다"
},
"pathValidation": {
"valid": "유효한 경로입니다",
"pathNotFound": "경로가 존재하지 않습니다",
"notADirectory": "디렉터리가 아닙니다",
"notReadable": "경로를 읽을 수 없습니다",
"notWritable": "경로에 쓸 수 없습니다"
},
"priorityTags": {
"title": "우선순위 태그",
"description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).",
+16
View File
@@ -608,6 +608,22 @@
"controlnet": "Пути ControlNet"
}
},
"directoryPicker": {
"title": "Обзор папок",
"selectFolder": "Выбрать эту папку",
"goUp": "Вверх",
"pathPlaceholder": "Введите путь...",
"go": "Перейти",
"emptyFolder": "Нет подпапок",
"loadError": "Не удалось загрузить каталог"
},
"pathValidation": {
"valid": "Путь действителен",
"pathNotFound": "Путь не существует",
"notADirectory": "Не является каталогом",
"notReadable": "Путь недоступен для чтения",
"notWritable": "Путь недоступен для записи"
},
"priorityTags": {
"title": "Приоритетные теги",
"description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).",
+16
View File
@@ -608,6 +608,22 @@
"controlnet": "ControlNet 路径"
}
},
"directoryPicker": {
"title": "浏览文件夹",
"selectFolder": "选择此文件夹",
"goUp": "上级目录",
"pathPlaceholder": "输入路径...",
"go": "跳转",
"emptyFolder": "没有子文件夹",
"loadError": "目录加载失败"
},
"pathValidation": {
"valid": "路径有效",
"pathNotFound": "路径不存在",
"notADirectory": "不是一个目录",
"notReadable": "路径不可读",
"notWritable": "路径不可写"
},
"priorityTags": {
"title": "优先标签",
"description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))",
+16
View File
@@ -608,6 +608,22 @@
"controlnet": "ControlNet 路徑"
}
},
"directoryPicker": {
"title": "瀏覽資料夾",
"selectFolder": "選擇此資料夾",
"goUp": "上一層",
"pathPlaceholder": "輸入路徑...",
"go": "前往",
"emptyFolder": "沒有子資料夾",
"loadError": "目錄載入失敗"
},
"pathValidation": {
"valid": "路徑有效",
"pathNotFound": "路徑不存在",
"notADirectory": "不是目錄",
"notReadable": "路徑無法讀取",
"notWritable": "路徑無法寫入"
},
"priorityTags": {
"title": "優先標籤",
"description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))",
+73
View File
@@ -61,6 +61,7 @@ from .agent_handlers import AgentHandler
from .download_routing_handlers import DownloadRoutingHandler
from .model_handlers import ModelCivitaiHandler
from ...utils.civitai_utils import rewrite_preview_url
from ...utils.directory_browser import browse_directory
from ...utils.example_images_paths import (
find_non_compliant_items_in_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)
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:
"""Handler for autocomplete via TagFTSIndex."""
@@ -4141,6 +4212,8 @@ class MiscHandlerSet:
"open_settings_location": self.filesystem.open_settings_location,
"open_backup_location": self.filesystem.open_backup_location,
"open_wildcards_location": self.filesystem.open_wildcards_location,
"browse_directory": self.filesystem.browse_directory,
"validate_path": self.filesystem.validate_path,
"search_custom_words": self.custom_words.search_custom_words,
"search_wildcards": self.wildcards.search_wildcards,
"get_supporters": self.supporters.get_supporters,
+7 -158
View File
@@ -9,7 +9,6 @@ import re
import asyncio
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Protocol, Tuple
from aiohttp import web
@@ -34,6 +33,7 @@ from ...utils.civitai_utils import (
rewrite_preview_url,
)
from ...utils.constants import NSFW_LEVELS
from ...utils.directory_browser import WINDOWS_DRIVES_TOKEN, browse_directory
from ...utils.exif_utils import ExifUtils
from ...utils.recipe_open_stats import RecipeOpenStats
from ...recipes.merger import GenParamsMerger
@@ -3124,11 +3124,10 @@ class RecipeWorkflowHandler:
class BatchImportHandler:
"""Handle batch import operations for recipes."""
# 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__"
# Virtual path token for the Windows drive list. Kept as a class
# attribute for backwards compatibility; the canonical definition lives
# in py/utils/directory_browser.py.
WINDOWS_DRIVES_TOKEN = WINDOWS_DRIVES_TOKEN
def __init__(
self,
@@ -3301,131 +3300,8 @@ class BatchImportHandler:
"""Browse a directory and return its contents (subdirectories and files)."""
try:
data = await request.json()
directory_path = data.get("path", "")
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,
)
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"},
@@ -3434,30 +3310,3 @@ class BatchImportHandler:
except Exception as exc:
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
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),
}
)
+2
View File
@@ -37,6 +37,8 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_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("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
+152
View File
@@ -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;
}
/* 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 */
@keyframes settings-highlight-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(from var(--lora-accent) r g b / 0.4); }
+1
View File
@@ -18,6 +18,7 @@
@import 'components/modal/example-access-modal.css';
@import 'components/modal/support-modal.css';
@import 'components/modal/download-modal.css';
@import 'components/modal/directory-picker-modal.css';
@import 'components/toast.css';
@import 'components/loading.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 };
+191
View File
@@ -15,9 +15,17 @@ import { i18n } from '../i18n/index.js';
import { configureModelCardVideo } from '../components/shared/ModelCard.js';
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
import { bannerService } from './BannerService.js';
import { directoryPickerModal } from '../components/DirectoryPickerModal.js';
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
// templates/components/modals/settings/library.html.
const OTHER_SUB_TYPE_LABEL_KEYS = {
@@ -1198,6 +1206,24 @@ export class SettingsManager {
if (useNewLicenseIconsCheckbox) {
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() {
@@ -1806,6 +1832,11 @@ export class SettingsManager {
onblur="settingsManager.updateExtraFolderPaths('${modelType}')"
onfocus="settingsManager.clearExtraFolderPathError(this)"
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"
onclick="settingsManager.removeExtraFolderPathRow(this, '${modelType}')"
title="${translate('common.actions.delete', {}, 'Delete')}">
@@ -2242,6 +2273,11 @@ export class SettingsManager {
onblur="settingsManager.updateModelFolderPaths('${key}')"
onfocus="settingsManager.clearModelFolderPathError(this)"
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"
onclick="settingsManager.removeModelFolderPathRow(this, '${key}')"
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) {
const element = document.getElementById(elementId);
if (!element) return;
+1
View File
@@ -14,3 +14,4 @@
{% include 'components/modals/move_modal.html' %}
{% include 'components/modals/bulk_add_tags_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">&times;</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">&times;</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');
});
});
+183
View File
@@ -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))