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.
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+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(),
+12 -5
View File
@@ -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',
{
+29 -1
View File
@@ -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);
}
+3
View File
@@ -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',
+59
View File
@@ -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"])
+40
View File
@@ -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