feat: Add recursive root folder scanning with API and UI updates. fixes #737

This commit is contained in:
Will Miao
2025-12-25 21:07:52 +08:00
parent e5869648fb
commit 91cd88f1df
4 changed files with 187 additions and 91 deletions

View File

@@ -1267,13 +1267,13 @@ class RecipeScanner:
# Skip further filtering if we're only filtering by LoRA hash with bypass enabled # Skip further filtering if we're only filtering by LoRA hash with bypass enabled
if not (lora_hash and bypass_filters): if not (lora_hash and bypass_filters):
# Apply folder filter before other criteria # Apply folder filter before other criteria
normalized_folder = (folder or "").strip("/") if folder is not None:
if normalized_folder: normalized_folder = folder.strip("/")
def matches_folder(item_folder: str) -> bool: def matches_folder(item_folder: str) -> bool:
item_path = (item_folder or "").strip("/") item_path = (item_folder or "").strip("/")
if not item_path:
return False
if recursive: if recursive:
if not normalized_folder:
return True
return item_path == normalized_folder or item_path.startswith(f"{normalized_folder}/") return item_path == normalized_folder or item_path.startswith(f"{normalized_folder}/")
return item_path == normalized_folder return item_path == normalized_folder

View File

@@ -51,7 +51,7 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
params.append('favorite', 'true'); params.append('favorite', 'true');
} }
if (pageState.activeFolder) { if (pageState.activeFolder !== null && pageState.activeFolder !== undefined) {
params.append('folder', pageState.activeFolder); params.append('folder', pageState.activeFolder);
params.append('recursive', pageState.searchOptions?.recursive !== false); params.append('recursive', pageState.searchOptions?.recursive !== false);
} else if (pageState.searchOptions?.recursive !== undefined) { } else if (pageState.searchOptions?.recursive !== undefined) {

View File

@@ -1025,8 +1025,11 @@ export class SidebarManager {
} }
async selectFolder(path) { async selectFolder(path) {
// Normalize path: null or undefined means root
const normalizedPath = (path === null || path === undefined) ? '' : path;
// Update selected path // Update selected path
this.selectedPath = path; this.selectedPath = normalizedPath;
// Update UI // Update UI
this.updateTreeSelection(); this.updateTreeSelection();
@@ -1034,8 +1037,8 @@ export class SidebarManager {
this.updateSidebarHeader(); this.updateSidebarHeader();
// Update page state // Update page state
this.pageControls.pageState.activeFolder = path; this.pageControls.pageState.activeFolder = normalizedPath;
setStorageItem(`${this.pageType}_activeFolder`, path); setStorageItem(`${this.pageType}_activeFolder`, normalizedPath);
// Reload models with new filter // Reload models with new filter
await this.pageControls.resetAndReload(); await this.pageControls.resetAndReload();
@@ -1158,7 +1161,7 @@ export class SidebarManager {
}); });
// Add selection to current path // Add selection to current path
if (this.selectedPath !== null) { if (this.selectedPath !== null && this.selectedPath !== undefined) {
const selectedItem = folderTree.querySelector(`[data-path="${this.selectedPath}"]`); const selectedItem = folderTree.querySelector(`[data-path="${this.selectedPath}"]`);
if (selectedItem) { if (selectedItem) {
selectedItem.classList.add('selected'); selectedItem.classList.add('selected');
@@ -1169,7 +1172,7 @@ export class SidebarManager {
node.classList.remove('selected'); node.classList.remove('selected');
}); });
if (this.selectedPath) { if (this.selectedPath !== null && this.selectedPath !== undefined) {
const selectedNode = folderTree.querySelector(`[data-path="${this.selectedPath}"] .sidebar-tree-node-content`); const selectedNode = folderTree.querySelector(`[data-path="${this.selectedPath}"] .sidebar-tree-node-content`);
if (selectedNode) { if (selectedNode) {
selectedNode.classList.add('selected'); selectedNode.classList.add('selected');
@@ -1240,9 +1243,10 @@ export class SidebarManager {
// Start with root breadcrumb // Start with root breadcrumb
const rootSiblings = Object.keys(this.treeData); const rootSiblings = Object.keys(this.treeData);
const isRootSelected = !this.selectedPath;
const breadcrumbs = [` const breadcrumbs = [`
<div class="breadcrumb-dropdown"> <div class="breadcrumb-dropdown">
<span class="sidebar-breadcrumb-item ${this.selectedPath == null ? 'active' : ''}" data-path=""> <span class="sidebar-breadcrumb-item ${isRootSelected ? 'active' : ''}" data-path="">
<i class="fas fa-home"></i> ${this.apiClient.apiConfig.config.displayName} root <i class="fas fa-home"></i> ${this.apiClient.apiConfig.config.displayName} root
</span> </span>
</div> </div>
@@ -1266,7 +1270,7 @@ export class SidebarManager {
<div class="breadcrumb-dropdown-item" data-path="${folder}"> <div class="breadcrumb-dropdown-item" data-path="${folder}">
${folder} ${folder}
</div>`).join('') </div>`).join('')
} }
</div> </div>
</div> </div>
`); `);
@@ -1299,7 +1303,7 @@ export class SidebarManager {
data-path="${currentPath.replace(part, folder)}"> data-path="${currentPath.replace(part, folder)}">
${folder} ${folder}
</div>`).join('') </div>`).join('')
} }
</div> </div>
` : ''} ` : ''}
</div> </div>
@@ -1323,7 +1327,7 @@ export class SidebarManager {
<div class="breadcrumb-dropdown-item" data-path="${currentPath}/${folder}"> <div class="breadcrumb-dropdown-item" data-path="${currentPath}/${folder}">
${folder} ${folder}
</div>`).join('') </div>`).join('')
} }
</div> </div>
</div> </div>
`); `);
@@ -1338,7 +1342,7 @@ export class SidebarManager {
const sidebarHeader = document.getElementById('sidebarHeader'); const sidebarHeader = document.getElementById('sidebarHeader');
if (!sidebarHeader) return; if (!sidebarHeader) return;
if (this.selectedPath == null) { if (!this.selectedPath) {
sidebarHeader.classList.add('root-selected'); sidebarHeader.classList.add('root-selected');
} else { } else {
sidebarHeader.classList.remove('root-selected'); sidebarHeader.classList.remove('root-selected');

View File

@@ -0,0 +1,92 @@
import pytest
from py.services.model_query import ModelFilterSet, FilterCriteria
from py.services.recipe_scanner import RecipeScanner
from types import SimpleNamespace
# Mock settings
class MockSettings:
def get(self, key, default=None):
return default
# --- Model Filtering Tests ---
def test_model_filter_set_root_recursive_true():
filter_set = ModelFilterSet(MockSettings())
items = [
{"model_name": "root_item", "folder": ""},
{"model_name": "sub_item", "folder": "sub"},
]
criteria = FilterCriteria(folder="", search_options={"recursive": True})
result = filter_set.apply(items, criteria)
assert len(result) == 2
assert any(i["model_name"] == "root_item" for i in result)
assert any(i["model_name"] == "sub_item" for i in result)
def test_model_filter_set_root_recursive_false():
filter_set = ModelFilterSet(MockSettings())
items = [
{"model_name": "root_item", "folder": ""},
{"model_name": "sub_item", "folder": "sub"},
]
criteria = FilterCriteria(folder="", search_options={"recursive": False})
result = filter_set.apply(items, criteria)
assert len(result) == 1
assert result[0]["model_name"] == "root_item"
# --- Recipe Filtering Tests ---
@pytest.mark.asyncio
async def test_recipe_scanner_root_recursive_true():
# Mock LoraScanner
class StubLoraScanner:
async def get_cached_data(self):
return SimpleNamespace(raw_data=[])
scanner = RecipeScanner(lora_scanner=StubLoraScanner())
# Manually populate cache for testing get_paginated_data logic
scanner._cache = SimpleNamespace(
raw_data=[
{"id": "r1", "title": "root_recipe", "folder": "", "modified": 1.0, "created_date": 1.0, "loras": []},
{"id": "r2", "title": "sub_recipe", "folder": "sub", "modified": 2.0, "created_date": 2.0, "loras": []},
],
sorted_by_date=[
{"id": "r2", "title": "sub_recipe", "folder": "sub", "modified": 2.0, "created_date": 2.0, "loras": []},
{"id": "r1", "title": "root_recipe", "folder": "", "modified": 1.0, "created_date": 1.0, "loras": []},
],
sorted_by_name=[],
version_index={}
)
result = await scanner.get_paginated_data(page=1, page_size=10, folder="", recursive=True)
assert len(result["items"]) == 2
@pytest.mark.asyncio
async def test_recipe_scanner_root_recursive_false():
# Mock LoraScanner
class StubLoraScanner:
async def get_cached_data(self):
return SimpleNamespace(raw_data=[])
scanner = RecipeScanner(lora_scanner=StubLoraScanner())
scanner._cache = SimpleNamespace(
raw_data=[
{"id": "r1", "title": "root_recipe", "folder": "", "modified": 1.0, "created_date": 1.0, "loras": []},
{"id": "r2", "title": "sub_recipe", "folder": "sub", "modified": 2.0, "created_date": 2.0, "loras": []},
],
sorted_by_date=[
{"id": "r2", "title": "sub_recipe", "folder": "sub", "modified": 2.0, "created_date": 2.0, "loras": []},
{"id": "r1", "title": "root_recipe", "folder": "", "modified": 1.0, "created_date": 1.0, "loras": []},
],
sorted_by_name=[],
version_index={}
)
result = await scanner.get_paginated_data(page=1, page_size=10, folder="", recursive=False)
assert len(result["items"]) == 1
assert result["items"][0]["id"] == "r1"