diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index 8b7554ff..da96696e 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -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 diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index 7f700a21..5d373c66 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -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) { diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index 8c21643a..f90e4806 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -80,10 +80,10 @@ export class SidebarManager { this.apiClient = pageControls?.getSidebarApiClient?.() || pageControls?.sidebarApiClient || getModelApiClient(); - + // Set initial sidebar state immediately (hidden by default) this.setInitialSidebarState(); - + this.setupEventHandlers(); this.initializeDragAndDrop(); this.updateSidebarTitle(); @@ -94,13 +94,13 @@ export class SidebarManager { return; } this.restoreSelectedFolder(); - + // Apply final state with animation after everything is loaded this.applyFinalSidebarState(); - + // Update container margin based on initial sidebar state this.updateContainerMargin(); - + this.isInitialized = true; console.log(`SidebarManager initialized for ${this.pageType} page`); } @@ -113,7 +113,7 @@ export class SidebarManager { clearTimeout(this.hoverTimeout); this.hoverTimeout = null; } - + // Clean up event handlers this.removeEventHandlers(); @@ -143,13 +143,13 @@ export class SidebarManager { this.apiClient = null; this.isInitialized = false; this.recursiveSearchEnabled = true; - + // Reset container margin const container = document.querySelector('.container'); if (container) { container.style.marginLeft = ''; } - + // Remove resize event listener window.removeEventListener('resize', this.updateContainerMargin); @@ -191,10 +191,10 @@ export class SidebarManager { hoverArea.removeEventListener('mouseenter', this.handleHoverAreaEnter); hoverArea.removeEventListener('mouseleave', this.handleHoverAreaLeave); } - + // Remove document click handler document.removeEventListener('click', this.handleDocumentClick); - + // Remove resize event handler window.removeEventListener('resize', this.updateContainerMargin); @@ -486,20 +486,20 @@ export class SidebarManager { this.apiClient = this.pageControls?.getSidebarApiClient?.() || this.pageControls?.sidebarApiClient || getModelApiClient(); - + // Set initial sidebar state immediately (hidden by default) this.setInitialSidebarState(); - + this.setupEventHandlers(); this.initializeDragAndDrop(); this.updateSidebarTitle(); this.restoreSidebarState(); await this.loadFolderTree(); this.restoreSelectedFolder(); - + // Apply final state with animation after everything is loaded this.applyFinalSidebarState(); - + // Update container margin based on initial sidebar state this.updateContainerMargin(); } @@ -511,11 +511,11 @@ export class SidebarManager { const hoverArea = document.getElementById('sidebarHoverArea'); if (!sidebar || !hoverArea) return; - + // Get stored pin state const isPinned = getStorageItem(`${this.pageType}_sidebarPinned`, true); this.isPinned = isPinned; - + // Sidebar starts hidden by default (CSS handles this) // Just set up the hover area state if (window.innerWidth <= 1024) { @@ -583,12 +583,12 @@ export class SidebarManager { // Hover detection for auto-hide const sidebar = document.getElementById('folderSidebar'); const hoverArea = document.getElementById('sidebarHoverArea'); - + if (sidebar) { sidebar.addEventListener('mouseenter', this.handleMouseEnter); sidebar.addEventListener('mouseleave', this.handleMouseLeave); } - + if (hoverArea) { hoverArea.addEventListener('mouseenter', this.handleHoverAreaEnter); hoverArea.addEventListener('mouseleave', this.handleHoverAreaLeave); @@ -598,7 +598,7 @@ export class SidebarManager { document.addEventListener('click', (e) => { if (window.innerWidth <= 1024 && this.isVisible) { const sidebar = document.getElementById('folderSidebar'); - + if (sidebar && !sidebar.contains(e.target)) { this.hideSidebar(); } @@ -613,7 +613,7 @@ export class SidebarManager { // Add document click handler for closing dropdowns document.addEventListener('click', this.handleDocumentClick); - + // Add dedicated resize listener for container margin updates window.addEventListener('resize', this.updateContainerMargin); @@ -660,7 +660,7 @@ export class SidebarManager { clearTimeout(this.hoverTimeout); this.hoverTimeout = null; } - + if (!this.isPinned) { this.showSidebar(); } @@ -710,9 +710,9 @@ export class SidebarManager { const sidebar = document.getElementById('folderSidebar'); const hoverArea = document.getElementById('sidebarHoverArea'); - + if (!sidebar || !hoverArea) return; - + if (window.innerWidth <= 1024) { // Mobile: always use collapsed state sidebar.classList.remove('auto-hide', 'hover-active', 'visible'); @@ -730,7 +730,7 @@ export class SidebarManager { sidebar.classList.remove('collapsed', 'visible'); sidebar.classList.add('auto-hide'); hoverArea.classList.remove('disabled'); - + if (this.isHovering) { sidebar.classList.add('hover-active'); this.isVisible = true; @@ -739,7 +739,7 @@ export class SidebarManager { this.isVisible = false; } } - + // Update container margin when sidebar state changes this.updateContainerMargin(); } @@ -750,16 +750,16 @@ export class SidebarManager { const sidebar = document.getElementById('folderSidebar'); if (!container || !sidebar || this.isDisabledBySetting) return; - + // Reset margin to default container.style.marginLeft = ''; - + // Only adjust margin if sidebar is visible and pinned if ((this.isPinned || this.isHovering) && this.isVisible) { const sidebarWidth = sidebar.offsetWidth; const viewportWidth = window.innerWidth; const containerWidth = container.offsetWidth; - + // Check if there's enough space for both sidebar and container // We need: sidebar width + container width + some padding < viewport width if (sidebarWidth + containerWidth + sidebarWidth > viewportWidth) { @@ -837,8 +837,8 @@ export class SidebarManager { const pinBtn = document.getElementById('sidebarPinToggle'); if (pinBtn) { pinBtn.classList.toggle('active', this.isPinned); - pinBtn.title = this.isPinned - ? translate('sidebar.unpinSidebar') + pinBtn.title = this.isPinned + ? translate('sidebar.unpinSidebar') : translate('sidebar.pinSidebar'); } } @@ -883,13 +883,13 @@ export class SidebarManager { renderTreeNode(nodeData, basePath) { const entries = Object.entries(nodeData); if (entries.length === 0) return ''; - + return entries.map(([folderName, children]) => { const currentPath = basePath ? `${basePath}/${folderName}` : folderName; const hasChildren = Object.keys(children).length > 0; const isExpanded = this.expandedNodes.has(currentPath); const isSelected = this.selectedPath === currentPath; - + return ` `); - + // Add separator and placeholder for next level if not the last item if (isLast) { const childFolders = this.getChildFolders(currentPath); @@ -1323,22 +1327,22 @@ export class SidebarManager { `).join('') - } + } `); } } }); - + sidebarBreadcrumbNav.innerHTML = breadcrumbs.join(''); } updateSidebarHeader() { 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'); @@ -1348,11 +1352,11 @@ export class SidebarManager { toggleSidebar() { const sidebar = document.getElementById('folderSidebar'); const toggleBtn = document.querySelector('.sidebar-toggle-btn'); - + if (!sidebar) return; - + this.isVisible = !this.isVisible; - + if (this.isVisible) { sidebar.classList.remove('collapsed'); sidebar.classList.add('visible'); @@ -1360,28 +1364,28 @@ export class SidebarManager { sidebar.classList.remove('visible'); sidebar.classList.add('collapsed'); } - + if (toggleBtn) { toggleBtn.classList.toggle('active', this.isVisible); } - + this.saveSidebarState(); } closeSidebar() { const sidebar = document.getElementById('folderSidebar'); const toggleBtn = document.querySelector('.sidebar-toggle-btn'); - + if (!sidebar) return; - + this.isVisible = false; sidebar.classList.remove('visible'); sidebar.classList.add('collapsed'); - + if (toggleBtn) { toggleBtn.classList.remove('active'); } - + this.saveSidebarState(); } @@ -1390,12 +1394,12 @@ export class SidebarManager { const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []); const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree' const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true); - + this.isPinned = isPinned; this.expandedNodes = new Set(expandedPaths); this.displayMode = displayMode; this.recursiveSearchEnabled = recursiveSearchEnabled; - + this.updatePinButton(); this.updateDisplayModeButton(); this.updateCollapseAllButton(); diff --git a/tests/services/test_root_folder_recursive.py b/tests/services/test_root_folder_recursive.py new file mode 100644 index 00000000..4d51178c --- /dev/null +++ b/tests/services/test_root_folder_recursive.py @@ -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"