diff --git a/locales/de.json b/locales/de.json index 38d9752d..5dcfa609 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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))", diff --git a/locales/en.json b/locales/en.json index e0c1a194..668fe316 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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))", diff --git a/locales/es.json b/locales/es.json index 0db61892..209fcd90 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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))", diff --git a/locales/fr.json b/locales/fr.json index 508c2dab..1b826ecf 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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 n’existe pas", + "notADirectory": "N’est pas un dossier", + "notReadable": "Le chemin n’est pas lisible", + "notWritable": "Le chemin n’est pas accessible en écriture" + }, "priorityTags": { "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))", diff --git a/locales/he.json b/locales/he.json index f3e90647..907ab6be 100644 --- a/locales/he.json +++ b/locales/he.json @@ -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))", diff --git a/locales/ja.json b/locales/ja.json index 7a99601e..38d5a5e8 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -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))", diff --git a/locales/ko.json b/locales/ko.json index 5c97cbab..671eaba4 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -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)).", diff --git a/locales/ru.json b/locales/ru.json index 2a4455ed..c55911e2 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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)).", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index e18fce7f..895c77de 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -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))", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 2a3a7353..631e43f9 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -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))", diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index d25f8aa2..8faa37f9 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -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, diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 8df4eb86..9b26ba10 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -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), - } - ) diff --git a/py/routes/misc_route_registrar.py b/py/routes/misc_route_registrar.py index 7e0e03d8..0c4dde79 100644 --- a/py/routes/misc_route_registrar.py +++ b/py/routes/misc_route_registrar.py @@ -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"), diff --git a/py/utils/directory_browser.py b/py/utils/directory_browser.py new file mode 100644 index 00000000..4f3b5008 --- /dev/null +++ b/py/utils/directory_browser.py @@ -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), + } diff --git a/static/css/components/modal/directory-picker-modal.css b/static/css/components/modal/directory-picker-modal.css new file mode 100644 index 00000000..cb301f43 --- /dev/null +++ b/static/css/components/modal/directory-picker-modal.css @@ -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; +} diff --git a/static/css/components/modal/settings-modal.css b/static/css/components/modal/settings-modal.css index ba416b57..5b979d43 100644 --- a/static/css/components/modal/settings-modal.css +++ b/static/css/components/modal/settings-modal.css @@ -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); } diff --git a/static/css/style.css b/static/css/style.css index cb098abf..32d82a0a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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'; diff --git a/static/js/components/DirectoryPickerModal.js b/static/js/components/DirectoryPickerModal.js new file mode 100644 index 00000000..465286c5 --- /dev/null +++ b/static/js/components/DirectoryPickerModal.js @@ -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 = ` + + ${this._escapeHtml(entry.name)} + `; + 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 }; diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 6270233c..fc746b07 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -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(); }" /> + +

{{ t('settings.directoryPicker.title') }}

+ +
+ + +
+ +
+
+ +
+
+
+
+ +
+ +
+ + diff --git a/tests/frontend/components/DirectoryPickerModal.test.js b/tests/frontend/components/DirectoryPickerModal.test.js new file mode 100644 index 00000000..612bd18a --- /dev/null +++ b/tests/frontend/components/DirectoryPickerModal.test.js @@ -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 = ` + `; +} + +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'); + }); +}); diff --git a/tests/frontend/managers/settingsManager.pathValidation.test.js b/tests/frontend/managers/settingsManager.pathValidation.test.js new file mode 100644 index 00000000..291cccbc --- /dev/null +++ b/tests/frontend/managers/settingsManager.pathValidation.test.js @@ -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'); + }); +}); diff --git a/tests/routes/test_filesystem_paths.py b/tests/routes/test_filesystem_paths.py new file mode 100644 index 00000000..5fe33afc --- /dev/null +++ b/tests/routes/test_filesystem_paths.py @@ -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))