mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-22 11:34:08 -03:00
feat(sidebar): show empty folders by default (#999)
The sidebar used the models-only folder list while the download and move destination pickers list every directory, so a model downloaded into an empty category folder did not appear in the sidebar at all. Empty folders are also deliberate organization on disk, and a file-manager-shaped tree that hides them is surprising. Default the preference to on. Because the preference no longer gates fetching, both folder lists are always loaded: the full list is the tree's single source of truth and the empty-folder count, the models-only list is what "empty" is measured against. That makes the view-options toggle a pure re-render, and lets the new count decorate the "..." menu so the preference's effect is visible without scanning the tree: - empty-folder count shown next to the menu label, cleared when unknown - the toggle is hidden entirely when there are no empty folders - list view filters empty entries itself (the tree gets that for free from the backend, the flat list is now always loaded in full) - creating a folder still re-enables the preference, since the folder the user just asked for would otherwise be invisible Dim styling is decided by _isRenderedEmptyFolder() at render time; the models-only set keeps its ancestor-expanded semantics for the delete guard.
This commit is contained in:
@@ -67,6 +67,21 @@
|
|||||||
text-align: center;
|
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 */
|
/* Section Headers */
|
||||||
.context-menu-section-header {
|
.context-menu-section-header {
|
||||||
padding: 6px 12px 2px;
|
padding: 6px 12px 2px;
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ export class SidebarManager {
|
|||||||
this.isDisabledByPage = false;
|
this.isDisabledByPage = false;
|
||||||
this.initializationPromise = null;
|
this.initializationPromise = null;
|
||||||
this.isCreatingFolder = false;
|
this.isCreatingFolder = false;
|
||||||
this.showEmptyFolders = false;
|
this.showEmptyFolders = true;
|
||||||
this.nonEmptyFolders = null; // models-only folder set used to dim empty nodes
|
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._createFolderBasePath = null;
|
||||||
this._createFolderTempChildren = null; // children container added for a leaf parent during inline creation
|
this._createFolderTempChildren = null; // children container added for a leaf parent during inline creation
|
||||||
this._renameFolderPath = null;
|
this._renameFolderPath = null;
|
||||||
@@ -135,8 +136,9 @@ export class SidebarManager {
|
|||||||
this.apiClient = null;
|
this.apiClient = null;
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
this.recursiveSearchEnabled = true;
|
this.recursiveSearchEnabled = true;
|
||||||
this.showEmptyFolders = false;
|
this.showEmptyFolders = true;
|
||||||
this.nonEmptyFolders = null;
|
this.nonEmptyFolders = null;
|
||||||
|
this.emptyFolderCount = null;
|
||||||
this._createFolderBasePath = null;
|
this._createFolderBasePath = null;
|
||||||
this._createFolderTempChildren = null;
|
this._createFolderTempChildren = null;
|
||||||
this._renameFolderPath = null;
|
this._renameFolderPath = null;
|
||||||
@@ -723,8 +725,9 @@ export class SidebarManager {
|
|||||||
const absolutePath = this.combineRootAndRelativePath(root, targetRelativePath);
|
const absolutePath = this.combineRootAndRelativePath(root, targetRelativePath);
|
||||||
const result = await this.apiClient.createFolder(absolutePath);
|
const result = await this.apiClient.createFolder(absolutePath);
|
||||||
|
|
||||||
// The new folder has no models yet — enable empty-folder display
|
// A newly created folder is empty by definition. If the user turned
|
||||||
// so it shows up in the tree immediately.
|
// empty-folder display off, switch it back on so the folder they
|
||||||
|
// just asked for is actually visible in the tree.
|
||||||
if (!this.showEmptyFolders) {
|
if (!this.showEmptyFolders) {
|
||||||
this.showEmptyFolders = true;
|
this.showEmptyFolders = true;
|
||||||
setStorageItem(`${this.pageType}_showEmptyFolders`, true);
|
setStorageItem(`${this.pageType}_showEmptyFolders`, true);
|
||||||
@@ -1392,40 +1395,44 @@ export class SidebarManager {
|
|||||||
|
|
||||||
async loadFolderTree() {
|
async loadFolderTree() {
|
||||||
try {
|
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 (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([
|
const [treeResponse, foldersResponse] = await Promise.all([
|
||||||
this.apiClient.fetchUnifiedFolderTree({ includeEmpty: true }),
|
supportsEmptyFolders
|
||||||
this.apiClient.fetchModelFolders(),
|
? this.apiClient.fetchUnifiedFolderTree({ includeEmpty: true })
|
||||||
|
: this.apiClient.fetchUnifiedFolderTree(),
|
||||||
|
supportsEmptyFolders ? this.apiClient.fetchModelFolders() : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
this.treeData = treeResponse.tree || {};
|
this.treeData = treeResponse.tree || {};
|
||||||
this.nonEmptyFolders = this._buildNonEmptyFolderSet(foldersResponse.folders || []);
|
this.nonEmptyFolders = foldersResponse
|
||||||
|
? this._buildNonEmptyFolderSet(foldersResponse.folders || [])
|
||||||
|
: null;
|
||||||
|
this.emptyFolderCount = this._computeEmptyFolderCount();
|
||||||
} else {
|
} else {
|
||||||
const response = await this.apiClient.fetchUnifiedFolderTree();
|
|
||||||
this.treeData = response.tree || {};
|
|
||||||
this.nonEmptyFolders = null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (includeEmpty) {
|
|
||||||
const [allFoldersResponse, foldersResponse] = await Promise.all([
|
const [allFoldersResponse, foldersResponse] = await Promise.all([
|
||||||
this.apiClient.fetchModelFolders({ includeEmpty: true }),
|
this.apiClient.fetchModelFolders(
|
||||||
this.apiClient.fetchModelFolders(),
|
supportsEmptyFolders ? { includeEmpty: true } : undefined
|
||||||
|
),
|
||||||
|
supportsEmptyFolders ? this.apiClient.fetchModelFolders() : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
this.foldersList = allFoldersResponse.folders || [];
|
this.foldersList = allFoldersResponse.folders || [];
|
||||||
this.nonEmptyFolders = this._buildNonEmptyFolderSet(foldersResponse.folders || []);
|
this.nonEmptyFolders = foldersResponse
|
||||||
} else {
|
? this._buildNonEmptyFolderSet(foldersResponse.folders || [])
|
||||||
const response = await this.apiClient.fetchModelFolders();
|
: null;
|
||||||
this.foldersList = response.folders || [];
|
this.emptyFolderCount = this._computeEmptyFolderCount();
|
||||||
this.nonEmptyFolders = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.folderTreeLoaded = true;
|
this.folderTreeLoaded = true;
|
||||||
this.renderFolderDisplay();
|
this.renderFolderDisplay();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.folderTreeLoaded = false;
|
this.folderTreeLoaded = false;
|
||||||
|
this.emptyFolderCount = null;
|
||||||
console.error('Failed to load folder data:', error);
|
console.error('Failed to load folder data:', error);
|
||||||
this.renderEmptyState();
|
this.renderEmptyState();
|
||||||
}
|
}
|
||||||
@@ -1452,6 +1459,62 @@ export class SidebarManager {
|
|||||||
return set;
|
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) {
|
folderExistsInTree(path) {
|
||||||
if (!path) return true;
|
if (!path) return true;
|
||||||
|
|
||||||
@@ -1499,7 +1562,7 @@ export class SidebarManager {
|
|||||||
const hasChildren = Object.keys(children).length > 0;
|
const hasChildren = Object.keys(children).length > 0;
|
||||||
const isExpanded = this.expandedNodes.has(currentPath);
|
const isExpanded = this.expandedNodes.has(currentPath);
|
||||||
const isSelected = this.selectedPath === 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 escapedPath = escapeAttribute(currentPath);
|
||||||
const escapedFolderName = escapeHtml(folderName);
|
const escapedFolderName = escapeHtml(folderName);
|
||||||
@@ -1549,15 +1612,21 @@ export class SidebarManager {
|
|||||||
const folderTree = document.getElementById('sidebarFolderTree');
|
const folderTree = document.getElementById('sidebarFolderTree');
|
||||||
if (!folderTree) return;
|
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();
|
this.renderEmptyState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const foldersHtml = this.foldersList.map(folder => {
|
const foldersHtml = visibleFolders.map(folder => {
|
||||||
const displayName = folder === '' ? '/' : folder;
|
const displayName = folder === '' ? '/' : folder;
|
||||||
const isSelected = this.selectedPath === 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 escapedPath = escapeAttribute(folder);
|
||||||
const escapedDisplayName = escapeHtml(displayName);
|
const escapedDisplayName = escapeHtml(displayName);
|
||||||
const escapedTitle = escapeAttribute(displayName);
|
const escapedTitle = escapeAttribute(displayName);
|
||||||
@@ -1860,7 +1929,9 @@ export class SidebarManager {
|
|||||||
this.showEmptyFolders = !this.showEmptyFolders;
|
this.showEmptyFolders = !this.showEmptyFolders;
|
||||||
setStorageItem(`${this.pageType}_showEmptyFolders`, this.showEmptyFolders);
|
setStorageItem(`${this.pageType}_showEmptyFolders`, this.showEmptyFolders);
|
||||||
this.updateViewOptionsMenu();
|
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) {
|
handleCreateFolderButton(event) {
|
||||||
@@ -1974,12 +2045,29 @@ export class SidebarManager {
|
|||||||
setCheck('view-mode-list', !isTreeMode);
|
setCheck('view-mode-list', !isTreeMode);
|
||||||
setCheck('toggle-recursive', isTreeMode && this.recursiveSearchEnabled);
|
setCheck('toggle-recursive', isTreeMode && this.recursiveSearchEnabled);
|
||||||
setDisabled('toggle-recursive', !isTreeMode);
|
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"]');
|
const emptyFoldersItem = menu.querySelector('[data-action="toggle-empty-folders"]');
|
||||||
if (emptyFoldersItem) {
|
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.expandedNodes = new Set(expandedPaths);
|
||||||
this.displayMode = displayMode;
|
this.displayMode = displayMode;
|
||||||
this.recursiveSearchEnabled = recursiveSearchEnabled;
|
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.updateSearchRecursiveOption();
|
||||||
this.updateFolderManagementButtons();
|
this.updateFolderManagementButtons();
|
||||||
|
|||||||
@@ -243,6 +243,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" data-action="toggle-empty-folders">
|
<div class="context-menu-item" data-action="toggle-empty-folders">
|
||||||
<i class="fas fa-folder-open"></i> <span>{{ t('sidebar.showEmptyFolders') }}</span>
|
<i class="fas fa-folder-open"></i> <span>{{ t('sidebar.showEmptyFolders') }}</span>
|
||||||
|
<span id="sidebarEmptyFoldersCount" class="context-menu-count"></span>
|
||||||
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
|
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -86,28 +86,33 @@ describe('SidebarManager empty folders toggle', () => {
|
|||||||
document.body.innerHTML = '';
|
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();
|
const apiClient = createApiClient();
|
||||||
|
apiClient.fetchUnifiedFolderTree.mockResolvedValue({ tree: { full: {}, empty: {} } });
|
||||||
|
apiClient.fetchModelFolders.mockResolvedValue({ folders: ['', 'full'] });
|
||||||
const manager = createManager(apiClient);
|
const manager = createManager(apiClient);
|
||||||
|
|
||||||
await manager.loadFolderTree();
|
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.fetchUnifiedFolderTree).toHaveBeenCalledWith({ includeEmpty: true });
|
||||||
expect(apiClient.fetchModelFolders).toHaveBeenCalledWith();
|
expect(apiClient.fetchModelFolders).toHaveBeenCalledWith();
|
||||||
expect(manager.nonEmptyFolders).toEqual(new Set(['', 'full']));
|
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 () => {
|
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', 'empty'] })
|
||||||
.mockResolvedValueOnce({ folders: ['', 'full'] });
|
.mockResolvedValueOnce({ folders: ['', 'full'] });
|
||||||
const manager = createManager(apiClient, { displayMode: 'list' });
|
const manager = createManager(apiClient, { displayMode: 'list' });
|
||||||
manager.showEmptyFolders = true;
|
|
||||||
|
|
||||||
await manager.loadFolderTree();
|
await manager.loadFolderTree();
|
||||||
|
|
||||||
expect(apiClient.fetchModelFolders).toHaveBeenNthCalledWith(1, { includeEmpty: true });
|
expect(apiClient.fetchModelFolders).toHaveBeenNthCalledWith(1, { includeEmpty: true });
|
||||||
|
expect(apiClient.fetchModelFolders).toHaveBeenNthCalledWith(2);
|
||||||
expect(manager.foldersList).toEqual(['', 'full', 'empty']);
|
expect(manager.foldersList).toEqual(['', 'full', 'empty']);
|
||||||
expect(manager.nonEmptyFolders).toEqual(new Set(['', 'full']));
|
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();
|
const apiClient = createApiClient();
|
||||||
apiClient.apiConfig.config.supportsFolderManagement = false;
|
apiClient.apiConfig.config.supportsFolderManagement = false;
|
||||||
const manager = createManager(apiClient);
|
const manager = createManager(apiClient);
|
||||||
manager.showEmptyFolders = true;
|
|
||||||
|
|
||||||
await manager.loadFolderTree();
|
await manager.loadFolderTree();
|
||||||
|
|
||||||
expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith();
|
expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith();
|
||||||
|
expect(apiClient.fetchModelFolders).not.toHaveBeenCalled();
|
||||||
expect(manager.nonEmptyFolders).toBeNull();
|
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 apiClient = createApiClient();
|
||||||
const manager = createManager(apiClient);
|
const manager = createManager(apiClient);
|
||||||
|
manager.showEmptyFolders = false;
|
||||||
manager.loadFolderTree = vi.fn();
|
manager.loadFolderTree = vi.fn();
|
||||||
|
|
||||||
manager.handleEmptyFoldersToggle({ stopPropagation: vi.fn() });
|
manager.handleEmptyFoldersToggle({ stopPropagation: vi.fn() });
|
||||||
|
|
||||||
expect(manager.showEmptyFolders).toBe(true);
|
expect(manager.showEmptyFolders).toBe(true);
|
||||||
expect(getStorageItem('loras_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', () => {
|
it('dims folders that contain no models', () => {
|
||||||
const manager = createManager(createApiClient());
|
const manager = createManager(createApiClient());
|
||||||
|
manager.showEmptyFolders = true;
|
||||||
manager.treeData = { full: {}, empty: {} };
|
manager.treeData = { full: {}, empty: {} };
|
||||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||||
|
|
||||||
@@ -163,6 +173,7 @@ describe('SidebarManager empty folders toggle', () => {
|
|||||||
|
|
||||||
it('does not dim folders whose subtree contains models', () => {
|
it('does not dim folders whose subtree contains models', () => {
|
||||||
const manager = createManager(createApiClient());
|
const manager = createManager(createApiClient());
|
||||||
|
manager.showEmptyFolders = true;
|
||||||
// Models live in "characters/anime" only; "characters" itself holds no
|
// Models live in "characters/anime" only; "characters" itself holds no
|
||||||
// direct models but must not be dimmed.
|
// direct models but must not be dimmed.
|
||||||
manager.nonEmptyFolders = manager._buildNonEmptyFolderSet(['characters/anime']);
|
manager.nonEmptyFolders = manager._buildNonEmptyFolderSet(['characters/anime']);
|
||||||
@@ -177,6 +188,48 @@ describe('SidebarManager empty folders toggle', () => {
|
|||||||
expect(animeNode[0]).not.toContain('empty');
|
expect(animeNode[0]).not.toContain('empty');
|
||||||
expect(emptyNode[0]).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 = '<div id="sidebarFolderTree"></div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
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', () => {
|
describe('SidebarManager view options menu', () => {
|
||||||
@@ -185,13 +238,17 @@ describe('SidebarManager view options menu', () => {
|
|||||||
<div class="context-menu-item" data-action="view-mode-tree"><i class="check-indicator" style="display:none"></i></div>
|
<div class="context-menu-item" data-action="view-mode-tree"><i class="check-indicator" style="display:none"></i></div>
|
||||||
<div class="context-menu-item" data-action="view-mode-list"><i class="check-indicator" style="display:none"></i></div>
|
<div class="context-menu-item" data-action="view-mode-list"><i class="check-indicator" style="display:none"></i></div>
|
||||||
<div class="context-menu-item" data-action="toggle-recursive"><i class="check-indicator" style="display:none"></i></div>
|
<div class="context-menu-item" data-action="toggle-recursive"><i class="check-indicator" style="display:none"></i></div>
|
||||||
<div class="context-menu-item" data-action="toggle-empty-folders"><i class="check-indicator" style="display:none"></i></div>
|
<div class="context-menu-item" data-action="toggle-empty-folders"><span id="sidebarEmptyFoldersCount"></span><i class="check-indicator" style="display:none"></i></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
function getCheck(action) {
|
function getCheck(action) {
|
||||||
return document.querySelector(`#sidebarViewOptionsMenu [data-action="${action}"] .check-indicator`);
|
return document.querySelector(`#sidebarViewOptionsMenu [data-action="${action}"] .check-indicator`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEmptyFoldersItem() {
|
||||||
|
return document.querySelector('[data-action="toggle-empty-folders"]');
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
document.body.innerHTML = MENU_HTML;
|
document.body.innerHTML = MENU_HTML;
|
||||||
@@ -227,7 +284,35 @@ describe('SidebarManager view options menu', () => {
|
|||||||
|
|
||||||
manager.updateViewOptionsMenu();
|
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', () => {
|
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', () => {
|
it('toggles empty folders from the menu and keeps it open', () => {
|
||||||
const manager = createManager(createApiClient());
|
const manager = createManager(createApiClient());
|
||||||
manager.loadFolderTree = vi.fn();
|
manager.showEmptyFolders = false;
|
||||||
const menu = document.getElementById('sidebarViewOptionsMenu');
|
const menu = document.getElementById('sidebarViewOptionsMenu');
|
||||||
menu.style.display = 'block';
|
menu.style.display = 'block';
|
||||||
|
|
||||||
@@ -254,6 +339,7 @@ describe('SidebarManager view options menu', () => {
|
|||||||
expect(manager.showEmptyFolders).toBe(true);
|
expect(manager.showEmptyFolders).toBe(true);
|
||||||
expect(getCheck('toggle-empty-folders').style.display).toBe('block');
|
expect(getCheck('toggle-empty-folders').style.display).toBe('block');
|
||||||
expect(menu.style.display).toBe('block');
|
expect(menu.style.display).toBe('block');
|
||||||
|
expect(manager.renderFolderDisplay).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('collapses all folders from the header button', () => {
|
it('collapses all folders from the header button', () => {
|
||||||
@@ -298,6 +384,29 @@ describe('SidebarManager view options menu', () => {
|
|||||||
manager.handleViewOptionsButton({ stopPropagation: vi.fn(), currentTarget: button });
|
manager.handleViewOptionsButton({ stopPropagation: vi.fn(), currentTarget: button });
|
||||||
expect(menu.style.display).toBe('none');
|
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', () => {
|
describe('SidebarManager folder creation', () => {
|
||||||
@@ -327,19 +436,34 @@ describe('SidebarManager folder creation', () => {
|
|||||||
const apiClient = createApiClient();
|
const apiClient = createApiClient();
|
||||||
const manager = createManager(apiClient);
|
const manager = createManager(apiClient);
|
||||||
manager.selectedPath = 'characters';
|
manager.selectedPath = 'characters';
|
||||||
|
manager.showEmptyFolders = false;
|
||||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
const success = await manager._createFolder('characters/anime', 'characters');
|
const success = await manager._createFolder('characters/anime', 'characters');
|
||||||
|
|
||||||
expect(success).toBe(true);
|
expect(success).toBe(true);
|
||||||
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/characters/anime');
|
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(manager.showEmptyFolders).toBe(true);
|
||||||
expect(getStorageItem('loras_showEmptyFolders')).toBe(true);
|
expect(getStorageItem('loras_showEmptyFolders')).toBe(true);
|
||||||
expect(manager.expandedNodes.has('characters')).toBe(true);
|
expect(manager.expandedNodes.has('characters')).toBe(true);
|
||||||
expect(manager.refresh).toHaveBeenCalledTimes(1);
|
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 () => {
|
it('fails gracefully when no model root is configured', async () => {
|
||||||
const apiClient = createApiClient();
|
const apiClient = createApiClient();
|
||||||
apiClient.fetchModelRoots.mockResolvedValue({ roots: [] });
|
apiClient.fetchModelRoots.mockResolvedValue({ roots: [] });
|
||||||
|
|||||||
Reference in New Issue
Block a user