mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 07:05:43 -03:00
feat: Add recursive root folder scanning with API and UI updates. fixes #737
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
92
tests/services/test_root_folder_recursive.py
Normal file
92
tests/services/test_root_folder_recursive.py
Normal 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"
|
||||||
Reference in New Issue
Block a user