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
+34 -13
View File
@@ -2,9 +2,10 @@
**Issue:** [#1108](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1108) — scan a single
folder/root instead of the whole library.
**Status:** **P1 implemented** (2026-10-07, uncommitted working tree). P2 (folder scope, Wave 5)
is not implemented. Supersedes the earlier draft's per-root *status panel* and persisted
*unreachable-subtree* state (both dropped, see "Must NOT have").
**Status:** **P1 and P2 implemented** (2026-10-07). P1 shipped in `470d85cc` (translations in
`bd184559`); P2 (folder scope + the sidebar entry) is in the working tree. Supersedes the earlier
draft's per-root *status panel* and persisted *unreachable-subtree* state (both dropped, see
"Must NOT have").
Implementation notes / deviations from the draft below:
@@ -90,14 +91,20 @@ and in the exact pruning predicate; both get explicit tests.
- i18n keys in `locales/en.json` + `python scripts/sync_translation_keys.py`.
- pytest + vitest coverage for every item above.
### Must have (P2, optional follow-up)
### Must have (P2, implemented)
- `folder=<rel>` parameter: walk `<root>/<rel>` for every reachable root that contains it (the
sidebar's unified tree has no root identity, so "this folder" means "this relative path in all
roots"), prune only inside that prefix.
roots"), prune only inside that prefix. Validation reuses `normalize_relative_folder()` (extracted
from `ModelMoveService` so the folder operations and the scan endpoint reject the same input:
absolute paths, drive letters, `..` climbing). The summary carries `scope_label` = the folder, so
the toast names the folder the user clicked rather than the roots it happens to live under.
- Sidebar folder context menu entry "Scan this folder" (`templates/components/context_menu.html`
`#sidebarFolderContextMenu`, above `check-folder-updates`; update
`tests/frontend/regression/sidebarFolderContextMenu.test.js` expectations).
`tests/frontend/regression/sidebarFolderContextMenu.test.js` expectations). The entry is gated
like the other folder operations and resolves the folder through the existing
`_resolveFolderCandidates()` before scanning, so a folder no root holds any more explains itself
instead of scanning nothing. It shares `check-folder-updates`'s divider (both are refresh-ish).
### Must NOT have (guardrails)
@@ -250,11 +257,19 @@ and in the exact pruning predicate; both get explicit tests.
(`LM_WALK_DELAY_S`-style `sitecustomize` hook) to eyeball the scoped progress line, the offline
row and the toast — the user verifies by eye.
### Wave 5 — P2 (folder scope), only if approved
### Wave 5 — P2 (folder scope) — done
14. `folder=<rel>` in the scan endpoint + `_ReconcileScope.folder`, the sidebar menu entry, the
14. `folder=<rel>` in the scan endpoint + `ReconcileScope.folder`, the sidebar menu entry, the
context-menu regression test update, and the "folder in several roots" semantics (scan every
reachable root that has the relative path; report per root).
* `normalize_relative_folder()` extracted to module scope in `model_file_service.py` (the
private static method now delegates) and reused by the scan handler.
* `scanFolder()` in `SidebarManager` reuses `_resolveFolderCandidatesSafe()` and delegates to the
host page controls, which pass `{ folder }` through `registerAPI`'s argument-forwarding
`refreshModels`.
* Verified live: `folder=pack000` on the three-root sandbox 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.
## Known limitations (accepted)
@@ -275,9 +290,15 @@ and in the exact pruning predicate; both get explicit tests.
## Verification checklist
- [ ] `pytest -q` green, `npx vitest run` green, `npm run test:vue` green.
- [ ] `python scripts/sync_translation_keys.py --dry-run` reports no pending changes.
- [ ] Sandbox: 2 roots, one offline → scoped scan of the online root reports `added`, keeps the
offline root's models, and the grid/sidebar still show them.
- [ ] Sandbox: preview of an offline root's model returns 404 and the DB keeps `preview_url`.
- [x] `pytest -q` green (3704 passed, 7 skipped), `npx vitest run` green (1495 passed),
`npm run test:vue` green (96 passed).
- [x] `python scripts/sync_translation_keys.py --dry-run` reports no pending changes.
- [x] Sandbox: 3 roots, one offline → scoped scan of one root walks only it; a full refresh keeps
the offline root's models (`kept_unreachable=60`) and the grid still shows all 420.
- [x] Sandbox: folder scope (`folder=pack000`) walks the two reachable roots, labels the scan by the
folder and keeps the offline root's 6 entries under it.
- [ ] Sandbox: preview of an offline root's model returns 404 and the DB keeps `preview_url`
(test-locked; not eyeballed in the sandbox because the demo models have no previews).
- [ ] Release note wording agreed for the behaviour change (decision 1).
- [ ] The 2 new sidebar keys (`sidebar.scanFolder`, `sidebar.scanFolderResult.missing`) are
`[TODO: Translate]` placeholders pending the feature owner's go-ahead.