diff --git a/docs/plans/issue-1108-scoped-scan.md b/docs/plans/issue-1108-scoped-scan.md index c121045f..6b0e504f 100644 --- a/docs/plans/issue-1108-scoped-scan.md +++ b/docs/plans/issue-1108-scoped-scan.md @@ -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=` parameter: walk `/` 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=` in the scan endpoint + `_ReconcileScope.folder`, the sidebar menu entry, the +14. `folder=` 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. diff --git a/locales/de.json b/locales/de.json index 7224c690..c3f90a2c 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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": { diff --git a/locales/en.json b/locales/en.json index ff08bf1e..baae5c95 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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": { diff --git a/locales/es.json b/locales/es.json index 91cf539c..486c4ebf 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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": { diff --git a/locales/fr.json b/locales/fr.json index c455988f..64426195 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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": { diff --git a/locales/he.json b/locales/he.json index 8108df3a..29b95117 100644 --- a/locales/he.json +++ b/locales/he.json @@ -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": { diff --git a/locales/ja.json b/locales/ja.json index a516818b..cbd936c7 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -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": { diff --git a/locales/ko.json b/locales/ko.json index feb94d68..708363f6 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -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": { diff --git a/locales/ru.json b/locales/ru.json index cc8f97b8..8a5c9b68 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 195912e9..654ad167 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -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": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 12bc1297..e2a95592 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -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": { diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 1adc9d39..ec471734 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -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 diff --git a/py/services/model_file_service.py b/py/services/model_file_service.py index a44b2d10..56190581 100644 --- a/py/services/model_file_service.py +++ b/py/services/model_file_service.py @@ -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. diff --git a/py/services/model_scanner.py b/py/services/model_scanner.py index b982e015..f0db90a5 100644 --- a/py/services/model_scanner.py +++ b/py/services/model_scanner.py @@ -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(), diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index e00f8494..f7439cb0 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -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} 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', { diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index 1ca319a3..09c0edff 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -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'); diff --git a/static/js/components/controls/PageControls.js b/static/js/components/controls/PageControls.js index 089ad9c6..ce36b796 100644 --- a/static/js/components/controls/PageControls.js +++ b/static/js/components/controls/PageControls.js @@ -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); } diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index 67e6d080..efd513ff 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -210,6 +210,9 @@ dividers are collapsed by SidebarManager when a group is hidden on the current page (recipes keep only the update check). -->
+
+ {{ t('sidebar.scanFolder') }} +
{{ t('sidebar.folderUpdateCheck.label') }}
diff --git a/tests/frontend/api/baseModelApi.refresh.test.js b/tests/frontend/api/baseModelApi.refresh.test.js index 4d4a29d5..490024f2 100644 --- a/tests/frontend/api/baseModelApi.refresh.test.js +++ b/tests/frontend/api/baseModelApi.refresh.test.js @@ -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(); diff --git a/tests/frontend/components/sidebarManager.scanFolder.test.js b/tests/frontend/components/sidebarManager.scanFolder.test.js new file mode 100644 index 00000000..e5d65aef --- /dev/null +++ b/tests/frontend/components/sidebarManager.scanFolder.test.js @@ -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(); + }); +}); diff --git a/tests/frontend/regression/sidebarFolderContextMenu.test.js b/tests/frontend/regression/sidebarFolderContextMenu.test.js index cecba33a..ac8e725f 100644 --- a/tests/frontend/regression/sidebarFolderContextMenu.test.js +++ b/tests/frontend/regression/sidebarFolderContextMenu.test.js @@ -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', diff --git a/tests/routes/test_model_query_handler.py b/tests/routes/test_model_query_handler.py index f333eea4..90dc7360 100644 --- a/tests/routes/test_model_query_handler.py +++ b/tests/routes/test_model_query_handler.py @@ -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"]) diff --git a/tests/services/test_model_scanner.py b/tests/services/test_model_scanner.py index ae6f63bf..63580577 100644 --- a/tests/services/test_model_scanner.py +++ b/tests/services/test_model_scanner.py @@ -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