mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
feat(sidebar): show empty folders and create folders from the sidebar (#999)
Empty folders (tracked in the scan-recorded all_folders list, same source
the move/download destination picker uses) can now be surfaced in the
folder sidebar via a view-options toggle, dimmed when their subtree holds
no models. Folders can be created directly from the sidebar through a new
POST /api/lm/{prefix}/create-folder endpoint with library-root
containment checks; the scanner records the new directory incrementally
so the tree reflects it without a rescan.
The sidebar header moves its view toggles (tree/list, recursive, empty
folders) into a "..." menu to fit the new create-folder button.
This commit is contained in:
@@ -473,17 +473,69 @@ class ModelFileService:
|
||||
|
||||
class ModelMoveService:
|
||||
"""Service for handling individual model moves"""
|
||||
|
||||
|
||||
def __init__(self, scanner, model_type: str):
|
||||
"""Initialize the service
|
||||
|
||||
|
||||
Args:
|
||||
scanner: Model scanner instance
|
||||
model_type: Type of model (e.g., 'lora', 'checkpoint')
|
||||
"""
|
||||
self.scanner = scanner
|
||||
self.model_type = model_type
|
||||
|
||||
|
||||
async def create_folder(self, folder_path: str) -> Dict[str, Any]:
|
||||
"""Create a directory inside the model library roots.
|
||||
|
||||
Args:
|
||||
folder_path: Absolute path of the directory to create (business
|
||||
path — symlinks are not resolved)
|
||||
|
||||
Returns:
|
||||
Dictionary with success flag, the created path and the
|
||||
library-relative folder name used by folder trees.
|
||||
"""
|
||||
try:
|
||||
if not folder_path or not str(folder_path).strip():
|
||||
return {"success": False, "error": "Folder path is required"}
|
||||
|
||||
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
|
||||
|
||||
absolute_path = os.path.abspath(folder_path)
|
||||
already_exists = os.path.isdir(absolute_path)
|
||||
os.makedirs(absolute_path, exist_ok=True)
|
||||
|
||||
relative_folder = self._calculate_relative_folder(absolute_path)
|
||||
if relative_folder:
|
||||
await self.scanner.add_known_folder(relative_folder)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"folder_path": absolute_path.replace(os.sep, "/"),
|
||||
"folder": relative_folder,
|
||||
"created": not already_exists,
|
||||
}
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
except Exception as exc:
|
||||
logger.error(f"Error creating folder: {exc}", exc_info=True)
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
def _calculate_relative_folder(self, absolute_path: str) -> str:
|
||||
"""Return the library-relative folder for an absolute directory path."""
|
||||
normalized = os.path.abspath(absolute_path)
|
||||
for root in self.scanner.get_model_roots():
|
||||
abs_root = os.path.abspath(root)
|
||||
try:
|
||||
rel = os.path.relpath(normalized, abs_root)
|
||||
except ValueError:
|
||||
continue
|
||||
if rel == ".":
|
||||
return ""
|
||||
if not rel.startswith(".."):
|
||||
return rel.replace(os.sep, "/")
|
||||
return ""
|
||||
|
||||
async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
|
||||
"""Move a single model file
|
||||
|
||||
|
||||
@@ -1477,6 +1477,34 @@ class ModelScanner:
|
||||
|
||||
return sorted(folders, key=lambda x: x.lower())
|
||||
|
||||
async def add_known_folder(self, folder: str) -> None:
|
||||
"""Record a folder (and its parents) in the known folder list.
|
||||
|
||||
Called when a directory is created between scans (e.g. via the
|
||||
create-folder API) so folder trees reflect it immediately without
|
||||
waiting for the next reconciliation. When ``all_folders`` has not
|
||||
been recorded yet (legacy snapshot), this is a no-op — the scheduled
|
||||
backfill walk discovers the directory from disk instead.
|
||||
"""
|
||||
normalized = folder.replace("\\", "/").strip("/")
|
||||
parts = [part for part in normalized.split("/") if part]
|
||||
if not parts:
|
||||
return
|
||||
cache = self._cache
|
||||
if cache is None:
|
||||
return
|
||||
recorded = getattr(cache, "all_folders", None)
|
||||
if recorded is None:
|
||||
return
|
||||
known = set(recorded)
|
||||
for i in range(1, len(parts) + 1):
|
||||
known.add("/".join(parts[:i]))
|
||||
updated = sorted(known, key=lambda x: x.lower())
|
||||
if updated != list(recorded):
|
||||
cache.all_folders = updated
|
||||
await self._persist_current_cache()
|
||||
self.bump_cache_version()
|
||||
|
||||
def _schedule_all_folders_backfill(self) -> None:
|
||||
"""Kick off a one-shot background folder walk if none is running."""
|
||||
if self._all_folders_backfill_running:
|
||||
|
||||
Reference in New Issue
Block a user