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
if not (lora_hash and bypass_filters):
# Apply folder filter before other criteria
normalized_folder = (folder or "").strip("/")
if normalized_folder:
if folder is not None:
normalized_folder = folder.strip("/")
def matches_folder(item_folder: str) -> bool:
item_path = (item_folder or "").strip("/")
if not item_path:
return False
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

View File

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

View File

@@ -1025,8 +1025,11 @@ export class SidebarManager {
}
async selectFolder(path) {
// Normalize path: null or undefined means root
const normalizedPath = (path === null || path === undefined) ? '' : path;
// Update selected path
this.selectedPath = path;
this.selectedPath = normalizedPath;
// Update UI
this.updateTreeSelection();
@@ -1034,8 +1037,8 @@ export class SidebarManager {
this.updateSidebarHeader();
// Update page state
this.pageControls.pageState.activeFolder = path;
setStorageItem(`${this.pageType}_activeFolder`, path);
this.pageControls.pageState.activeFolder = normalizedPath;
setStorageItem(`${this.pageType}_activeFolder`, normalizedPath);
// Reload models with new filter
await this.pageControls.resetAndReload();
@@ -1158,7 +1161,7 @@ export class SidebarManager {
});
// Add selection to current path
if (this.selectedPath !== null) {
if (this.selectedPath !== null && this.selectedPath !== undefined) {
const selectedItem = folderTree.querySelector(`[data-path="${this.selectedPath}"]`);
if (selectedItem) {
selectedItem.classList.add('selected');
@@ -1169,7 +1172,7 @@ export class SidebarManager {
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`);
if (selectedNode) {
selectedNode.classList.add('selected');
@@ -1240,9 +1243,10 @@ export class SidebarManager {
// Start with root breadcrumb
const rootSiblings = Object.keys(this.treeData);
const isRootSelected = !this.selectedPath;
const breadcrumbs = [`
<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
</span>
</div>
@@ -1338,7 +1342,7 @@ export class SidebarManager {
const sidebarHeader = document.getElementById('sidebarHeader');
if (!sidebarHeader) return;
if (this.selectedPath == null) {
if (!this.selectedPath) {
sidebarHeader.classList.add('root-selected');
} else {
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"