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:
Will Miao
2026-09-15 15:14:56 +08:00
parent 2ceb1e2850
commit 9734df15b4
24 changed files with 1260 additions and 173 deletions
+55 -3
View File
@@ -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
+28
View 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: