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" "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))",
+16
View File
@@ -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))",
+16
View File
@@ -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))",
+16
View File
@@ -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 nexiste pas",
"notADirectory": "Nest pas un dossier",
"notReadable": "Le chemin nest pas lisible",
"notWritable": "Le chemin nest 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))",
+16
View File
@@ -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))",
+16
View File
@@ -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))",
+16
View File
@@ -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)).",
+16
View File
@@ -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)).",
+16
View File
@@ -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))",
+16
View File
@@ -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))",
+73
View File
@@ -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,
+7 -158
View File
@@ -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),
}
)
+2
View File
@@ -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"),
+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; 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); }
+1
View File
@@ -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 };
+191
View File
@@ -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;
+1
View File
@@ -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">&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))