mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-24 06:21:26 -03:00
fix(ui): show empty folders as move and download destinations (#999)
This commit is contained in:
@@ -1030,6 +1030,11 @@ class ModelQueryHandler:
|
||||
self._service = service
|
||||
self._logger = logger
|
||||
|
||||
@staticmethod
|
||||
def _parse_include_empty(request: web.Request) -> bool:
|
||||
"""Parse the include_empty query flag (``1``/``true``)."""
|
||||
return request.query.get("include_empty", "").lower() in ("1", "true")
|
||||
|
||||
async def get_top_tags(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
@@ -1124,8 +1129,14 @@ class ModelQueryHandler:
|
||||
|
||||
async def get_folders(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
return web.json_response({"folders": cache.folders})
|
||||
include_empty = self._parse_include_empty(request)
|
||||
if include_empty:
|
||||
# Live enumeration includes empty OS-created directories.
|
||||
folders = await self._service.scanner.get_all_folders()
|
||||
else:
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
folders = cache.folders
|
||||
return web.json_response({"folders": folders})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error getting folders: %s", exc)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
@@ -1150,7 +1161,9 @@ class ModelQueryHandler:
|
||||
{"success": False, "error": "model_root parameter is required"},
|
||||
status=400,
|
||||
)
|
||||
folder_tree = await self._service.get_folder_tree(model_root)
|
||||
folder_tree = await self._service.get_folder_tree(
|
||||
model_root, include_empty=self._parse_include_empty(request)
|
||||
)
|
||||
return web.json_response({"success": True, "tree": folder_tree})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error getting folder tree: %s", exc)
|
||||
@@ -1158,7 +1171,9 @@ class ModelQueryHandler:
|
||||
|
||||
async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
unified_tree = await self._service.get_unified_folder_tree()
|
||||
unified_tree = await self._service.get_unified_folder_tree(
|
||||
include_empty=self._parse_include_empty(request)
|
||||
)
|
||||
return web.json_response({"success": True, "tree": unified_tree})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error getting unified folder tree: %s", exc)
|
||||
|
||||
@@ -972,14 +972,25 @@ class BaseModelService(ABC):
|
||||
)
|
||||
return {k: data[k] for k in fields if k in data}
|
||||
|
||||
async def get_folder_tree(self, model_root: str) -> Dict[str, Any]:
|
||||
async def _get_tree_folders(self, cache, include_empty: bool) -> List[str]:
|
||||
"""Return the folder list backing folder tree responses.
|
||||
|
||||
With ``include_empty`` the directories are enumerated live from the
|
||||
filesystem (including empty ones) via the scanner; otherwise the
|
||||
models-only ``cache.folders`` list is used unchanged.
|
||||
"""
|
||||
if include_empty:
|
||||
return await self.scanner.get_all_folders()
|
||||
return cache.folders
|
||||
|
||||
async def get_folder_tree(self, model_root: str, include_empty: bool = False) -> Dict[str, Any]:
|
||||
"""Get hierarchical folder tree for a specific model root"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
# Build tree structure from folders
|
||||
tree = {}
|
||||
|
||||
for folder in cache.folders:
|
||||
for folder in await self._get_tree_folders(cache, include_empty):
|
||||
# Check if this folder belongs to the specified model root
|
||||
folder_belongs_to_root = False
|
||||
for root in self.scanner.get_model_roots():
|
||||
@@ -1001,7 +1012,7 @@ class BaseModelService(ABC):
|
||||
|
||||
return tree
|
||||
|
||||
async def get_unified_folder_tree(self) -> Dict[str, Any]:
|
||||
async def get_unified_folder_tree(self, include_empty: bool = False) -> Dict[str, Any]:
|
||||
"""Get unified folder tree across all model roots"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
@@ -1011,7 +1022,7 @@ class BaseModelService(ABC):
|
||||
# Get all model roots for path normalization
|
||||
model_roots = self.scanner.get_model_roots()
|
||||
|
||||
for folder in cache.folders:
|
||||
for folder in await self._get_tree_folders(cache, include_empty):
|
||||
if not folder: # Skip empty folders
|
||||
continue
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import asyncio
|
||||
import time
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Sequence, Set, Type, Union, cast
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple, Type, Union, cast
|
||||
|
||||
from ..utils.models import BaseModelMetadata, autov3_from_civitai_files
|
||||
from ..config import config
|
||||
@@ -57,6 +57,16 @@ def _is_excluded_dir(name: str) -> bool:
|
||||
return name == PENDING_DELETE_DIR_NAME
|
||||
|
||||
|
||||
def _is_hidden_relative_path(rel_path: str) -> bool:
|
||||
"""Return True when any segment of a relative path is a hidden directory."""
|
||||
return any(part.startswith(".") for part in rel_path.replace(os.sep, "/").split("/"))
|
||||
|
||||
|
||||
# TTL (seconds) for the get_all_folders() live-walk cache, so rapid repeated
|
||||
# requests (modal open + autocomplete) do not re-walk the model roots.
|
||||
ALL_FOLDERS_CACHE_TTL_SECONDS = 5.0
|
||||
|
||||
|
||||
def _is_pending_delete_path(path: str) -> bool:
|
||||
"""Return True when any path component is the pending-delete staging dir."""
|
||||
normalized = str(path).replace(os.sep, "/")
|
||||
@@ -126,6 +136,8 @@ class ModelScanner:
|
||||
self._name_display_mode = self._resolve_name_display_mode()
|
||||
self._cancel_requested = False # Flag for cancellation
|
||||
self._autov3_backfill_scheduled = False # One-time AutoV3 backfill trigger per process
|
||||
# Short-lived cache for get_all_folders(): (timestamp, folders) or None
|
||||
self._all_folders_ttl_cache: Optional[Tuple[float, List[str]]] = None
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
@@ -165,6 +177,7 @@ class ModelScanner:
|
||||
self._excluded_models = []
|
||||
self._is_initializing = False
|
||||
self._name_display_mode = self._resolve_name_display_mode()
|
||||
self.invalidate_all_folders_cache()
|
||||
self.bump_cache_version()
|
||||
|
||||
try:
|
||||
@@ -897,12 +910,12 @@ class ModelScanner:
|
||||
new_files = []
|
||||
visited_real_paths = set()
|
||||
discovered_real_files = set()
|
||||
|
||||
|
||||
# Scan all model roots
|
||||
for root_path in self.get_model_roots():
|
||||
if not os.path.exists(root_path):
|
||||
continue
|
||||
|
||||
|
||||
# Recursively scan directory
|
||||
for root, dirnames, files in os.walk(root_path, followlinks=True):
|
||||
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||
@@ -910,7 +923,7 @@ class ModelScanner:
|
||||
if real_root in visited_real_paths:
|
||||
continue
|
||||
visited_real_paths.add(real_root)
|
||||
|
||||
|
||||
for file in files:
|
||||
ext = os.path.splitext(file)[1].lower()
|
||||
if ext in self.file_extensions:
|
||||
@@ -955,7 +968,7 @@ class ModelScanner:
|
||||
if self.is_cancelled():
|
||||
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile scan cancelled")
|
||||
return
|
||||
|
||||
|
||||
# Process new files in batches
|
||||
total_added = 0
|
||||
if new_files:
|
||||
@@ -1114,6 +1127,56 @@ class ModelScanner:
|
||||
def get_model_roots(self) -> List[str]:
|
||||
"""Get model root directories"""
|
||||
raise NotImplementedError("Subclasses must implement get_model_roots")
|
||||
|
||||
async def get_all_folders(self) -> List[str]:
|
||||
"""Enumerate every directory under the model roots, live from disk.
|
||||
|
||||
Unlike the models-only ``cache.folders``, this includes empty
|
||||
directories, so it stays accurate even when the in-memory cache was
|
||||
hydrated from a persisted snapshot without a filesystem walk. Hidden
|
||||
directories (any segment starting with '.') and the pending-delete
|
||||
staging dir are excluded. The result is unioned with the model-derived
|
||||
folders so it is always a superset of ``cache.folders``, and cached
|
||||
for ``ALL_FOLDERS_CACHE_TTL_SECONDS`` to avoid repeated walks.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if self._all_folders_ttl_cache is not None:
|
||||
cached_at, cached_folders = self._all_folders_ttl_cache
|
||||
if now - cached_at < ALL_FOLDERS_CACHE_TTL_SECONDS:
|
||||
return cached_folders
|
||||
|
||||
discovered: Set[str] = set()
|
||||
visited_real_paths: Set[str] = set()
|
||||
|
||||
for root_path in self.get_model_roots():
|
||||
if not os.path.exists(root_path):
|
||||
continue
|
||||
|
||||
for root, dirnames, _files in os.walk(root_path, followlinks=True):
|
||||
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||
# realpath is used only for symlink dedup, never for the
|
||||
# recorded path (business paths stay unresolved).
|
||||
real_root = os.path.realpath(root)
|
||||
if real_root in visited_real_paths:
|
||||
continue
|
||||
visited_real_paths.add(real_root)
|
||||
|
||||
rel_dir = os.path.relpath(os.path.abspath(root), os.path.abspath(root_path))
|
||||
rel_dir = rel_dir.replace(os.path.sep, "/")
|
||||
if rel_dir != "." and not _is_hidden_relative_path(rel_dir):
|
||||
discovered.add(rel_dir)
|
||||
|
||||
folders = set(discovered)
|
||||
if self._cache is not None:
|
||||
folders |= {item.get('folder', '') for item in self._cache.raw_data}
|
||||
|
||||
result = sorted(folders, key=lambda x: x.lower())
|
||||
self._all_folders_ttl_cache = (now, result)
|
||||
return result
|
||||
|
||||
def invalidate_all_folders_cache(self) -> None:
|
||||
"""Drop the cached get_all_folders() result (e.g. after a move)."""
|
||||
self._all_folders_ttl_cache = None
|
||||
|
||||
async def _create_default_metadata(self, file_path: str) -> Optional[BaseModelMetadata]:
|
||||
"""Get model file info and metadata (extensible for different model types)"""
|
||||
@@ -1773,6 +1836,10 @@ class ModelScanner:
|
||||
|
||||
await cache.resort()
|
||||
|
||||
# A move may have created new directories; drop the cached live-walk
|
||||
# result so the next include_empty request sees them.
|
||||
self.invalidate_all_folders_cache()
|
||||
|
||||
if cache_modified:
|
||||
await self._persist_current_cache()
|
||||
self.bump_cache_version()
|
||||
|
||||
Reference in New Issue
Block a user