diff --git a/static/css/components/menu.css b/static/css/components/menu.css index 8f846a33..ed3f61bb 100644 --- a/static/css/components/menu.css +++ b/static/css/components/menu.css @@ -67,6 +67,21 @@ text-align: center; } +/* Muted counter shown next to a menu label (e.g. how many empty folders the + "Show empty folders" toggle would reveal) */ +.context-menu-count { + color: var(--text-muted); + font-size: 12px; +} + +/* The count keeps the label/tally muted even while the row is hovered, since + the accent background would otherwise wash the muted colour out. */ +.context-menu-item:hover .context-menu-count, +.context-menu-item:focus-visible .context-menu-count { + color: var(--lora-text); + opacity: 0.8; +} + /* Section Headers */ .context-menu-section-header { padding: 6px 12px 2px; diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index 5e2e94c5..ae7c347e 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -43,8 +43,9 @@ export class SidebarManager { this.isDisabledByPage = false; this.initializationPromise = null; this.isCreatingFolder = false; - this.showEmptyFolders = false; + this.showEmptyFolders = true; this.nonEmptyFolders = null; // models-only folder set used to dim empty nodes + this.emptyFolderCount = null; // null = not known yet (no models-only list) this._createFolderBasePath = null; this._createFolderTempChildren = null; // children container added for a leaf parent during inline creation this._renameFolderPath = null; @@ -135,8 +136,9 @@ export class SidebarManager { this.apiClient = null; this.isInitialized = false; this.recursiveSearchEnabled = true; - this.showEmptyFolders = false; + this.showEmptyFolders = true; this.nonEmptyFolders = null; + this.emptyFolderCount = null; this._createFolderBasePath = null; this._createFolderTempChildren = null; this._renameFolderPath = null; @@ -723,8 +725,9 @@ export class SidebarManager { const absolutePath = this.combineRootAndRelativePath(root, targetRelativePath); const result = await this.apiClient.createFolder(absolutePath); - // The new folder has no models yet — enable empty-folder display - // so it shows up in the tree immediately. + // A newly created folder is empty by definition. If the user turned + // empty-folder display off, switch it back on so the folder they + // just asked for is actually visible in the tree. if (!this.showEmptyFolders) { this.showEmptyFolders = true; setStorageItem(`${this.pageType}_showEmptyFolders`, true); @@ -1392,40 +1395,44 @@ export class SidebarManager { async loadFolderTree() { try { - const includeEmpty = this.showEmptyFolders && this._supportsFolderManagement(); + const supportsEmptyFolders = this._supportsFolderManagement(); + // The full folder list (including empty directories) and the + // models-only list are both always fetched: the first is the + // single source of truth for the tree and for the empty-folder + // count, the second is what "empty" is measured against. The + // `showEmptyFolders` preference only gates rendering, so the + // view-options menu can report the count (and hide the toggle + // when there are none) while empty folders stay hidden. if (this.displayMode === 'tree') { - if (includeEmpty) { - // Fetch the models-only folder list alongside so empty - // directories can be dimmed in the tree. - const [treeResponse, foldersResponse] = await Promise.all([ - this.apiClient.fetchUnifiedFolderTree({ includeEmpty: true }), - this.apiClient.fetchModelFolders(), - ]); - this.treeData = treeResponse.tree || {}; - this.nonEmptyFolders = this._buildNonEmptyFolderSet(foldersResponse.folders || []); - } else { - const response = await this.apiClient.fetchUnifiedFolderTree(); - this.treeData = response.tree || {}; - this.nonEmptyFolders = null; - } + const [treeResponse, foldersResponse] = await Promise.all([ + supportsEmptyFolders + ? this.apiClient.fetchUnifiedFolderTree({ includeEmpty: true }) + : this.apiClient.fetchUnifiedFolderTree(), + supportsEmptyFolders ? this.apiClient.fetchModelFolders() : Promise.resolve(null), + ]); + this.treeData = treeResponse.tree || {}; + this.nonEmptyFolders = foldersResponse + ? this._buildNonEmptyFolderSet(foldersResponse.folders || []) + : null; + this.emptyFolderCount = this._computeEmptyFolderCount(); } else { - if (includeEmpty) { - const [allFoldersResponse, foldersResponse] = await Promise.all([ - this.apiClient.fetchModelFolders({ includeEmpty: true }), - this.apiClient.fetchModelFolders(), - ]); - this.foldersList = allFoldersResponse.folders || []; - this.nonEmptyFolders = this._buildNonEmptyFolderSet(foldersResponse.folders || []); - } else { - const response = await this.apiClient.fetchModelFolders(); - this.foldersList = response.folders || []; - this.nonEmptyFolders = null; - } + const [allFoldersResponse, foldersResponse] = await Promise.all([ + this.apiClient.fetchModelFolders( + supportsEmptyFolders ? { includeEmpty: true } : undefined + ), + supportsEmptyFolders ? this.apiClient.fetchModelFolders() : Promise.resolve(null), + ]); + this.foldersList = allFoldersResponse.folders || []; + this.nonEmptyFolders = foldersResponse + ? this._buildNonEmptyFolderSet(foldersResponse.folders || []) + : null; + this.emptyFolderCount = this._computeEmptyFolderCount(); } this.folderTreeLoaded = true; this.renderFolderDisplay(); } catch (error) { this.folderTreeLoaded = false; + this.emptyFolderCount = null; console.error('Failed to load folder data:', error); this.renderEmptyState(); } @@ -1452,6 +1459,62 @@ export class SidebarManager { return set; } + /** + * Whether a folder should render in the dimmed "empty" style. + * + * The full folder list is always loaded so the empty-folder count is + * available, but the dimmed styling (like the folders themselves) is only + * shown while the show-empty-folders preference is on. + */ + _isRenderedEmptyFolder(path) { + if (!this.showEmptyFolders || !this.nonEmptyFolders) return false; + return !this.nonEmptyFolders.has(path); + } + + // Apply the show-empty-folders preference to the flat folder list. When + // the models-only set is unknown the list is passed through unchanged, so + // the filter can never hide everything. + _filterVisibleFolders(folders) { + const list = folders || []; + if (this.showEmptyFolders || !this.nonEmptyFolders) return list; + return list.filter((folder) => !folder || this.nonEmptyFolders.has(folder)); + } + + /** + * How many directories in the current view hold no models anywhere in + * their subtree, or null when the models-only list is unavailable + * (unsupported page or a failed request). Null keeps the view-options + * menu in its "not sure yet" state instead of claiming there are none. + */ + _computeEmptyFolderCount() { + if (!this.nonEmptyFolders) return null; + const known = this.displayMode === 'tree' + ? this._collectTreePaths() + : this._collectListPaths(); + return known.filter((path) => path && !this.nonEmptyFolders.has(path)).length; + } + + // Every folder path present in the current tree, including intermediate + // nodes that only exist to nest other folders. + _collectTreePaths() { + const paths = []; + const walk = (node, prefix) => { + for (const [name, children] of Object.entries(node || {})) { + const path = prefix ? `${prefix}/${name}` : name; + paths.push(path); + walk(children, path); + } + }; + walk(this.treeData, ''); + return paths; + } + + // Every folder path in the flat list view. Unlike the tree, that list + // already carries full paths, so no prefix expansion is needed. + _collectListPaths() { + return (this.foldersList || []).filter(Boolean); + } + folderExistsInTree(path) { if (!path) return true; @@ -1499,7 +1562,7 @@ export class SidebarManager { const hasChildren = Object.keys(children).length > 0; const isExpanded = this.expandedNodes.has(currentPath); const isSelected = this.selectedPath === currentPath; - const isEmpty = this.nonEmptyFolders ? !this.nonEmptyFolders.has(currentPath) : false; + const isEmpty = this._isRenderedEmptyFolder(currentPath); const escapedPath = escapeAttribute(currentPath); const escapedFolderName = escapeHtml(folderName); @@ -1549,15 +1612,21 @@ export class SidebarManager { const folderTree = document.getElementById('sidebarFolderTree'); if (!folderTree) return; - if (!this.foldersList || this.foldersList.length === 0) { + // Unlike the tree — where the backend simply omits empty directories + // when the preference is off — the flat list is always loaded in full + // (the empty-folder count and the delete guard need it), so empty + // entries are filtered out here instead. + const visibleFolders = this._filterVisibleFolders(this.foldersList); + + if (visibleFolders.length === 0) { this.renderEmptyState(); return; } - const foldersHtml = this.foldersList.map(folder => { + const foldersHtml = visibleFolders.map(folder => { const displayName = folder === '' ? '/' : folder; const isSelected = this.selectedPath === folder; - const isEmpty = this.nonEmptyFolders ? !this.nonEmptyFolders.has(folder) : false; + const isEmpty = this._isRenderedEmptyFolder(folder); const escapedPath = escapeAttribute(folder); const escapedDisplayName = escapeHtml(displayName); const escapedTitle = escapeAttribute(displayName); @@ -1860,7 +1929,9 @@ export class SidebarManager { this.showEmptyFolders = !this.showEmptyFolders; setStorageItem(`${this.pageType}_showEmptyFolders`, this.showEmptyFolders); this.updateViewOptionsMenu(); - this.loadFolderTree(); + // Both folder lists are already loaded (the count needs them), so + // showing/hiding empty folders is a pure re-render. + this.renderFolderDisplay(); } handleCreateFolderButton(event) { @@ -1974,12 +2045,29 @@ export class SidebarManager { setCheck('view-mode-list', !isTreeMode); setCheck('toggle-recursive', isTreeMode && this.recursiveSearchEnabled); setDisabled('toggle-recursive', !isTreeMode); - setCheck('toggle-empty-folders', this.showEmptyFolders && this._supportsFolderManagement()); - // Empty-folder display requires a model library backend + // Empty-folder display requires a model library backend, and the + // toggle is only worth showing when there actually are empty folders + // to reveal/hide. `null` means the folder data has not loaded yet, in + // which case the item is kept visible rather than guessed at. + const supportsFolderManagement = this._supportsFolderManagement(); + const hasEmptyFolders = !supportsFolderManagement + || this.emptyFolderCount === null + || this.emptyFolderCount > 0; + setCheck('toggle-empty-folders', this.showEmptyFolders && supportsFolderManagement); + const emptyFoldersItem = menu.querySelector('[data-action="toggle-empty-folders"]'); if (emptyFoldersItem) { - emptyFoldersItem.style.display = this._supportsFolderManagement() ? '' : 'none'; + emptyFoldersItem.style.display = supportsFolderManagement && hasEmptyFolders ? '' : 'none'; + } + + // Surface the count so the preference's effect is visible without + // opening the menu against a large library. + const emptyFoldersCount = document.getElementById('sidebarEmptyFoldersCount'); + if (emptyFoldersCount) { + emptyFoldersCount.textContent = this.emptyFolderCount + ? `(${this.emptyFolderCount})` + : ''; } } @@ -2221,7 +2309,11 @@ export class SidebarManager { this.expandedNodes = new Set(expandedPaths); this.displayMode = displayMode; this.recursiveSearchEnabled = recursiveSearchEnabled; - this.showEmptyFolders = getStorageItem(`${this.pageType}_showEmptyFolders`, false); + // Empty folders are shown by default so the sidebar matches the + // destination picker (which lists them too) instead of silently hiding + // a folder that a download just landed in. New folders created from the + // header stay visible for the same reason. + this.showEmptyFolders = getStorageItem(`${this.pageType}_showEmptyFolders`, true); this.updateSearchRecursiveOption(); this.updateFolderManagementButtons(); diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index 86f8188e..67e6d080 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -243,6 +243,7 @@
diff --git a/tests/frontend/components/sidebarManager.folderManagement.test.js b/tests/frontend/components/sidebarManager.folderManagement.test.js index 00e6c742..bef9e5a4 100644 --- a/tests/frontend/components/sidebarManager.folderManagement.test.js +++ b/tests/frontend/components/sidebarManager.folderManagement.test.js @@ -86,28 +86,33 @@ describe('SidebarManager empty folders toggle', () => { document.body.innerHTML = ''; }); - it('requests the models-only tree by default', async () => { + it('loads the full and models-only folder lists and counts the empty folders', async () => { const apiClient = createApiClient(); + apiClient.fetchUnifiedFolderTree.mockResolvedValue({ tree: { full: {}, empty: {} } }); + apiClient.fetchModelFolders.mockResolvedValue({ folders: ['', 'full'] }); const manager = createManager(apiClient); await manager.loadFolderTree(); - expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith(); - expect(apiClient.fetchModelFolders).not.toHaveBeenCalled(); - expect(manager.nonEmptyFolders).toBeNull(); - expect(manager.treeData).toEqual({ full: {}, empty: {} }); - }); - - it('includes empty folders and tracks the models-only set when enabled', async () => { - const apiClient = createApiClient(); - const manager = createManager(apiClient); - manager.showEmptyFolders = true; - - await manager.loadFolderTree(); - expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith({ includeEmpty: true }); expect(apiClient.fetchModelFolders).toHaveBeenCalledWith(); expect(manager.nonEmptyFolders).toEqual(new Set(['', 'full'])); + expect(manager.treeData).toEqual({ full: {}, empty: {} }); + expect(manager.emptyFolderCount).toBe(1); + }); + + it('keeps the folder data loaded while empty folders are hidden', async () => { + const apiClient = createApiClient(); + apiClient.fetchUnifiedFolderTree.mockResolvedValue({ tree: { full: {}, empty: {} } }); + const manager = createManager(apiClient); + manager.showEmptyFolders = false; + + await manager.loadFolderTree(); + + // The data is still fetched so the menu can report the count; only the + // rendering is gated by the preference. + expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith({ includeEmpty: true }); + expect(manager.emptyFolderCount).toBe(1); }); it('passes includeEmpty to the folder list in list display mode', async () => { @@ -116,41 +121,46 @@ describe('SidebarManager empty folders toggle', () => { .mockResolvedValueOnce({ folders: ['', 'full', 'empty'] }) .mockResolvedValueOnce({ folders: ['', 'full'] }); const manager = createManager(apiClient, { displayMode: 'list' }); - manager.showEmptyFolders = true; await manager.loadFolderTree(); expect(apiClient.fetchModelFolders).toHaveBeenNthCalledWith(1, { includeEmpty: true }); + expect(apiClient.fetchModelFolders).toHaveBeenNthCalledWith(2); expect(manager.foldersList).toEqual(['', 'full', 'empty']); expect(manager.nonEmptyFolders).toEqual(new Set(['', 'full'])); + expect(manager.emptyFolderCount).toBe(1); }); - it('ignores the preference when the page does not support folder management', async () => { + it('does not request empty folders when the page does not support folder management', async () => { const apiClient = createApiClient(); apiClient.apiConfig.config.supportsFolderManagement = false; const manager = createManager(apiClient); - manager.showEmptyFolders = true; await manager.loadFolderTree(); expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith(); + expect(apiClient.fetchModelFolders).not.toHaveBeenCalled(); expect(manager.nonEmptyFolders).toBeNull(); + expect(manager.emptyFolderCount).toBeNull(); }); - it('persists the toggle and reloads the tree', async () => { + it('persists the toggle and re-renders without refetching', () => { const apiClient = createApiClient(); const manager = createManager(apiClient); + manager.showEmptyFolders = false; manager.loadFolderTree = vi.fn(); manager.handleEmptyFoldersToggle({ stopPropagation: vi.fn() }); expect(manager.showEmptyFolders).toBe(true); expect(getStorageItem('loras_showEmptyFolders')).toBe(true); - expect(manager.loadFolderTree).toHaveBeenCalledTimes(1); + expect(manager.loadFolderTree).not.toHaveBeenCalled(); + expect(manager.renderFolderDisplay).toHaveBeenCalledTimes(1); }); it('dims folders that contain no models', () => { const manager = createManager(createApiClient()); + manager.showEmptyFolders = true; manager.treeData = { full: {}, empty: {} }; manager.nonEmptyFolders = new Set(['', 'full']); @@ -163,6 +173,7 @@ describe('SidebarManager empty folders toggle', () => { it('does not dim folders whose subtree contains models', () => { const manager = createManager(createApiClient()); + manager.showEmptyFolders = true; // Models live in "characters/anime" only; "characters" itself holds no // direct models but must not be dimmed. manager.nonEmptyFolders = manager._buildNonEmptyFolderSet(['characters/anime']); @@ -177,6 +188,48 @@ describe('SidebarManager empty folders toggle', () => { expect(animeNode[0]).not.toContain('empty'); expect(emptyNode[0]).toContain('empty'); }); + + it('does not dim folders while the preference is off', () => { + const manager = createManager(createApiClient()); + manager.showEmptyFolders = false; + manager.treeData = { full: {}, empty: {} }; + manager.nonEmptyFolders = new Set(['', 'full']); + + const html = manager.renderTreeNode(manager.treeData, ''); + + expect(html).not.toContain('sidebar-tree-node-content empty'); + }); + + describe('list view', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + it('hides empty folders from the flat list while the preference is off', () => { + const manager = createManager(createApiClient(), { displayMode: 'list' }); + manager.showEmptyFolders = false; + manager.foldersList = ['', 'full', 'empty']; + manager.nonEmptyFolders = manager._buildNonEmptyFolderSet(['full']); + + manager.renderFolderList(); + + const html = document.getElementById('sidebarFolderTree').innerHTML; + expect(html).toContain('data-path="full"'); + expect(html).not.toContain('data-path="empty"'); + }); + + it('shows empty folders dimmed in the flat list when the preference is on', () => { + const manager = createManager(createApiClient(), { displayMode: 'list' }); + manager.showEmptyFolders = true; + manager.foldersList = ['', 'full', 'empty']; + manager.nonEmptyFolders = manager._buildNonEmptyFolderSet(['full']); + + manager.renderFolderList(); + + const html = document.getElementById('sidebarFolderTree').innerHTML; + expect(html).toContain('sidebar-node-content empty" data-path="empty"'); + }); + }); }); describe('SidebarManager view options menu', () => { @@ -185,13 +238,17 @@ describe('SidebarManager view options menu', () => { - + `; function getCheck(action) { return document.querySelector(`#sidebarViewOptionsMenu [data-action="${action}"] .check-indicator`); } + function getEmptyFoldersItem() { + return document.querySelector('[data-action="toggle-empty-folders"]'); + } + beforeEach(() => { localStorage.clear(); document.body.innerHTML = MENU_HTML; @@ -227,7 +284,35 @@ describe('SidebarManager view options menu', () => { manager.updateViewOptionsMenu(); - expect(document.querySelector('[data-action="toggle-empty-folders"]').style.display).toBe('none'); + expect(getEmptyFoldersItem().style.display).toBe('none'); + }); + + it('hides the empty-folders item when the library has no empty folders', () => { + const manager = createManager(createApiClient()); + manager.emptyFolderCount = 0; + + manager.updateViewOptionsMenu(); + + expect(getEmptyFoldersItem().style.display).toBe('none'); + }); + + it('keeps the empty-folders item visible while the count is unknown', () => { + const manager = createManager(createApiClient()); + manager.emptyFolderCount = null; + + manager.updateViewOptionsMenu(); + + expect(getEmptyFoldersItem().style.display).not.toBe('none'); + }); + + it('shows the empty-folder count next to the label', () => { + const manager = createManager(createApiClient()); + manager.emptyFolderCount = 12; + + manager.updateViewOptionsMenu(); + + expect(getEmptyFoldersItem().style.display).not.toBe('none'); + expect(document.getElementById('sidebarEmptyFoldersCount').textContent).toBe('(12)'); }); it('switches display mode from the menu and closes it', () => { @@ -245,7 +330,7 @@ describe('SidebarManager view options menu', () => { it('toggles empty folders from the menu and keeps it open', () => { const manager = createManager(createApiClient()); - manager.loadFolderTree = vi.fn(); + manager.showEmptyFolders = false; const menu = document.getElementById('sidebarViewOptionsMenu'); menu.style.display = 'block'; @@ -254,6 +339,7 @@ describe('SidebarManager view options menu', () => { expect(manager.showEmptyFolders).toBe(true); expect(getCheck('toggle-empty-folders').style.display).toBe('block'); expect(menu.style.display).toBe('block'); + expect(manager.renderFolderDisplay).toHaveBeenCalled(); }); it('collapses all folders from the header button', () => { @@ -298,6 +384,29 @@ describe('SidebarManager view options menu', () => { manager.handleViewOptionsButton({ stopPropagation: vi.fn(), currentTarget: button }); expect(menu.style.display).toBe('none'); }); + + it('shows empty folders on a fresh library (default preference)', () => { + const manager = createManager(createApiClient()); + manager.updateSearchRecursiveOption = vi.fn(); + manager.updateFolderManagementButtons = vi.fn(); + manager.updateCollapseAllButton = vi.fn(); + + manager.restoreSidebarState(); + + expect(manager.showEmptyFolders).toBe(true); + }); + + it('honours a stored preference to hide empty folders', () => { + setStorageItem('loras_showEmptyFolders', false); + const manager = createManager(createApiClient()); + manager.updateSearchRecursiveOption = vi.fn(); + manager.updateFolderManagementButtons = vi.fn(); + manager.updateCollapseAllButton = vi.fn(); + + manager.restoreSidebarState(); + + expect(manager.showEmptyFolders).toBe(false); + }); }); describe('SidebarManager folder creation', () => { @@ -327,19 +436,34 @@ describe('SidebarManager folder creation', () => { const apiClient = createApiClient(); const manager = createManager(apiClient); manager.selectedPath = 'characters'; + manager.showEmptyFolders = false; manager.refresh = vi.fn().mockResolvedValue(undefined); const success = await manager._createFolder('characters/anime', 'characters'); expect(success).toBe(true); expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/characters/anime'); - // Empty-folder display is enabled so the new folder shows up immediately + // The new folder is empty, so creating it turns empty-folder display back + // on to keep the folder visible in the tree. expect(manager.showEmptyFolders).toBe(true); expect(getStorageItem('loras_showEmptyFolders')).toBe(true); expect(manager.expandedNodes.has('characters')).toBe(true); expect(manager.refresh).toHaveBeenCalledTimes(1); }); + it('re-enables empty folders when creating while the preference is off', async () => { + const apiClient = createApiClient(); + const manager = createManager(apiClient); + manager.showEmptyFolders = false; + manager.refresh = vi.fn().mockResolvedValue(undefined); + + const success = await manager._createFolder('new-folder', ''); + + expect(success).toBe(true); + expect(manager.showEmptyFolders).toBe(true); + expect(getStorageItem('loras_showEmptyFolders')).toBe(true); + }); + it('fails gracefully when no model root is configured', async () => { const apiClient = createApiClient(); apiClient.fetchModelRoots.mockResolvedValue({ roots: [] });