mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
9bbe57ee85
Follows the folder create/delete work: a typo'd directory could be
removed but not corrected, and for a folder holding models the only fix
was to move every model out by hand.
Adds POST /api/lm/{prefix}/rename-folder. Unlike the delete path this one
deliberately works on folders that hold models — a rename keeps every
file, so nothing is cascaded over: the directory is renamed on disk and
the scanner re-keys the records that pointed at the old prefix (recorded
folder list, cache file_path/folder/preview_url, hash and autov3 index
paths, excluded-model paths, and the metadata sidecars that travelled
with the directory). Ancestors are never touched, and only the leaf name
is accepted so a rename can never escape its parent.
Library roots, top-level symlinks and folders holding a staged delete are
refused; the last because a staging manifest records absolute
original/staged paths, so moving it would break undo and purge. A name
collision is a 409 target_exists conflict.
The sidebar reuses the inline-row idiom from folder creation: prefilled
with the current name, inserted in place of the node with that node
hidden while editing, Enter confirms and Escape/blur cancels. The
persisted selection and the expanded set are re-keyed across the rename
so the user keeps their place in the refreshed tree.
849 lines
34 KiB
JavaScript
849 lines
34 KiB
JavaScript
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
|
|
|
const {
|
|
SIDEBAR_MANAGER_MODULE,
|
|
STORAGE_HELPERS_MODULE,
|
|
MODEL_API_FACTORY_MODULE,
|
|
I18N_MODULE,
|
|
BULK_MANAGER_MODULE,
|
|
UI_HELPERS_MODULE,
|
|
UPDATE_CHECK_MODULE,
|
|
STATE_MODULE,
|
|
MODAL_MANAGER_MODULE,
|
|
} = vi.hoisted(() => ({
|
|
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
|
|
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
|
MODEL_API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
|
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
|
BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname,
|
|
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
|
UPDATE_CHECK_MODULE: new URL('../../../static/js/utils/updateCheckHelpers.js', import.meta.url).pathname,
|
|
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
|
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
|
}));
|
|
|
|
vi.mock(MODEL_API_FACTORY_MODULE, () => ({ getModelApiClient: vi.fn() }));
|
|
vi.mock(I18N_MODULE, () => ({ translate: (key, _args, fallback) => fallback || key }));
|
|
vi.mock(BULK_MANAGER_MODULE, () => ({ bulkManager: {} }));
|
|
vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn(), showActionToast: vi.fn() }));
|
|
vi.mock(UPDATE_CHECK_MODULE, () => ({ performFolderUpdateCheck: vi.fn() }));
|
|
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
|
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
|
|
}));
|
|
|
|
const { SidebarManager } = await import(SIDEBAR_MANAGER_MODULE);
|
|
const { state } = await import(STATE_MODULE);
|
|
const { setStorageItem, getStorageItem } = await import(STORAGE_HELPERS_MODULE);
|
|
const { showToast, showActionToast } = await import(UI_HELPERS_MODULE);
|
|
const { modalManager } = await import(MODAL_MANAGER_MODULE);
|
|
|
|
function createApiClient(overrides = {}) {
|
|
return {
|
|
apiConfig: {
|
|
config: {
|
|
displayName: 'LoRA',
|
|
singularName: 'lora',
|
|
supportsMove: true,
|
|
supportsFolderManagement: true,
|
|
},
|
|
},
|
|
fetchUnifiedFolderTree: vi.fn().mockResolvedValue({ tree: { full: {}, empty: {} } }),
|
|
fetchModelFolders: vi.fn().mockResolvedValue({ folders: ['', 'full'] }),
|
|
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/models/loras'] }),
|
|
createFolder: vi.fn().mockResolvedValue({ success: true, folder: 'new-folder', created: true }),
|
|
deleteFolder: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
folder: 'empty',
|
|
model_count: 0,
|
|
file_count: 0,
|
|
dir_count: 0,
|
|
restorable: true,
|
|
}),
|
|
renameFolder: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
renamed: true,
|
|
folder: 'renamed',
|
|
previous_folder: 'empty',
|
|
}),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createManager(apiClient, { displayMode = 'tree' } = {}) {
|
|
const manager = new SidebarManager();
|
|
manager.pageType = 'loras';
|
|
manager.displayMode = displayMode;
|
|
manager.apiClient = apiClient;
|
|
manager.pageControls = { pageState: { searchOptions: {} } };
|
|
manager.renderFolderDisplay = vi.fn();
|
|
manager.renderEmptyState = vi.fn();
|
|
return manager;
|
|
}
|
|
|
|
describe('SidebarManager empty folders toggle', () => {
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
document.body.innerHTML = '';
|
|
});
|
|
|
|
it('requests the models-only tree by default', async () => {
|
|
const apiClient = createApiClient();
|
|
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']));
|
|
});
|
|
|
|
it('passes includeEmpty to the folder list in list display mode', async () => {
|
|
const apiClient = createApiClient();
|
|
apiClient.fetchModelFolders
|
|
.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(manager.foldersList).toEqual(['', 'full', 'empty']);
|
|
expect(manager.nonEmptyFolders).toEqual(new Set(['', 'full']));
|
|
});
|
|
|
|
it('ignores the preference 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(manager.nonEmptyFolders).toBeNull();
|
|
});
|
|
|
|
it('persists the toggle and reloads the tree', async () => {
|
|
const apiClient = createApiClient();
|
|
const manager = createManager(apiClient);
|
|
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);
|
|
});
|
|
|
|
it('dims folders that contain no models', () => {
|
|
const manager = createManager(createApiClient());
|
|
manager.treeData = { full: {}, empty: {} };
|
|
manager.nonEmptyFolders = new Set(['', 'full']);
|
|
|
|
const html = manager.renderTreeNode(manager.treeData, '');
|
|
|
|
expect(html).toContain('sidebar-tree-node-content empty');
|
|
expect(html.match(/empty/g).length).toBeGreaterThan(0);
|
|
expect(html).not.toContain('sidebar-tree-node-content empty" data-path="full"');
|
|
});
|
|
|
|
it('does not dim folders whose subtree contains models', () => {
|
|
const manager = createManager(createApiClient());
|
|
// Models live in "characters/anime" only; "characters" itself holds no
|
|
// direct models but must not be dimmed.
|
|
manager.nonEmptyFolders = manager._buildNonEmptyFolderSet(['characters/anime']);
|
|
manager.treeData = { characters: { anime: {} }, empty: {} };
|
|
|
|
const html = manager.renderTreeNode(manager.treeData, '');
|
|
|
|
const charactersNode = html.match(/<div class="sidebar-tree-node-content[^"]*" data-path="characters">/);
|
|
const animeNode = html.match(/<div class="sidebar-tree-node-content[^"]*" data-path="characters\/anime">/);
|
|
const emptyNode = html.match(/<div class="sidebar-tree-node-content[^"]*" data-path="empty">/);
|
|
expect(charactersNode[0]).not.toContain('empty');
|
|
expect(animeNode[0]).not.toContain('empty');
|
|
expect(emptyNode[0]).toContain('empty');
|
|
});
|
|
});
|
|
|
|
describe('SidebarManager view options menu', () => {
|
|
const MENU_HTML = `
|
|
<div id="sidebarViewOptionsMenu" class="context-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-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-empty-folders"><i class="check-indicator" style="display:none"></i></div>
|
|
</div>`;
|
|
|
|
function getCheck(action) {
|
|
return document.querySelector(`#sidebarViewOptionsMenu [data-action="${action}"] .check-indicator`);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
document.body.innerHTML = MENU_HTML;
|
|
});
|
|
|
|
it('reflects the current view state with check indicators', () => {
|
|
const manager = createManager(createApiClient());
|
|
manager.displayMode = 'tree';
|
|
manager.recursiveSearchEnabled = true;
|
|
manager.showEmptyFolders = false;
|
|
|
|
manager.updateViewOptionsMenu();
|
|
|
|
expect(getCheck('view-mode-tree').style.display).toBe('block');
|
|
expect(getCheck('view-mode-list').style.display).toBe('none');
|
|
expect(getCheck('toggle-recursive').style.display).toBe('block');
|
|
expect(getCheck('toggle-empty-folders').style.display).toBe('none');
|
|
});
|
|
|
|
it('disables the recursive toggle in list mode', () => {
|
|
const manager = createManager(createApiClient());
|
|
manager.displayMode = 'list';
|
|
|
|
manager.updateViewOptionsMenu();
|
|
|
|
expect(document.querySelector('[data-action="toggle-recursive"]').classList.contains('disabled')).toBe(true);
|
|
});
|
|
|
|
it('hides the empty-folders item when folder management is unsupported', () => {
|
|
const apiClient = createApiClient();
|
|
apiClient.apiConfig.config.supportsFolderManagement = false;
|
|
const manager = createManager(apiClient);
|
|
|
|
manager.updateViewOptionsMenu();
|
|
|
|
expect(document.querySelector('[data-action="toggle-empty-folders"]').style.display).toBe('none');
|
|
});
|
|
|
|
it('switches display mode from the menu and closes it', () => {
|
|
const manager = createManager(createApiClient());
|
|
manager.loadFolderTree = vi.fn();
|
|
const menu = document.getElementById('sidebarViewOptionsMenu');
|
|
menu.style.display = 'block';
|
|
|
|
manager.handleViewOptionsAction('view-mode-list');
|
|
|
|
expect(manager.displayMode).toBe('list');
|
|
expect(manager.loadFolderTree).toHaveBeenCalledTimes(1);
|
|
expect(menu.style.display).toBe('none');
|
|
});
|
|
|
|
it('toggles empty folders from the menu and keeps it open', () => {
|
|
const manager = createManager(createApiClient());
|
|
manager.loadFolderTree = vi.fn();
|
|
const menu = document.getElementById('sidebarViewOptionsMenu');
|
|
menu.style.display = 'block';
|
|
|
|
manager.handleViewOptionsAction('toggle-empty-folders');
|
|
|
|
expect(manager.showEmptyFolders).toBe(true);
|
|
expect(getCheck('toggle-empty-folders').style.display).toBe('block');
|
|
expect(menu.style.display).toBe('block');
|
|
});
|
|
|
|
it('collapses all folders from the header button', () => {
|
|
document.body.insertAdjacentHTML('beforeend',
|
|
'<button id="sidebarCollapseAll"><i class="fas fa-compress-alt"></i></button>');
|
|
const manager = createManager(createApiClient());
|
|
manager.expandedNodes = new Set(['a', 'a/b']);
|
|
|
|
manager.handleCollapseAll();
|
|
|
|
expect(manager.expandedNodes.size).toBe(0);
|
|
expect(manager.renderFolderDisplay).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('disables the collapse-all header button in list mode', () => {
|
|
document.body.insertAdjacentHTML('beforeend',
|
|
'<button id="sidebarCollapseAll"><i class="fas fa-compress-alt"></i></button>');
|
|
const manager = createManager(createApiClient());
|
|
manager.displayMode = 'list';
|
|
|
|
manager.updateCollapseAllButton();
|
|
|
|
const button = document.getElementById('sidebarCollapseAll');
|
|
expect(button.disabled).toBe(true);
|
|
expect(button.classList.contains('disabled')).toBe(true);
|
|
|
|
manager.displayMode = 'tree';
|
|
manager.updateCollapseAllButton();
|
|
expect(button.disabled).toBe(false);
|
|
expect(button.classList.contains('disabled')).toBe(false);
|
|
});
|
|
|
|
it('toggles the menu open and closed from the header button', () => {
|
|
const manager = createManager(createApiClient());
|
|
const button = document.createElement('button');
|
|
document.body.appendChild(button);
|
|
const menu = document.getElementById('sidebarViewOptionsMenu');
|
|
|
|
manager.handleViewOptionsButton({ stopPropagation: vi.fn(), currentTarget: button });
|
|
expect(menu.style.display).toBe('block');
|
|
|
|
manager.handleViewOptionsButton({ stopPropagation: vi.fn(), currentTarget: button });
|
|
expect(menu.style.display).toBe('none');
|
|
});
|
|
});
|
|
|
|
describe('SidebarManager folder creation', () => {
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
document.body.innerHTML = '';
|
|
state.global.settings = {};
|
|
});
|
|
|
|
it('resolves the configured default root when it is a known root', () => {
|
|
const manager = createManager(createApiClient());
|
|
state.global.settings = { default_lora_root: '/models/loras-main' };
|
|
|
|
const root = manager._resolveDefaultRoot(['/models/loras', '/models/loras-main']);
|
|
|
|
expect(root).toBe('/models/loras-main');
|
|
});
|
|
|
|
it('falls back to the first root when no default is configured', () => {
|
|
const manager = createManager(createApiClient());
|
|
|
|
expect(manager._resolveDefaultRoot(['/models/loras'])).toBe('/models/loras');
|
|
expect(manager._resolveDefaultRoot([])).toBe('');
|
|
});
|
|
|
|
it('creates the folder under the selected path and reveals it', async () => {
|
|
const apiClient = createApiClient();
|
|
const manager = createManager(apiClient);
|
|
manager.selectedPath = 'characters';
|
|
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
|
|
expect(manager.showEmptyFolders).toBe(true);
|
|
expect(getStorageItem('loras_showEmptyFolders')).toBe(true);
|
|
expect(manager.expandedNodes.has('characters')).toBe(true);
|
|
expect(manager.refresh).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('fails gracefully when no model root is configured', async () => {
|
|
const apiClient = createApiClient();
|
|
apiClient.fetchModelRoots.mockResolvedValue({ roots: [] });
|
|
const manager = createManager(apiClient);
|
|
manager.refresh = vi.fn();
|
|
|
|
const success = await manager._createFolder('new-folder', '');
|
|
|
|
expect(success).toBe(false);
|
|
expect(apiClient.createFolder).not.toHaveBeenCalled();
|
|
expect(manager.refresh).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('opens the create-folder input as an inline row under the context-menu folder', () => {
|
|
const manager = createManager(createApiClient());
|
|
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
|
manager.treeData = { characters: {} };
|
|
manager.renderTree();
|
|
|
|
manager._performFolderAction('create-subfolder', 'characters');
|
|
|
|
const row = document.getElementById('sidebarCreateFolderInput');
|
|
expect(row).not.toBeNull();
|
|
expect(row.classList.contains('sidebar-create-folder-node')).toBe(true);
|
|
expect(manager._createFolderBasePath).toBe('characters');
|
|
// The leaf parent is expanded and the row sits inside its children container
|
|
expect(manager.expandedNodes.has('characters')).toBe(true);
|
|
const parentNode = document.querySelector('.sidebar-tree-node[data-path="characters"]');
|
|
expect(parentNode.querySelector(':scope > .sidebar-tree-children').contains(row)).toBe(true);
|
|
});
|
|
|
|
it('inserts the row as the first child of an already-expanded parent', () => {
|
|
const manager = createManager(createApiClient());
|
|
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
|
manager.treeData = { characters: { anime: {} } };
|
|
manager.expandedNodes = new Set(['characters']);
|
|
manager.renderTree();
|
|
|
|
manager.showCreateFolderInput('characters');
|
|
|
|
const children = document.querySelector('.sidebar-tree-node[data-path="characters"] > .sidebar-tree-children');
|
|
expect(children.firstElementChild.id).toBe('sidebarCreateFolderInput');
|
|
// Existing children container is reused, no temporary one is tracked
|
|
expect(manager._createFolderTempChildren).toBeNull();
|
|
});
|
|
|
|
it('appends the row at the top level for root creation', () => {
|
|
const manager = createManager(createApiClient());
|
|
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
|
manager.treeData = { characters: {} };
|
|
manager.renderTree();
|
|
|
|
manager.showCreateFolderInput('');
|
|
|
|
const folderTree = document.getElementById('sidebarFolderTree');
|
|
const row = document.getElementById('sidebarCreateFolderInput');
|
|
expect(row.parentElement).toBe(folderTree);
|
|
expect(folderTree.lastElementChild).toBe(row);
|
|
});
|
|
|
|
it('inserts the row after the parent item in list mode', () => {
|
|
const manager = createManager(createApiClient(), { displayMode: 'list' });
|
|
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
|
manager.foldersList = ['characters', 'characters/anime'];
|
|
manager.renderFolderList();
|
|
|
|
manager.showCreateFolderInput('characters');
|
|
|
|
const items = [...document.querySelectorAll('#sidebarFolderTree > div')];
|
|
const parentIndex = items.findIndex(el => el.dataset.path === 'characters');
|
|
expect(items[parentIndex + 1].id).toBe('sidebarCreateFolderInput');
|
|
// List-mode rows use the list content styling, not the tree one
|
|
expect(items[parentIndex + 1].querySelector('.sidebar-node-content')).not.toBeNull();
|
|
});
|
|
|
|
it('removes the temporary children container when creation is canceled', () => {
|
|
const manager = createManager(createApiClient());
|
|
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
|
manager.treeData = { characters: {} };
|
|
manager.renderTree();
|
|
|
|
manager.showCreateFolderInput('characters');
|
|
manager.handleCreateFolderCancel();
|
|
|
|
expect(document.getElementById('sidebarCreateFolderInput')).toBeNull();
|
|
expect(manager.isCreatingFolder).toBe(false);
|
|
const parentNode = document.querySelector('.sidebar-tree-node[data-path="characters"]');
|
|
expect(parentNode.querySelector(':scope > .sidebar-tree-children')).toBeNull();
|
|
});
|
|
|
|
it('cancels creation when the input loses focus', () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const manager = createManager(createApiClient());
|
|
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
|
|
|
manager.showCreateFolderInput('');
|
|
const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input');
|
|
input.dispatchEvent(new Event('blur'));
|
|
vi.advanceTimersByTime(150);
|
|
|
|
expect(document.getElementById('sidebarCreateFolderInput')).toBeNull();
|
|
expect(manager.isCreatingFolder).toBe(false);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('ignores tree clicks and context menus on the create row', () => {
|
|
const manager = createManager(createApiClient());
|
|
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
|
manager.treeData = { characters: {} };
|
|
manager.renderTree();
|
|
manager.selectFolder = vi.fn();
|
|
const showMenu = vi.spyOn(manager, '_showFolderContextMenu').mockImplementation(() => {});
|
|
|
|
manager.showCreateFolderInput('characters');
|
|
const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input');
|
|
|
|
manager.handleTreeClick({ target: input });
|
|
expect(manager.selectFolder).not.toHaveBeenCalled();
|
|
|
|
manager.handleTreeContextMenu({ target: input, preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
|
expect(showMenu).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('submits a standalone folder creation when no drag is pending', async () => {
|
|
const apiClient = createApiClient();
|
|
const manager = createManager(apiClient);
|
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
|
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
|
|
|
manager.showCreateFolderInput('characters');
|
|
document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input').value = 'anime';
|
|
|
|
await manager.handleCreateFolderSubmit();
|
|
|
|
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/characters/anime');
|
|
expect(document.getElementById('sidebarCreateFolderInput')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('SidebarManager folder deletion', () => {
|
|
const MODAL_HTML = `
|
|
<div id="deleteFolderModal" class="modal delete-modal">
|
|
<div class="modal-content delete-modal-content">
|
|
<h2 data-role="title"></h2>
|
|
<p class="delete-message" data-role="message"></p>
|
|
<div class="delete-model-info" data-role="info"></div>
|
|
<div class="modal-actions">
|
|
<button class="cancel-btn" data-action="cancel-delete-folder">Cancel</button>
|
|
<button class="delete-btn" data-action="confirm-delete-folder">Delete folder</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
function confirmBtn() {
|
|
return document.querySelector('#deleteFolderModal [data-action="confirm-delete-folder"]');
|
|
}
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
document.body.innerHTML = MODAL_HTML;
|
|
state.global.settings = {};
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('opens the confirm state for a folder whose subtree holds no models', () => {
|
|
const manager = createManager(createApiClient());
|
|
manager.nonEmptyFolders = new Set(['', 'full']);
|
|
|
|
manager.showDeleteFolderModal('empty');
|
|
|
|
const modal = document.getElementById('deleteFolderModal');
|
|
expect(modal.dataset.state).toBe('confirm');
|
|
expect(confirmBtn().style.display).toBe('');
|
|
expect(manager._pendingDeleteFolderPath).toBe('empty');
|
|
expect(modalManager.showModal).toHaveBeenCalledWith('deleteFolderModal');
|
|
});
|
|
|
|
it('explains the refusal when the subtree still holds models', () => {
|
|
const manager = createManager(createApiClient());
|
|
manager.nonEmptyFolders = new Set(['', 'full']);
|
|
|
|
manager.showDeleteFolderModal('full');
|
|
|
|
const modal = document.getElementById('deleteFolderModal');
|
|
expect(modal.dataset.state).toBe('blocked');
|
|
expect(confirmBtn().style.display).toBe('none');
|
|
expect(manager._pendingDeleteFolderPath).toBeNull();
|
|
});
|
|
|
|
it('treats an unknown folder as model-free when the models-only set is missing', () => {
|
|
// nonEmptyFolders is null outside the include-empty tree; the server still
|
|
// refuses a non-empty folder, so the client falls back to the confirm state.
|
|
const manager = createManager(createApiClient());
|
|
manager.nonEmptyFolders = null;
|
|
|
|
manager.showDeleteFolderModal('empty');
|
|
|
|
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('confirm');
|
|
});
|
|
|
|
it('deletes the folder and offers the undo affordance for an empty one', async () => {
|
|
const apiClient = createApiClient();
|
|
const manager = createManager(apiClient);
|
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
|
|
|
const success = await manager._deleteFolder('empty');
|
|
|
|
expect(success).toBe(true);
|
|
expect(apiClient.deleteFolder).toHaveBeenCalledWith('/models/loras/empty');
|
|
expect(manager.refresh).toHaveBeenCalledTimes(1);
|
|
expect(showActionToast).toHaveBeenCalledTimes(1);
|
|
expect(showToast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('restores a deleted empty folder through the create-folder API', async () => {
|
|
const apiClient = createApiClient();
|
|
const manager = createManager(apiClient);
|
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
|
|
|
await manager._deleteFolder('empty');
|
|
const undo = showActionToast.mock.calls[0][3].onAction;
|
|
await undo();
|
|
|
|
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/empty');
|
|
expect(showToast).toHaveBeenCalledWith('sidebar.deleteFolderResult.restored', {}, 'success');
|
|
});
|
|
|
|
it('skips the undo affordance when non-model leftovers were removed', async () => {
|
|
const apiClient = createApiClient({
|
|
deleteFolder: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
folder: 'empty',
|
|
file_count: 2,
|
|
dir_count: 1,
|
|
restorable: false,
|
|
}),
|
|
});
|
|
const manager = createManager(apiClient);
|
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
|
|
|
await manager._deleteFolder('empty');
|
|
|
|
expect(showActionToast).not.toHaveBeenCalled();
|
|
expect(showToast).toHaveBeenCalledWith(
|
|
'sidebar.deleteFolderResult.successWithFiles',
|
|
{ name: 'empty', count: 3 },
|
|
'success'
|
|
);
|
|
});
|
|
|
|
it('surfaces the not_empty conflict when the tree was stale', async () => {
|
|
const conflict = Object.assign(new Error('still contains models'), { code: 'not_empty' });
|
|
const apiClient = createApiClient({
|
|
deleteFolder: vi.fn().mockRejectedValue(conflict),
|
|
});
|
|
const manager = createManager(apiClient);
|
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
|
|
|
const success = await manager._deleteFolder('full');
|
|
|
|
expect(success).toBe(false);
|
|
expect(showToast).toHaveBeenCalledWith('sidebar.deleteFolderResult.notEmpty', {}, 'warning');
|
|
expect(manager.refresh).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('surfaces a busy folder with a staged delete', async () => {
|
|
const busy = Object.assign(new Error('staged delete pending'), { code: 'busy' });
|
|
const apiClient = createApiClient({
|
|
deleteFolder: vi.fn().mockRejectedValue(busy),
|
|
});
|
|
const manager = createManager(apiClient);
|
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
|
|
|
await manager._deleteFolder('full');
|
|
|
|
expect(showToast).toHaveBeenCalledWith('sidebar.deleteFolderResult.busy', {}, 'warning');
|
|
});
|
|
|
|
it('drops the removed subtree from the persisted expand state', () => {
|
|
const manager = createManager(createApiClient());
|
|
manager.expandedNodes = new Set(['empty', 'empty/deep', 'other']);
|
|
manager.saveExpandedState = vi.fn();
|
|
|
|
manager._forgetRemovedFolder('empty');
|
|
|
|
expect([...manager.expandedNodes]).toEqual(['other']);
|
|
expect(manager.saveExpandedState).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('leaves the expand state untouched when nothing matched', () => {
|
|
const manager = createManager(createApiClient());
|
|
manager.expandedNodes = new Set(['other']);
|
|
manager.saveExpandedState = vi.fn();
|
|
|
|
manager._forgetRemovedFolder('empty');
|
|
|
|
expect([...manager.expandedNodes]).toEqual(['other']);
|
|
expect(manager.saveExpandedState).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('routes the modal buttons to cancel and confirm', () => {
|
|
const manager = createManager(createApiClient());
|
|
manager._deleteFolder = vi.fn().mockResolvedValue(true);
|
|
manager._pendingDeleteFolderPath = 'empty';
|
|
manager._wireDeleteFolderModal();
|
|
|
|
confirmBtn().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
|
|
expect(modalManager.closeModal).toHaveBeenCalledWith('deleteFolderModal');
|
|
expect(manager._deleteFolder).toHaveBeenCalledWith('empty');
|
|
});
|
|
|
|
it('routes the context-menu action to the delete modal', () => {
|
|
const manager = createManager(createApiClient());
|
|
manager.showDeleteFolderModal = vi.fn();
|
|
|
|
manager._performFolderAction('delete-folder', 'empty');
|
|
|
|
expect(manager.showDeleteFolderModal).toHaveBeenCalledWith('empty');
|
|
});
|
|
|
|
it('hides the delete entry when folder management is unsupported', () => {
|
|
document.body.insertAdjacentHTML('beforeend', `
|
|
<div id="sidebarFolderContextMenu" class="context-menu">
|
|
<div class="context-menu-item" data-action="create-subfolder"></div>
|
|
<div class="context-menu-item delete-item" data-action="delete-folder"></div>
|
|
</div>`);
|
|
const apiClient = createApiClient();
|
|
apiClient.apiConfig.config.supportsFolderManagement = false;
|
|
const manager = createManager(apiClient);
|
|
|
|
manager._showFolderContextMenu(10, 10, 'empty');
|
|
|
|
const item = document.querySelector('#sidebarFolderContextMenu [data-action="delete-folder"]');
|
|
expect(item.style.display).toBe('none');
|
|
|
|
manager._closeFolderContextMenu();
|
|
});
|
|
});
|
|
|
|
describe('SidebarManager folder rename', () => {
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
|
state.global.settings = {};
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
function renameInput() {
|
|
return document.querySelector('#sidebarRenameFolderInput .sidebar-rename-folder-input');
|
|
}
|
|
|
|
it('turns the node into a prefilled inline row in tree mode', () => {
|
|
const manager = createManager(createApiClient());
|
|
manager.treeData = { characters: { anime: {} } };
|
|
manager.renderTree();
|
|
|
|
manager.showRenameFolderInput('characters/anime');
|
|
|
|
const row = document.getElementById('sidebarRenameFolderInput');
|
|
expect(row).not.toBeNull();
|
|
expect(renameInput().value).toBe('anime');
|
|
// The node is hidden in place, not removed: the row sits right before it
|
|
const node = document.querySelector('.sidebar-tree-node[data-path="characters/anime"]');
|
|
expect(node.style.display).toBe('none');
|
|
expect(row.nextElementSibling).toBe(node);
|
|
expect(manager._renameFolderPath).toBe('characters/anime');
|
|
});
|
|
|
|
it('inserts the row in place in list mode', () => {
|
|
const manager = createManager(createApiClient(), { displayMode: 'list' });
|
|
manager.foldersList = ['characters', 'characters/anime'];
|
|
manager.renderFolderList();
|
|
|
|
manager.showRenameFolderInput('characters/anime');
|
|
|
|
const row = document.getElementById('sidebarRenameFolderInput');
|
|
expect(row.querySelector('.sidebar-node-content')).not.toBeNull();
|
|
const item = document.querySelector('.sidebar-folder-item[data-path="characters/anime"]');
|
|
expect(row.nextElementSibling).toBe(item);
|
|
});
|
|
|
|
it('restores the node when the edit is canceled', () => {
|
|
const manager = createManager(createApiClient());
|
|
manager.treeData = { characters: { anime: {} } };
|
|
manager.renderTree();
|
|
|
|
manager.showRenameFolderInput('characters/anime');
|
|
manager.handleRenameFolderCancel();
|
|
|
|
expect(document.getElementById('sidebarRenameFolderInput')).toBeNull();
|
|
expect(manager._renameFolderPath).toBeNull();
|
|
const node = document.querySelector('.sidebar-tree-node[data-path="characters/anime"]');
|
|
expect(node.style.display).toBe('');
|
|
});
|
|
|
|
it('renames through the API and re-keys the persisted selection', async () => {
|
|
const apiClient = createApiClient();
|
|
const manager = createManager(apiClient);
|
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
|
manager.selectedPath = 'characters/anime';
|
|
manager.expandedNodes = new Set(['characters', 'characters/anime']);
|
|
manager.pageControls = { pageState: { activeFolder: 'characters/anime' } };
|
|
|
|
const success = await manager._renameFolder('characters/anime', 'animation');
|
|
|
|
expect(success).toBe(true);
|
|
expect(apiClient.renameFolder).toHaveBeenCalledWith('/models/loras/characters/anime', 'animation');
|
|
expect(manager.selectedPath).toBe('renamed');
|
|
expect(manager.pageControls.pageState.activeFolder).toBe('renamed');
|
|
expect(getStorageItem('loras_activeFolder')).toBe('renamed');
|
|
expect(manager.refresh).toHaveBeenCalledTimes(1);
|
|
expect(showToast).toHaveBeenCalledWith(
|
|
'sidebar.renameFolderResult.success', { name: 'animation' }, 'success'
|
|
);
|
|
});
|
|
|
|
it('re-keys the expanded subtree and the selection', () => {
|
|
const manager = createManager(createApiClient());
|
|
manager.expandedNodes = new Set(['a', 'a/b', 'a/b/c', 'x']);
|
|
manager.selectedPath = 'a/b/c';
|
|
manager.saveExpandedState = vi.fn();
|
|
|
|
manager._rekeyFolderPath('a/b', 'a/z');
|
|
|
|
expect([...manager.expandedNodes]).toEqual(['a', 'a/z', 'a/z/c', 'x']);
|
|
expect(manager.selectedPath).toBe('a/z/c');
|
|
expect(manager.saveExpandedState).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('submits the inline edit and skips the API for an unchanged name', async () => {
|
|
const apiClient = createApiClient();
|
|
const manager = createManager(apiClient);
|
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
|
manager.treeData = { characters: { anime: {} } };
|
|
manager.renderTree();
|
|
|
|
manager.showRenameFolderInput('characters/anime');
|
|
renameInput().value = 'anime';
|
|
await manager.handleRenameFolderSubmit();
|
|
|
|
expect(apiClient.renameFolder).not.toHaveBeenCalled();
|
|
expect(document.getElementById('sidebarRenameFolderInput')).toBeNull();
|
|
});
|
|
|
|
it('rejects invalid names before calling the API', async () => {
|
|
const apiClient = createApiClient();
|
|
const manager = createManager(apiClient);
|
|
manager.treeData = { characters: { anime: {} } };
|
|
manager.renderTree();
|
|
|
|
manager.showRenameFolderInput('characters/anime');
|
|
renameInput().value = 'bad/name';
|
|
await manager.handleRenameFolderSubmit();
|
|
|
|
expect(apiClient.renameFolder).not.toHaveBeenCalled();
|
|
expect(showToast).toHaveBeenCalledWith('sidebar.dragDrop.invalidFolderName', {}, 'error');
|
|
// The row stays open so the name can be corrected
|
|
expect(document.getElementById('sidebarRenameFolderInput')).not.toBeNull();
|
|
});
|
|
|
|
it('surfaces a name collision', async () => {
|
|
const conflict = Object.assign(new Error('already exists'), { code: 'target_exists' });
|
|
const apiClient = createApiClient({
|
|
renameFolder: vi.fn().mockRejectedValue(conflict),
|
|
});
|
|
const manager = createManager(apiClient);
|
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
|
|
|
const success = await manager._renameFolder('characters/anime', 'animation');
|
|
|
|
expect(success).toBe(false);
|
|
expect(showToast).toHaveBeenCalledWith('sidebar.renameFolderResult.targetExists', {}, 'warning');
|
|
expect(manager.refresh).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('routes the context-menu action to the inline rename row', () => {
|
|
const manager = createManager(createApiClient());
|
|
manager.showRenameFolderInput = vi.fn();
|
|
|
|
manager._performFolderAction('rename-folder', 'characters/anime');
|
|
|
|
expect(manager.showRenameFolderInput).toHaveBeenCalledWith('characters/anime');
|
|
});
|
|
|
|
it('hides the rename entry when folder management is unsupported', () => {
|
|
document.body.insertAdjacentHTML('beforeend', `
|
|
<div id="sidebarFolderContextMenu" class="context-menu">
|
|
<div class="context-menu-item" data-action="rename-folder"></div>
|
|
</div>`);
|
|
const apiClient = createApiClient();
|
|
apiClient.apiConfig.config.supportsFolderManagement = false;
|
|
const manager = createManager(apiClient);
|
|
|
|
manager._showFolderContextMenu(10, 10, 'empty');
|
|
|
|
const item = document.querySelector('#sidebarFolderContextMenu [data-action="rename-folder"]');
|
|
expect(item.style.display).toBe('none');
|
|
|
|
manager._closeFolderContextMenu();
|
|
});
|
|
});
|
|
|