mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
feat(sidebar): rename folders from the sidebar (#999)
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.
This commit is contained in:
@@ -59,6 +59,12 @@ function createApiClient(overrides = {}) {
|
||||
dir_count: 0,
|
||||
restorable: true,
|
||||
}),
|
||||
renameFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
renamed: true,
|
||||
folder: 'renamed',
|
||||
previous_folder: 'empty',
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -677,3 +683,166 @@ describe('SidebarManager folder deletion', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user