mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-07 18:12:12 -03:00
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:
@@ -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.
|
||||
|
||||
@@ -1482,6 +1482,10 @@
|
||||
"success": "{count} Update(s) für {type}s in diesem Ordner gefunden",
|
||||
"none": "Alle {type}s in diesem Ordner sind aktuell",
|
||||
"error": "Fehler beim Prüfen des Ordners auf {type}-Updates: {message}"
|
||||
},
|
||||
"scanFolder": "[TODO: Translate] Scan this folder",
|
||||
"scanFolderResult": {
|
||||
"missing": "[TODO: Translate] This folder no longer exists on disk. Refresh the sidebar and try again."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
|
||||
@@ -1482,6 +1482,10 @@
|
||||
"success": "Found {count} update(s) for {type}s in this folder",
|
||||
"none": "All {type}s in this folder are up to date",
|
||||
"error": "Failed to check folder for {type} updates: {message}"
|
||||
},
|
||||
"scanFolder": "Scan this folder",
|
||||
"scanFolderResult": {
|
||||
"missing": "This folder no longer exists on disk. Refresh the sidebar and try again."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
|
||||
@@ -1482,6 +1482,10 @@
|
||||
"success": "Se encontraron {count} actualización(es) para {type}s en esta carpeta",
|
||||
"none": "Todos los {type}s en esta carpeta están actualizados",
|
||||
"error": "Error al buscar actualizaciones de {type} en la carpeta: {message}"
|
||||
},
|
||||
"scanFolder": "[TODO: Translate] Scan this folder",
|
||||
"scanFolderResult": {
|
||||
"missing": "[TODO: Translate] This folder no longer exists on disk. Refresh the sidebar and try again."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
|
||||
@@ -1482,6 +1482,10 @@
|
||||
"success": "{count} mise(s) à jour trouvée(s) pour les {type}s dans ce dossier",
|
||||
"none": "Tous les {type}s dans ce dossier sont à jour",
|
||||
"error": "Échec de la vérification des mises à jour {type} dans ce dossier : {message}"
|
||||
},
|
||||
"scanFolder": "[TODO: Translate] Scan this folder",
|
||||
"scanFolderResult": {
|
||||
"missing": "[TODO: Translate] This folder no longer exists on disk. Refresh the sidebar and try again."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
|
||||
@@ -1482,6 +1482,10 @@
|
||||
"success": "נמצאו {count} עדכון/ים עבור {type}s בתיקייה זו",
|
||||
"none": "כל ה-{type}s בתיקייה זו מעודכנים",
|
||||
"error": "נכשל בבדיקת עדכוני {type} בתיקייה: {message}"
|
||||
},
|
||||
"scanFolder": "[TODO: Translate] Scan this folder",
|
||||
"scanFolderResult": {
|
||||
"missing": "[TODO: Translate] This folder no longer exists on disk. Refresh the sidebar and try again."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
|
||||
@@ -1482,6 +1482,10 @@
|
||||
"success": "このフォルダの{type}sに{count}件のアップデートが見つかりました",
|
||||
"none": "このフォルダのすべての{type}sは最新です",
|
||||
"error": "フォルダの{type}アップデート確認に失敗しました: {message}"
|
||||
},
|
||||
"scanFolder": "[TODO: Translate] Scan this folder",
|
||||
"scanFolderResult": {
|
||||
"missing": "[TODO: Translate] This folder no longer exists on disk. Refresh the sidebar and try again."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
|
||||
@@ -1482,6 +1482,10 @@
|
||||
"success": "이 폴더에서 {type}s에 대한 {count}개 업데이트를 찾았습니다",
|
||||
"none": "이 폴더의 모든 {type}s가 최신 상태입니다",
|
||||
"error": "폴더의 {type} 업데이트 확인 실패: {message}"
|
||||
},
|
||||
"scanFolder": "[TODO: Translate] Scan this folder",
|
||||
"scanFolderResult": {
|
||||
"missing": "[TODO: Translate] This folder no longer exists on disk. Refresh the sidebar and try again."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
|
||||
@@ -1482,6 +1482,10 @@
|
||||
"success": "Найдено {count} обновление(й) для {type}s в этой папке",
|
||||
"none": "Все {type}s в этой папке актуальны",
|
||||
"error": "Не удалось проверить папку на наличие обновлений {type}: {message}"
|
||||
},
|
||||
"scanFolder": "[TODO: Translate] Scan this folder",
|
||||
"scanFolderResult": {
|
||||
"missing": "[TODO: Translate] This folder no longer exists on disk. Refresh the sidebar and try again."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
|
||||
@@ -1482,6 +1482,10 @@
|
||||
"success": "在此文件夹中找到 {count} 个{type}更新",
|
||||
"none": "此文件夹中的所有{type}都是最新版本",
|
||||
"error": "检查文件夹{type}更新失败: {message}"
|
||||
},
|
||||
"scanFolder": "[TODO: Translate] Scan this folder",
|
||||
"scanFolderResult": {
|
||||
"missing": "[TODO: Translate] This folder no longer exists on disk. Refresh the sidebar and try again."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
|
||||
@@ -1482,6 +1482,10 @@
|
||||
"success": "在此資料夾中找到 {count} 個{type}更新",
|
||||
"none": "此資料夾中的所有{type}都是最新版本",
|
||||
"error": "檢查資料夾{type}更新失敗: {message}"
|
||||
},
|
||||
"scanFolder": "[TODO: Translate] Scan this folder",
|
||||
"scanFolderResult": {
|
||||
"missing": "[TODO: Translate] This folder no longer exists on disk. Refresh the sidebar and try again."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -511,9 +511,10 @@ export class BaseModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshModels(fullRebuild = false, { roots = null } = {}) {
|
||||
async refreshModels(fullRebuild = false, { roots = null, folder = null } = {}) {
|
||||
const abortController = new AbortController();
|
||||
const scopeRoots = Array.isArray(roots) ? roots.filter(Boolean) : [];
|
||||
const scopeFolder = typeof folder === 'string' && folder ? folder : null;
|
||||
const displayName = this.apiConfig.config.displayName;
|
||||
const singularName = this.apiConfig.config.singularName;
|
||||
const actionText = translate(
|
||||
@@ -604,6 +605,9 @@ export class BaseModelApiClient {
|
||||
for (const root of scopeRoots) {
|
||||
url.searchParams.append('roots', root);
|
||||
}
|
||||
if (scopeFolder) {
|
||||
url.searchParams.append('folder', scopeFolder);
|
||||
}
|
||||
|
||||
const response = await fetch(url, { signal: abortController.signal });
|
||||
|
||||
@@ -619,7 +623,7 @@ export class BaseModelApiClient {
|
||||
|
||||
resetAndReload(true);
|
||||
|
||||
this._showRefreshSummary(data, actionText, scopeRoots);
|
||||
this._showRefreshSummary(data, actionText, scopeRoots, scopeFolder);
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
showToast('toast.api.operationCancelled', {}, 'info');
|
||||
@@ -646,12 +650,15 @@ export class BaseModelApiClient {
|
||||
* @param {string} actionText - Localized "Refresh" / "Full rebuild"
|
||||
* @param {Array<string>} scopeRoots - Roots the scan was restricted to
|
||||
*/
|
||||
_showRefreshSummary(summary, actionText, scopeRoots = []) {
|
||||
_showRefreshSummary(summary, actionText, scopeRoots = [], scopeFolder = null) {
|
||||
const payload = summary || {};
|
||||
const scannedRoots = Array.isArray(payload.scanned_roots) ? payload.scanned_roots : [];
|
||||
const scopeLabel = scannedRoots.length ? scannedRoots.join(', ') : '';
|
||||
// A folder scan is named by the folder the user clicked; a root scan by
|
||||
// the roots it walked.
|
||||
const scopeLabel = payload.scope_label
|
||||
|| (scannedRoots.length ? scannedRoots.join(', ') : '');
|
||||
|
||||
if (scopeRoots.length && scopeLabel) {
|
||||
if ((scopeRoots.length || scopeFolder) && scopeLabel) {
|
||||
showToast(
|
||||
'toast.api.refreshCompleteScoped',
|
||||
{
|
||||
|
||||
@@ -2588,7 +2588,7 @@ export class SidebarManager {
|
||||
// collapsed afterwards so such a page shows the update check alone
|
||||
// instead of dangling separators.
|
||||
const supportsFolderManagement = this._supportsFolderManagement();
|
||||
for (const action of ['create-subfolder', 'rename-folder', 'delete-folder']) {
|
||||
for (const action of ['scan-folder', 'create-subfolder', 'rename-folder', 'delete-folder']) {
|
||||
const item = menu.querySelector(`[data-action="${action}"]`);
|
||||
if (item) {
|
||||
item.style.display = supportsFolderManagement ? '' : 'none';
|
||||
@@ -2684,6 +2684,9 @@ export class SidebarManager {
|
||||
case 'delete-folder':
|
||||
this.showDeleteFolderModal(path);
|
||||
break;
|
||||
case 'scan-folder':
|
||||
await this.scanFolder(path);
|
||||
break;
|
||||
case 'check-folder-updates':
|
||||
try {
|
||||
await performFolderUpdateCheck(path);
|
||||
@@ -2696,6 +2699,31 @@ export class SidebarManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the clicked folder (and its subtree) only.
|
||||
*
|
||||
* The sidebar tree is a merged relative-path namespace, so the scan is
|
||||
* addressed by the relative folder and the backend walks that path under
|
||||
* every root that holds it — the same resolution the folder operations use.
|
||||
* Nothing outside the folder is re-read or removed, which is what makes this
|
||||
* safe to use while another drive is switched off.
|
||||
*/
|
||||
async scanFolder(relativePath) {
|
||||
const resolution = await this._resolveFolderCandidatesSafe(relativePath);
|
||||
if (!resolution || resolution.candidates.length === 0) {
|
||||
showToast('sidebar.scanFolderResult.missing', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const controls = this.pageControls || this.lastPageControls;
|
||||
if (!controls || typeof controls.refreshModels !== 'function') {
|
||||
console.error('[SidebarManager] No page controls registered for a folder scan');
|
||||
return;
|
||||
}
|
||||
|
||||
await controls.refreshModels(false, { folder: relativePath });
|
||||
}
|
||||
|
||||
handleBreadcrumbClick(event) {
|
||||
const breadcrumbItem = event.target.closest('.sidebar-breadcrumb-item');
|
||||
const dropdownItem = event.target.closest('.breadcrumb-dropdown-item');
|
||||
|
||||
@@ -562,7 +562,7 @@ export class PageControls {
|
||||
* Refresh models list
|
||||
* @param {boolean} fullRebuild - Whether to perform a full rebuild
|
||||
*/
|
||||
async refreshModels(fullRebuild = false, { roots = null } = {}) {
|
||||
async refreshModels(fullRebuild = false, { roots = null, folder = null } = {}) {
|
||||
if (!this.api) {
|
||||
console.error('API methods not registered');
|
||||
return;
|
||||
@@ -572,10 +572,14 @@ export class PageControls {
|
||||
// model counts definitely did.
|
||||
this._scanScopeDetails = null;
|
||||
const scopedRoots = Array.isArray(roots) ? roots.filter(Boolean) : [];
|
||||
const scopedFolder = typeof folder === 'string' && folder ? folder : null;
|
||||
|
||||
try {
|
||||
if (scopedRoots.length) {
|
||||
await this.api.refreshModels(fullRebuild, { roots: scopedRoots });
|
||||
if (scopedRoots.length || scopedFolder) {
|
||||
await this.api.refreshModels(fullRebuild, {
|
||||
...(scopedRoots.length ? { roots: scopedRoots } : {}),
|
||||
...(scopedFolder ? { folder: scopedFolder } : {}),
|
||||
});
|
||||
} else {
|
||||
await this.api.refreshModels(fullRebuild);
|
||||
}
|
||||
|
||||
@@ -210,6 +210,9 @@
|
||||
dividers are collapsed by SidebarManager when a group is hidden on the
|
||||
current page (recipes keep only the update check). -->
|
||||
<div id="sidebarFolderContextMenu" class="context-menu">
|
||||
<div class="context-menu-item" data-action="scan-folder">
|
||||
<i class="fas fa-sync"></i> <span>{{ t('sidebar.scanFolder') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="check-folder-updates">
|
||||
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span>
|
||||
</div>
|
||||
|
||||
@@ -436,6 +436,43 @@ describe('BaseModelApiClient.refreshModels scan progress', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('requests a folder scan without a root', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'success' }),
|
||||
});
|
||||
|
||||
const client = await createClient();
|
||||
await client.refreshModels(false, { folder: 'pack/sub' });
|
||||
|
||||
const [url] = global.fetch.mock.calls[0];
|
||||
// The backend walks this relative path under every root that holds it.
|
||||
expect(url.searchParams.get('folder')).toBe('pack/sub');
|
||||
expect(url.searchParams.getAll('roots')).toEqual([]);
|
||||
});
|
||||
|
||||
it('names a folder scan by the folder, not by the roots it walked', async () => {
|
||||
const fetchControl = mockFetchPending();
|
||||
const client = await createClient();
|
||||
const { promise } = await startRefresh(client, false, { folder: 'pack' });
|
||||
|
||||
fetchControl.resolveOk({
|
||||
status: 'success',
|
||||
scope_label: 'pack',
|
||||
scanned_roots: ['a/loras', 'usb/loras'],
|
||||
added: 3,
|
||||
removed: 0,
|
||||
kept_unreachable: 0,
|
||||
});
|
||||
await promise;
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.api.refreshCompleteScoped',
|
||||
{ scope: 'pack', added: 3, removed: 0 },
|
||||
'success'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the generic completion toast for a full-library scan', async () => {
|
||||
const fetchControl = mockFetchPending();
|
||||
const client = await createClient();
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
SIDEBAR_MANAGER_MODULE,
|
||||
STORAGE_HELPERS_MODULE,
|
||||
MODEL_API_FACTORY_MODULE,
|
||||
I18N_MODULE,
|
||||
BULK_MANAGER_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
UPDATE_CHECK_MODULE,
|
||||
STATE_MODULE,
|
||||
MODAL_MANAGER_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
|
||||
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||
MODEL_API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
UPDATE_CHECK_MODULE: new URL('../../../static/js/utils/updateCheckHelpers.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(MODEL_API_FACTORY_MODULE, () => ({ getModelApiClient: vi.fn() }));
|
||||
vi.mock(I18N_MODULE, () => ({ translate: (key, _args, fallback) => fallback || key }));
|
||||
vi.mock(BULK_MANAGER_MODULE, () => ({ bulkManager: {} }));
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn(), showActionToast: vi.fn() }));
|
||||
vi.mock(UPDATE_CHECK_MODULE, () => ({ performFolderUpdateCheck: vi.fn() }));
|
||||
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
|
||||
}));
|
||||
|
||||
const { SidebarManager } = await import(SIDEBAR_MANAGER_MODULE);
|
||||
const { showToast } = await import(UI_HELPERS_MODULE);
|
||||
|
||||
function createApiClient(overrides = {}) {
|
||||
return {
|
||||
apiConfig: {
|
||||
config: {
|
||||
displayName: 'LoRA',
|
||||
singularName: 'lora',
|
||||
supportsMove: true,
|
||||
supportsFolderManagement: true,
|
||||
},
|
||||
},
|
||||
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/models/loras', '/mnt/usb/loras'] }),
|
||||
resolveFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
folder: 'pack',
|
||||
candidates: [
|
||||
{ folder_path: '/models/loras/pack', root: '/models/loras', is_symlink: false },
|
||||
{ folder_path: '/mnt/usb/loras/pack', root: '/mnt/usb/loras', is_symlink: false },
|
||||
],
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createManager(apiClient) {
|
||||
const manager = new SidebarManager();
|
||||
manager.pageType = 'loras';
|
||||
manager.apiClient = apiClient;
|
||||
manager.renderFolderDisplay = vi.fn();
|
||||
manager.renderEmptyState = vi.fn();
|
||||
return manager;
|
||||
}
|
||||
|
||||
describe('SidebarManager folder scan', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = '';
|
||||
showToast.mockClear();
|
||||
});
|
||||
|
||||
it('scans the relative folder instead of guessing a root', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
const refreshModels = vi.fn().mockResolvedValue(undefined);
|
||||
manager.pageControls = { refreshModels };
|
||||
|
||||
await manager.scanFolder('pack');
|
||||
|
||||
// The backend walks this relative path under every root that holds it, so
|
||||
// the request carries no root at all.
|
||||
expect(refreshModels).toHaveBeenCalledWith(false, { folder: 'pack' });
|
||||
expect(showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the registered page controls after a re-init', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
manager.pageControls = null;
|
||||
manager.lastPageControls = { refreshModels: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
await manager.scanFolder('pack');
|
||||
|
||||
expect(manager.lastPageControls.refreshModels).toHaveBeenCalledWith(false, { folder: 'pack' });
|
||||
});
|
||||
|
||||
it('explains a folder that no root holds any more', async () => {
|
||||
const apiClient = createApiClient({
|
||||
resolveFolder: vi.fn().mockResolvedValue({ success: true, folder: 'gone', candidates: [] }),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
const refreshModels = vi.fn();
|
||||
manager.pageControls = { refreshModels };
|
||||
|
||||
await manager.scanFolder('gone');
|
||||
|
||||
expect(refreshModels).not.toHaveBeenCalled();
|
||||
expect(showToast).toHaveBeenCalledWith('sidebar.scanFolderResult.missing', {}, 'warning');
|
||||
});
|
||||
|
||||
it('reports a failed resolution without scanning', async () => {
|
||||
const apiClient = createApiClient({
|
||||
resolveFolder: vi.fn().mockRejectedValue(new Error('offline')),
|
||||
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/models/loras', '/mnt/usb/loras'] }),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
const refreshModels = vi.fn();
|
||||
manager.pageControls = { refreshModels };
|
||||
|
||||
await manager.scanFolder('pack');
|
||||
|
||||
// Multi-root + no resolver: guessing a root is exactly what the folder
|
||||
// operations refuse to do, and a scan must not guess either.
|
||||
expect(refreshModels).not.toHaveBeenCalled();
|
||||
expect(showToast).toHaveBeenCalledWith('sidebar.scanFolderResult.missing', {}, 'warning');
|
||||
});
|
||||
|
||||
it('does nothing when no page controls are registered', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
manager.pageControls = null;
|
||||
manager.lastPageControls = null;
|
||||
|
||||
await manager.scanFolder('pack');
|
||||
|
||||
expect(showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,12 @@ import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Regression guard for the sidebar folder context-menu layout: the update check
|
||||
// sits on top, the folder operations form a single group, and the destructive
|
||||
// entry stays last behind its own divider. SidebarManager gates those groups
|
||||
// per page and collapses the dividers when a group is hidden, so a reorder here
|
||||
// also changes what the recipes page shows.
|
||||
// Regression guard for the sidebar folder context-menu layout: the two refresh
|
||||
// actions (scan this folder, check for updates) sit on top, the folder
|
||||
// operations form a single group, and the destructive entry stays last behind
|
||||
// its own divider. SidebarManager gates those groups per page and collapses the
|
||||
// dividers when a group is hidden, so a reorder here also changes what the
|
||||
// recipes page shows.
|
||||
describe('Sidebar folder context menu layout', () => {
|
||||
const repoRoot = path.resolve(__dirname, '../../..');
|
||||
const html = readFileSync(
|
||||
@@ -24,8 +25,9 @@ describe('Sidebar folder context menu layout', () => {
|
||||
return /data-action="([^"]+)"/.exec(rest)?.[1] || null;
|
||||
});
|
||||
|
||||
it('keeps the update check first and the folder operations grouped', () => {
|
||||
it('keeps the refresh actions first and the folder operations grouped', () => {
|
||||
expect(sequence).toEqual([
|
||||
'scan-folder',
|
||||
'check-folder-updates',
|
||||
'separator',
|
||||
'create-subfolder',
|
||||
|
||||
@@ -307,6 +307,65 @@ async def test_scan_models_rejects_roots_with_full_rebuild():
|
||||
assert service.received_scope == "not-called"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_models_accepts_folder_param():
|
||||
service = ScopedScanService(roots=["/mnt/a", "/mnt/b"], summary=SUMMARY)
|
||||
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||
|
||||
response = await handler.scan_models(
|
||||
SimpleNamespace(query=QueryParams({"folder": "pack\\sub"}))
|
||||
)
|
||||
|
||||
assert response.status == 200
|
||||
assert service.received_scope is not None
|
||||
# Backslashes are normalized and no root is pinned: the backend walks this
|
||||
# relative folder under every root that holds it.
|
||||
assert service.received_scope.folder == "pack/sub"
|
||||
assert service.received_scope.roots is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_models_rejects_absolute_folder():
|
||||
service = ScopedScanService(roots=["/mnt/a"], summary=SUMMARY)
|
||||
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||
|
||||
for folder in ("/mnt/a/pack", "../pack", "C:/models/pack"):
|
||||
response = await handler.scan_models(
|
||||
SimpleNamespace(query=QueryParams({"folder": folder}))
|
||||
)
|
||||
assert response.status == 400, folder
|
||||
assert "error" in json.loads(response.text)
|
||||
|
||||
assert service.received_scope == "not-called"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_models_rejects_folder_with_full_rebuild():
|
||||
service = ScopedScanService(roots=["/mnt/a"], summary=SUMMARY)
|
||||
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||
|
||||
response = await handler.scan_models(
|
||||
SimpleNamespace(query=QueryParams({"folder": "pack", "full_rebuild": "true"}))
|
||||
)
|
||||
|
||||
assert response.status == 400
|
||||
assert service.received_scope == "not-called"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_models_combines_folder_and_roots():
|
||||
service = ScopedScanService(roots=["/mnt/a", "/mnt/b"], summary=SUMMARY)
|
||||
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||
|
||||
response = await handler.scan_models(
|
||||
SimpleNamespace(query=QueryParams({"folder": "pack", "roots": ["/mnt/b"]}))
|
||||
)
|
||||
|
||||
assert response.status == 200
|
||||
assert service.received_scope.folder == "pack"
|
||||
assert service.received_scope.roots == ("/mnt/b",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_model_roots_reports_details():
|
||||
service = ScopedScanService(roots=["/mnt/a", "/mnt/b"])
|
||||
|
||||
@@ -2609,6 +2609,46 @@ async def test_reconcile_folder_scope_keeps_folder_tree_outside_scope(tmp_path:
|
||||
assert {item["file_path"] for item in scanner._cache.raw_data} == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_folder_scope_covers_every_root_holding_it(tmp_path: Path):
|
||||
"""The sidebar's unified tree has no root identity: a folder scope means
|
||||
"this relative folder under every root that holds it"."""
|
||||
primary = tmp_path / "primary"
|
||||
usb = tmp_path / "usb"
|
||||
for root in (primary, usb):
|
||||
(root / "pack").mkdir(parents=True)
|
||||
(root / "other").mkdir()
|
||||
(root / "other" / "keep.txt").write_text("k", encoding="utf-8")
|
||||
(primary / "pack" / "a.txt").write_text("a", encoding="utf-8")
|
||||
(usb / "pack" / "b.txt").write_text("b", encoding="utf-8")
|
||||
# Only the primary root holds this folder; the usb copy is gone.
|
||||
(primary / "only-primary").mkdir()
|
||||
(primary / "only-primary" / "c.txt").write_text("c", encoding="utf-8")
|
||||
(usb / "only-primary").mkdir()
|
||||
|
||||
scanner = MultiRootDummyScanner([primary, usb])
|
||||
await scanner._initialize_cache()
|
||||
|
||||
(usb / "pack" / "b.txt").unlink()
|
||||
(primary / "only-primary" / "c.txt").unlink()
|
||||
(primary / "pack" / "new.txt").write_text("n", encoding="utf-8")
|
||||
|
||||
summary = await scanner._reconcile_cache(scope=ReconcileScope(folder="pack"))
|
||||
|
||||
assert summary is not None
|
||||
assert summary["scope_label"] == "pack"
|
||||
assert summary["added"] == 1
|
||||
assert summary["removed"] == 1
|
||||
paths = {item["file_path"] for item in scanner._cache.raw_data}
|
||||
# Both copies of the scoped folder were walked...
|
||||
assert _normalize_path(primary / "pack" / "new.txt") in paths
|
||||
assert _normalize_path(usb / "pack" / "b.txt") not in paths
|
||||
# ...and nothing outside it was touched, including the folder that only one
|
||||
# root holds.
|
||||
assert _normalize_path(usb / "other" / "keep.txt") in paths
|
||||
assert _normalize_path(primary / "only-primary" / "c.txt") in paths
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_keeps_entries_under_unreadable_dir(tmp_path: Path, monkeypatch):
|
||||
"""A directory os.walk cannot enter (permissions, I/O error, offline
|
||||
|
||||
Reference in New Issue
Block a user