feat(sidebar): scan a single folder from the folder context menu (#1108)

The Refresh menu can scope a scan to one model root, but the folder a user is
looking at lives in the sidebar's unified tree, which merges every root into one
relative-path namespace. "Scan this folder" therefore addresses the folder, not a
root: the backend walks that relative path under every root that holds it, which
is also what makes the action safe while another drive is switched off.

Backend:
* GET /scan accepts `folder=<rel>` (alone or with `roots=`), rejected together
  with full_rebuild=true like the roots parameter. Validation reuses
  normalize_relative_folder(), extracted to module scope from ModelMoveService so
  the folder operations and the scan endpoint reject the same input (absolute
  paths, drive letters, `..` climbing) instead of each carrying its own copy.
* The reconcile summary carries `scope_label` (the folder) for a folder scope, so
  the result toast names the folder the user clicked instead of the roots it
  happens to live under; the completed WS payload carries it too.
* `folder` is a scope prefix exactly like a root: only that subtree is re-read or
  pruned, and an unreachable root keeps the entries that fall inside it.

Frontend:
* The sidebar folder context menu gains "Scan this folder" above "Check for
  updates in this folder" (they share the refresh divider); the entry is gated by
  the same supportsFolderManagement flag as the other folder operations.
* SidebarManager.scanFolder() resolves the node through the existing
  _resolveFolderCandidates() before doing anything: a folder no root holds any
  more explains itself ("no longer exists on disk") instead of scanning nothing,
  and an unresolvable multi-root node is refused rather than guessed.
* PageControls.refreshModels() and BaseModelApi.refreshModels() forward the folder
  scope, and _showRefreshSummary() prefers scope_label over the walked roots.

Verified live on the three-root sandbox with one drive switched off:
GET /scan?folder=pack000 walks drive-G and drive-Y, reports scope_label=pack000,
keeps drive-Z's 6 entries under that folder (kept_unreachable=6) and leaves all
420 models cached. 3704 passed, 7 skipped; frontend 1495 passed (150 files); vue
widgets 96 passed. The 2 new sidebar keys are [TODO: Translate] placeholders
pending the feature owner's go-ahead.
This commit is contained in:
Will Miao
2026-10-07 15:15:35 +08:00
parent bd184559bf
commit 12930ce78c
23 changed files with 465 additions and 55 deletions
+22 -4
View File
@@ -25,7 +25,10 @@ from ...services.connectivity_guard import (
is_expected_offline_error,
)
from ...services.metadata_sync_service import MetadataSyncService
from ...services.model_file_service import ModelMoveService
from ...services.model_file_service import (
ModelMoveService,
normalize_relative_folder,
)
from ...services.model_scanner import ReconcileScope
from ...services.preview_asset_service import PreviewAssetService
from ...services.service_registry import ServiceRegistry
@@ -1143,7 +1146,8 @@ class ModelQueryHandler:
requested_roots = [
value for value in request.query.getall("roots", []) if value
]
if requested_roots and full_rebuild:
raw_folder = (request.query.get("folder") or "").strip()
if (requested_roots or raw_folder) and full_rebuild:
return web.json_response(
{
"error": "Scoped scans are not supported with "
@@ -1152,7 +1156,13 @@ class ModelQueryHandler:
status=400,
)
scope = None
folder = None
if raw_folder:
try:
folder = normalize_relative_folder(raw_folder)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
if requested_roots:
configured = self._service.get_model_roots()
unknown = [root for root in requested_roots if root not in configured]
@@ -1160,7 +1170,15 @@ class ModelQueryHandler:
return web.json_response(
{"error": "Unknown model root(s)", "roots": unknown}, status=400
)
scope = ReconcileScope(roots=tuple(requested_roots))
# `folder` alone means "this relative folder in every root that has
# it" — the sidebar's unified tree carries no root identity.
scope = None
if requested_roots or folder:
scope = ReconcileScope(
roots=tuple(requested_roots) if requested_roots else None,
folder=folder,
)
summary = await self._service.scan_models(
force_refresh=True, rebuild_cache=full_rebuild, scope=scope
+29 -23
View File
@@ -16,6 +16,33 @@ from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
logger = logging.getLogger(__name__)
def normalize_relative_folder(folder: str) -> str:
"""Normalize a library-relative folder path, raising ``ValueError``.
Absolute paths (POSIX, drive-letter or UNC) and paths that climb out of the
library root are refused: both mean the caller is confused about which space
it is working in, and guessing would be worse than an error. Shared by the
folder operations and the scoped scan endpoint so both reject the same input.
"""
raw = str(folder or "").strip()
if not raw:
raise ValueError("Folder path is required")
normalized = raw.replace("\\", "/")
if normalized.startswith("/") or (len(normalized) > 1 and normalized[1] == ":"):
raise ValueError("Folder path must be relative to a library root")
normalized = os.path.normpath(normalized)
if (
normalized == ".."
or normalized.startswith("../")
or normalized.startswith(".." + os.sep)
):
raise ValueError("Folder path must stay inside the library root")
return normalized
def _normalize_match_path(path: Any) -> str:
"""Normalize a path for set membership tests.
@@ -616,29 +643,8 @@ class ModelMoveService:
@staticmethod
def _normalize_relative_folder(folder: str) -> str:
"""Normalize a library-relative folder path, raising ``ValueError``.
Absolute paths (POSIX, drive-letter or UNC) and paths that climb out of
the library root are refused: both mean the caller is confused about
which space it is working in, and guessing would be worse than an error.
"""
raw = str(folder or "").strip()
if not raw:
raise ValueError("Folder path is required")
normalized = raw.replace("\\", "/")
if normalized.startswith("/") or (len(normalized) > 1 and normalized[1] == ":"):
raise ValueError("Folder path must be relative to a library root")
normalized = os.path.normpath(normalized)
if (
normalized == ".."
or normalized.startswith("../")
or normalized.startswith(".." + os.sep)
):
raise ValueError("Folder path must stay inside the library root")
return normalized
"""Normalize a library-relative folder path, raising ``ValueError``."""
return normalize_relative_folder(folder)
async def delete_folder(self, folder_path: str, dry_run: bool = False) -> Dict[str, Any]:
"""Delete a model-free directory inside the model library roots.
+3
View File
@@ -2083,6 +2083,9 @@ class ModelScanner:
'removed': total_removed,
'repaired': total_repaired,
'scanned_roots': [all_labels.get(root, root) for root in roots],
# A folder scope is named by the folder the user clicked, not by
# the roots it happens to live under.
'scope_label': scope_folder or '',
'skipped_roots': skipped_roots,
'unavailable_paths': unreachable.payload(),
'unavailable_paths_total': unreachable.count(),