fix(ui): show empty folders as move and download destinations (#999)

This commit is contained in:
Will Miao
2026-08-23 21:09:16 +08:00
parent 030a32f8fa
commit c2360a35ad
12 changed files with 630 additions and 19 deletions
+19 -4
View File
@@ -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)
+15 -4
View File
@@ -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
+72 -5
View File
@@ -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()