mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -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.
2295 lines
87 KiB
JavaScript
2295 lines
87 KiB
JavaScript
/**
|
|
* SidebarManager - Manages hierarchical folder navigation sidebar
|
|
*/
|
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
|
import { translate } from '../utils/i18nHelpers.js';
|
|
import { state, getCurrentPageState } from '../state/index.js';
|
|
import { bulkManager } from '../managers/BulkManager.js';
|
|
import { modalManager } from '../managers/ModalManager.js';
|
|
import { showToast, showActionToast } from '../utils/uiHelpers.js';
|
|
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
|
import { escapeHtml, escapeAttribute } from './shared/utils.js';
|
|
import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js';
|
|
|
|
// Pages whose folder sidebar starts hidden. "other" downloads default to a flat
|
|
// layout (no subfolders are created), so on a fresh library the tree is empty
|
|
// there and the sidebar would only consume horizontal space. The preference is
|
|
// still persisted per page once the user toggles it, and the edge indicator
|
|
// makes the hidden sidebar discoverable/recoverable.
|
|
const SIDEBAR_DEFAULT_HIDDEN_PAGES = new Set(['other']);
|
|
|
|
export class SidebarManager {
|
|
constructor() {
|
|
this.pageControls = null;
|
|
this.pageType = null;
|
|
this.treeData = {};
|
|
this.folderTreeLoaded = false;
|
|
this.selectedPath = '';
|
|
this.expandedNodes = new Set();
|
|
this.apiClient = null;
|
|
this.openDropdown = null;
|
|
this.isInitialized = false;
|
|
this.displayMode = 'tree'; // 'tree' or 'list'
|
|
this.foldersList = [];
|
|
this.recursiveSearchEnabled = true;
|
|
this.draggedFilePaths = null;
|
|
this.draggedRootPath = null;
|
|
this.draggedFromBulk = false;
|
|
this.dragHandlersInitialized = false;
|
|
this.folderTreeElement = null;
|
|
this.currentDropTarget = null;
|
|
this.lastPageControls = null;
|
|
this.isDisabledByPage = false;
|
|
this.initializationPromise = null;
|
|
this.isCreatingFolder = false;
|
|
this.showEmptyFolders = false;
|
|
this.nonEmptyFolders = null; // models-only folder set used to dim empty nodes
|
|
this._createFolderBasePath = null;
|
|
this._createFolderTempChildren = null; // children container added for a leaf parent during inline creation
|
|
this._renameFolderPath = null;
|
|
this._renameFolderNode = null;
|
|
this._pendingDeleteFolderPath = null;
|
|
this._deleteFolderModalWired = false;
|
|
|
|
// Bind methods
|
|
this.handleTreeClick = this.handleTreeClick.bind(this);
|
|
this.handleTreeContextMenu = this.handleTreeContextMenu.bind(this);
|
|
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
|
|
this.handleDocumentClick = this.handleDocumentClick.bind(this);
|
|
this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this);
|
|
this.handleCollapseAll = this.handleCollapseAll.bind(this);
|
|
this.updateContainerMargin = this.updateContainerMargin.bind(this);
|
|
this.handleDisplayModeToggle = this.handleDisplayModeToggle.bind(this);
|
|
this.handleFolderListClick = this.handleFolderListClick.bind(this);
|
|
this.handleRecursiveToggle = this.handleRecursiveToggle.bind(this);
|
|
this.handleEmptyFoldersToggle = this.handleEmptyFoldersToggle.bind(this);
|
|
this.handleCreateFolderButton = this.handleCreateFolderButton.bind(this);
|
|
this.handleViewOptionsButton = this.handleViewOptionsButton.bind(this);
|
|
this.handleCardDragStart = this.handleCardDragStart.bind(this);
|
|
this.handleCardDragEnd = this.handleCardDragEnd.bind(this);
|
|
this.handleFolderDragEnter = this.handleFolderDragEnter.bind(this);
|
|
this.handleFolderDragOver = this.handleFolderDragOver.bind(this);
|
|
this.handleFolderDragLeave = this.handleFolderDragLeave.bind(this);
|
|
this.handleFolderDrop = this.handleFolderDrop.bind(this);
|
|
this.handleCreateFolderSubmit = this.handleCreateFolderSubmit.bind(this);
|
|
this.handleCreateFolderCancel = this.handleCreateFolderCancel.bind(this);
|
|
this.handleHideToggle = this.handleHideToggle.bind(this);
|
|
this.getPageDisplayName = this.getPageDisplayName.bind(this);
|
|
}
|
|
|
|
setHostPageControls(pageControls) {
|
|
this.lastPageControls = pageControls;
|
|
}
|
|
|
|
async initialize(pageControls, options = {}) {
|
|
// Clean up previous initialization if exists
|
|
if (this.isInitialized) {
|
|
this.cleanup();
|
|
}
|
|
|
|
this.pageControls = pageControls;
|
|
this.pageType = pageControls.pageType;
|
|
this.lastPageControls = pageControls;
|
|
this.apiClient = pageControls?.getSidebarApiClient?.()
|
|
|| pageControls?.sidebarApiClient
|
|
|| getModelApiClient();
|
|
|
|
this.setupEventHandlers();
|
|
this.initializeDragAndDrop();
|
|
this.updateSidebarTitle();
|
|
this.restoreSidebarState();
|
|
// Apply DOM visibility based on per-page state
|
|
this.updateDomVisibility();
|
|
await this.loadFolderTree();
|
|
this.restoreSelectedFolder();
|
|
|
|
// Update container margin based on initial sidebar state
|
|
this.updateContainerMargin();
|
|
|
|
this.isInitialized = true;
|
|
console.log(`SidebarManager initialized for ${this.pageType} page`);
|
|
}
|
|
|
|
cleanup() {
|
|
if (!this.isInitialized) return;
|
|
|
|
// Clean up event handlers
|
|
this.removeEventHandlers();
|
|
|
|
this.clearAllDropHighlights();
|
|
this.resetDragState();
|
|
this.hideCreateFolderInput();
|
|
this.hideRenameFolderInput();
|
|
|
|
this.hideSidebarHiddenIndicator();
|
|
|
|
// Reset state
|
|
this.pageControls = null;
|
|
this.pageType = null;
|
|
this.treeData = {};
|
|
this.selectedPath = '';
|
|
this.expandedNodes = new Set();
|
|
this.openDropdown = null;
|
|
this.isDisabledByPage = false;
|
|
this.apiClient = null;
|
|
this.isInitialized = false;
|
|
this.recursiveSearchEnabled = true;
|
|
this.showEmptyFolders = false;
|
|
this.nonEmptyFolders = null;
|
|
this._createFolderBasePath = null;
|
|
this._createFolderTempChildren = null;
|
|
this._renameFolderPath = null;
|
|
this._renameFolderNode = null;
|
|
this._pendingDeleteFolderPath = null;
|
|
|
|
// Reset container margin
|
|
const container = document.querySelector('.container');
|
|
if (container) {
|
|
container.style.marginLeft = '';
|
|
}
|
|
|
|
// Remove resize event listener
|
|
window.removeEventListener('resize', this.updateContainerMargin);
|
|
|
|
console.log('SidebarManager cleaned up');
|
|
this.initializationPromise = null;
|
|
}
|
|
|
|
removeEventHandlers() {
|
|
const folderTree = document.getElementById('sidebarFolderTree');
|
|
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
|
|
const sidebarHeader = document.getElementById('sidebarHeader');
|
|
|
|
if (folderTree) {
|
|
folderTree.removeEventListener('click', this.handleTreeClick);
|
|
folderTree.removeEventListener('contextmenu', this.handleTreeContextMenu);
|
|
folderTree.removeEventListener('dragover', this.handleFolderDragOver);
|
|
}
|
|
if (sidebarBreadcrumbNav) {
|
|
sidebarBreadcrumbNav.removeEventListener('click', this.handleBreadcrumbClick);
|
|
}
|
|
if (sidebarHeader) {
|
|
sidebarHeader.removeEventListener('click', this.handleSidebarHeaderClick);
|
|
}
|
|
|
|
// Remove document click handler
|
|
document.removeEventListener('click', this.handleDocumentClick);
|
|
|
|
// Remove resize event handler
|
|
window.removeEventListener('resize', this.updateContainerMargin);
|
|
|
|
const viewOptionsBtn = document.getElementById('sidebarViewOptions');
|
|
if (viewOptionsBtn) {
|
|
viewOptionsBtn.removeEventListener('click', this.handleViewOptionsButton);
|
|
}
|
|
this._closeViewOptionsMenu();
|
|
|
|
const createFolderBtn = document.getElementById('sidebarCreateFolder');
|
|
if (createFolderBtn) {
|
|
createFolderBtn.removeEventListener('click', this.handleCreateFolderButton);
|
|
}
|
|
|
|
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
|
if (collapseAllBtn) {
|
|
collapseAllBtn.removeEventListener('click', this.handleCollapseAll);
|
|
}
|
|
|
|
const hideToggle = document.getElementById('sidebarHideToggle');
|
|
if (hideToggle) {
|
|
hideToggle.removeEventListener('click', this.handleHideToggle);
|
|
}
|
|
}
|
|
|
|
initializeDragAndDrop() {
|
|
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
|
|
return;
|
|
}
|
|
|
|
if (!this.dragHandlersInitialized) {
|
|
document.addEventListener('dragstart', this.handleCardDragStart);
|
|
document.addEventListener('dragend', this.handleCardDragEnd);
|
|
this.dragHandlersInitialized = true;
|
|
}
|
|
|
|
const folderTree = document.getElementById('sidebarFolderTree');
|
|
if (folderTree && this.folderTreeElement !== folderTree) {
|
|
if (this.folderTreeElement) {
|
|
this.folderTreeElement.removeEventListener('dragenter', this.handleFolderDragEnter);
|
|
this.folderTreeElement.removeEventListener('dragover', this.handleFolderDragOver);
|
|
this.folderTreeElement.removeEventListener('dragleave', this.handleFolderDragLeave);
|
|
this.folderTreeElement.removeEventListener('drop', this.handleFolderDrop);
|
|
}
|
|
|
|
folderTree.addEventListener('dragenter', this.handleFolderDragEnter);
|
|
folderTree.addEventListener('dragover', this.handleFolderDragOver);
|
|
folderTree.addEventListener('dragleave', this.handleFolderDragLeave);
|
|
folderTree.addEventListener('drop', this.handleFolderDrop);
|
|
|
|
this.folderTreeElement = folderTree;
|
|
}
|
|
}
|
|
|
|
handleCardDragStart(event) {
|
|
const card = event.target.closest('.model-card');
|
|
if (!card) return;
|
|
|
|
const filePath = card.dataset.filepath;
|
|
if (!filePath) return;
|
|
|
|
const selectedSet = state.selectedModels instanceof Set
|
|
? state.selectedModels
|
|
: new Set(state.selectedModels || []);
|
|
const cardIsSelected = card.classList.contains('selected');
|
|
const usingBulkSelection = Boolean(state.bulkMode && cardIsSelected && selectedSet && selectedSet.size > 0);
|
|
|
|
const paths = usingBulkSelection ? Array.from(selectedSet) : [filePath];
|
|
const filePaths = Array.from(new Set(paths.filter(Boolean)));
|
|
|
|
if (filePaths.length === 0) {
|
|
return;
|
|
}
|
|
|
|
this.draggedFilePaths = filePaths;
|
|
this.draggedRootPath = this.getRootPathFromCard(card);
|
|
this.draggedFromBulk = usingBulkSelection;
|
|
|
|
const dataTransfer = event.dataTransfer;
|
|
if (dataTransfer) {
|
|
dataTransfer.effectAllowed = 'move';
|
|
dataTransfer.setData('text/plain', filePaths.join(','));
|
|
// Tag the drag as an internal card drag so preview-drop handlers on
|
|
// other cards ignore it (no highlight, no preview replacement).
|
|
dataTransfer.setData(MODEL_CARD_DRAG_MIME_TYPE, filePaths.join(','));
|
|
try {
|
|
dataTransfer.setData('application/json', JSON.stringify({ filePaths }));
|
|
} catch (error) {
|
|
// Ignore serialization errors
|
|
}
|
|
}
|
|
|
|
card.classList.add('dragging');
|
|
|
|
// Add dragging state to sidebar for visual feedback
|
|
const sidebar = document.getElementById('folderSidebar');
|
|
if (sidebar) {
|
|
sidebar.classList.add('dragging-active');
|
|
}
|
|
}
|
|
|
|
handleCardDragEnd(event) {
|
|
const card = event.target.closest('.model-card');
|
|
if (card) {
|
|
card.classList.remove('dragging');
|
|
}
|
|
|
|
// Remove dragging state from sidebar
|
|
const sidebar = document.getElementById('folderSidebar');
|
|
if (sidebar) {
|
|
sidebar.classList.remove('dragging-active');
|
|
}
|
|
|
|
this.clearAllDropHighlights();
|
|
this.resetDragState();
|
|
}
|
|
|
|
getRootPathFromCard(card) {
|
|
if (!card) return null;
|
|
|
|
const filePathRaw = card.dataset.filepath || '';
|
|
const normalizedFilePath = filePathRaw.replace(/\\/g, '/');
|
|
const lastSlashIndex = normalizedFilePath.lastIndexOf('/');
|
|
if (lastSlashIndex === -1) {
|
|
return null;
|
|
}
|
|
|
|
const directory = normalizedFilePath.substring(0, lastSlashIndex);
|
|
let folderValue = card.dataset.folder;
|
|
if (!folderValue || folderValue === 'undefined') {
|
|
folderValue = '';
|
|
}
|
|
const normalizedFolder = folderValue.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
|
|
|
if (!normalizedFolder) {
|
|
return directory;
|
|
}
|
|
|
|
const suffix = `/${normalizedFolder}`;
|
|
if (directory.endsWith(suffix)) {
|
|
return directory.slice(0, -suffix.length);
|
|
}
|
|
|
|
return directory;
|
|
}
|
|
|
|
combineRootAndRelativePath(root, relative) {
|
|
const normalizedRoot = (root || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
|
const normalizedRelative = (relative || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
|
|
|
if (!normalizedRoot) {
|
|
return normalizedRelative;
|
|
}
|
|
|
|
if (!normalizedRelative) {
|
|
return normalizedRoot;
|
|
}
|
|
|
|
return `${normalizedRoot}/${normalizedRelative}`;
|
|
}
|
|
|
|
getFolderElementFromEvent(event) {
|
|
const folderTree = this.folderTreeElement || document.getElementById('sidebarFolderTree');
|
|
if (!folderTree) return null;
|
|
|
|
const target = event.target instanceof Element ? event.target.closest('[data-path]') : null;
|
|
if (!target || !folderTree.contains(target)) {
|
|
return null;
|
|
}
|
|
|
|
return target;
|
|
}
|
|
|
|
setDropTargetHighlight(element, shouldAdd) {
|
|
if (!element) return;
|
|
|
|
let targetElement = element;
|
|
if (!targetElement.classList.contains('sidebar-tree-node-content') &&
|
|
!targetElement.classList.contains('sidebar-node-content')) {
|
|
targetElement = element.querySelector('.sidebar-tree-node-content, .sidebar-node-content');
|
|
}
|
|
|
|
if (targetElement) {
|
|
targetElement.classList.toggle('drop-target', shouldAdd);
|
|
}
|
|
}
|
|
|
|
handleFolderDragEnter(event) {
|
|
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
|
|
|
|
const folderElement = this.getFolderElementFromEvent(event);
|
|
if (!folderElement) return;
|
|
|
|
event.preventDefault();
|
|
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.dropEffect = 'move';
|
|
}
|
|
|
|
this.setDropTargetHighlight(folderElement, true);
|
|
this.currentDropTarget = folderElement;
|
|
}
|
|
|
|
handleFolderDragOver(event) {
|
|
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
|
|
|
|
const folderElement = this.getFolderElementFromEvent(event);
|
|
if (!folderElement) return;
|
|
|
|
event.preventDefault();
|
|
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.dropEffect = 'move';
|
|
}
|
|
}
|
|
|
|
handleFolderDragLeave(event) {
|
|
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
|
|
|
|
const folderElement = this.getFolderElementFromEvent(event);
|
|
if (!folderElement) return;
|
|
|
|
const relatedTarget = event.relatedTarget instanceof Element ? event.relatedTarget : null;
|
|
if (!relatedTarget || !folderElement.contains(relatedTarget)) {
|
|
this.setDropTargetHighlight(folderElement, false);
|
|
if (this.currentDropTarget === folderElement) {
|
|
this.currentDropTarget = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
async handleFolderDrop(event) {
|
|
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
|
|
|
|
const folderElement = this.getFolderElementFromEvent(event);
|
|
if (!folderElement) return;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
this.setDropTargetHighlight(folderElement, false);
|
|
this.currentDropTarget = null;
|
|
|
|
const targetPath = folderElement.dataset.path || '';
|
|
|
|
await this.performDragMove(targetPath);
|
|
|
|
this.resetDragState();
|
|
this.clearAllDropHighlights();
|
|
}
|
|
|
|
async performDragMove(targetRelativePath) {
|
|
console.log('[SidebarManager] performDragMove called with targetRelativePath:', targetRelativePath);
|
|
console.log('[SidebarManager] draggedFilePaths:', this.draggedFilePaths);
|
|
console.log('[SidebarManager] draggedRootPath:', this.draggedRootPath);
|
|
|
|
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) {
|
|
console.log('[SidebarManager] performDragMove returning false - no draggedFilePaths');
|
|
return false;
|
|
}
|
|
|
|
if (!this.apiClient) {
|
|
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|
|
|| this.pageControls?.sidebarApiClient
|
|
|| getModelApiClient();
|
|
}
|
|
|
|
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
|
|
console.log('[SidebarManager] performDragMove returning false - supportsMove is false');
|
|
showToast('toast.models.moveFailed', { message: translate('sidebar.dragDrop.moveUnsupported', {}, 'Move not supported for this page') }, 'error');
|
|
return false;
|
|
}
|
|
|
|
const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : '';
|
|
console.log('[SidebarManager] rootPath:', rootPath);
|
|
if (!rootPath) {
|
|
console.log('[SidebarManager] performDragMove returning false - no rootPath');
|
|
showToast(
|
|
'toast.models.moveFailed',
|
|
{ message: translate('sidebar.dragDrop.unableToResolveRoot', {}, 'Unable to determine destination path for move.') },
|
|
'error'
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const destination = this.combineRootAndRelativePath(rootPath, targetRelativePath);
|
|
const useBulkMove = this.draggedFromBulk || this.draggedFilePaths.length > 1;
|
|
|
|
try {
|
|
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
|
|
let movedFiles = []; // Array of { original_file_path, new_file_path }
|
|
|
|
if (useBulkMove) {
|
|
const results = await this.apiClient.moveBulkModels(this.draggedFilePaths, destination);
|
|
movedFiles = (results || [])
|
|
.filter(r => r.success)
|
|
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
|
} else {
|
|
const result = await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination);
|
|
if (result) {
|
|
movedFiles.push({
|
|
original_file_path: result.original_file_path || this.draggedFilePaths[0],
|
|
new_file_path: result.new_file_path
|
|
});
|
|
}
|
|
}
|
|
console.log('[SidebarManager] apiClient.move successful');
|
|
|
|
// Update VirtualScroller in-place instead of full reload
|
|
if (movedFiles.length > 0 && state.virtualScroller) {
|
|
const pageState = getCurrentPageState();
|
|
const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
|
|
const isRecursive = pageState.searchOptions?.recursive ?? true;
|
|
const isFolderFiltered = pageState.activeFolder !== null;
|
|
|
|
const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, '');
|
|
|
|
// Determine if items in the target folder are visible in the current view
|
|
let itemsRemainVisible = true;
|
|
if (isFolderFiltered) {
|
|
if (isRecursive) {
|
|
itemsRemainVisible = normalizedActive === '' ||
|
|
normalizedTarget === normalizedActive ||
|
|
normalizedTarget.startsWith(normalizedActive + '/');
|
|
} else {
|
|
itemsRemainVisible = normalizedTarget === normalizedActive;
|
|
}
|
|
}
|
|
|
|
if (itemsRemainVisible) {
|
|
// Items stay visible — update each item's file_path to reflect new location
|
|
for (const moved of movedFiles) {
|
|
if (moved.original_file_path && moved.new_file_path) {
|
|
state.virtualScroller.updateSingleItem(moved.original_file_path, {
|
|
file_path: moved.new_file_path,
|
|
folder: normalizedTarget
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
// Items no longer visible in current folder — remove from VirtualScroller
|
|
const pathsToRemove = movedFiles
|
|
.map(m => m.original_file_path)
|
|
.filter(Boolean);
|
|
if (pathsToRemove.length > 0) {
|
|
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Refresh sidebar folder tree only (no model data reload)
|
|
await this.refresh();
|
|
|
|
if (this.draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
|
|
bulkManager.toggleBulkMode();
|
|
}
|
|
|
|
console.log('[SidebarManager] performDragMove returning true');
|
|
return true;
|
|
} catch (error) {
|
|
console.error('[SidebarManager] Error moving model(s) via drag-and-drop:', error);
|
|
showToast('toast.models.moveFailed', { message: error.message || 'Unknown error' }, 'error');
|
|
console.log('[SidebarManager] performDragMove returning false due to error');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
resetDragState() {
|
|
this.draggedFilePaths = null;
|
|
this.draggedRootPath = null;
|
|
this.draggedFromBulk = false;
|
|
}
|
|
|
|
showCreateFolderInput(basePath = null) {
|
|
// Remove any existing input first — hideCreateFolderInput() also
|
|
// clears isCreatingFolder, so it must run before the flag is set.
|
|
this.hideCreateFolderInput();
|
|
this.isCreatingFolder = true;
|
|
|
|
// The folder is created under the given base path; falls back to the
|
|
// currently selected folder or the root.
|
|
this._createFolderBasePath = basePath !== null ? basePath : (this.selectedPath || '');
|
|
|
|
const folderTree = document.getElementById('sidebarFolderTree');
|
|
if (!folderTree) {
|
|
this.isCreatingFolder = false;
|
|
return;
|
|
}
|
|
|
|
// Inline row at the creation location, file-explorer style: the new
|
|
// folder will appear exactly where the input row is shown.
|
|
const row = this._buildCreateFolderRow();
|
|
this._insertCreateFolderRow(folderTree, row);
|
|
row.scrollIntoView?.({ block: 'nearest' });
|
|
|
|
const input = row.querySelector('.sidebar-create-folder-input');
|
|
if (!input) return;
|
|
input.focus();
|
|
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
this.handleCreateFolderSubmit();
|
|
} else if (e.key === 'Escape') {
|
|
this.handleCreateFolderCancel();
|
|
}
|
|
});
|
|
// Clicking away cancels creation, mirroring file-explorer behavior
|
|
input.addEventListener('blur', () => {
|
|
setTimeout(() => {
|
|
if (this.isCreatingFolder) {
|
|
this.handleCreateFolderCancel();
|
|
}
|
|
}, 100);
|
|
});
|
|
}
|
|
|
|
_buildCreateFolderRow() {
|
|
const isListMode = this.displayMode === 'list';
|
|
const row = document.createElement('div');
|
|
row.id = 'sidebarCreateFolderInput';
|
|
row.className = 'sidebar-create-folder-node';
|
|
row.innerHTML = `
|
|
<div class="${isListMode ? 'sidebar-node-content' : 'sidebar-tree-node-content'} sidebar-create-folder-row">
|
|
${isListMode ? '' : `
|
|
<div class="sidebar-tree-expand-icon sidebar-create-folder-spacer">
|
|
<i class="fas fa-chevron-right"></i>
|
|
</div>`}
|
|
<i class="fas fa-folder-plus sidebar-tree-folder-icon"></i>
|
|
<input type="text"
|
|
class="sidebar-create-folder-input"
|
|
placeholder="${translate('sidebar.dragDrop.newFolderName', {}, 'New folder name')}" />
|
|
</div>
|
|
`;
|
|
return row;
|
|
}
|
|
|
|
_insertCreateFolderRow(folderTree, row) {
|
|
const parentPath = this._createFolderBasePath;
|
|
|
|
if (this.displayMode === 'list') {
|
|
if (parentPath) {
|
|
const parentItem = [...folderTree.querySelectorAll('.sidebar-folder-item')]
|
|
.find(item => item.dataset.path === parentPath);
|
|
if (parentItem) {
|
|
parentItem.after(row);
|
|
return;
|
|
}
|
|
}
|
|
folderTree.appendChild(row);
|
|
return;
|
|
}
|
|
|
|
if (parentPath) {
|
|
// Expand the parent so the row is visible, then insert as its
|
|
// first child.
|
|
if (!this.expandedNodes.has(parentPath)) {
|
|
this.expandedNodes.add(parentPath);
|
|
this.saveExpandedState();
|
|
this.renderTree();
|
|
}
|
|
const parentNode = [...folderTree.querySelectorAll('.sidebar-tree-node')]
|
|
.find(node => node.dataset.path === parentPath);
|
|
if (parentNode) {
|
|
let children = parentNode.querySelector(':scope > .sidebar-tree-children');
|
|
if (!children) {
|
|
// Leaf folder: renderTree() only creates a children
|
|
// container for nodes with subfolders, so add one.
|
|
children = document.createElement('div');
|
|
children.className = 'sidebar-tree-children expanded';
|
|
parentNode.appendChild(children);
|
|
this._createFolderTempChildren = children;
|
|
}
|
|
children.prepend(row);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Root creation (or parent not currently visible): append at top level
|
|
folderTree.appendChild(row);
|
|
}
|
|
|
|
hideCreateFolderInput() {
|
|
// Clear the flag first so the input's blur handler does not treat
|
|
// removing the row as a cancel.
|
|
this.isCreatingFolder = false;
|
|
|
|
const row = document.getElementById('sidebarCreateFolderInput');
|
|
if (row) {
|
|
row.remove();
|
|
}
|
|
|
|
// Remove the temporary children container if it is still empty
|
|
if (this._createFolderTempChildren) {
|
|
const container = this._createFolderTempChildren;
|
|
this._createFolderTempChildren = null;
|
|
if (container.isConnected && container.children.length === 0) {
|
|
container.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
async handleCreateFolderSubmit() {
|
|
const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input');
|
|
if (!input) {
|
|
return;
|
|
}
|
|
|
|
const folderName = input.value.trim();
|
|
if (!folderName) {
|
|
showToast('sidebar.dragDrop.emptyFolderName', {}, 'warning');
|
|
return;
|
|
}
|
|
|
|
// Validate folder name (no slashes, no special chars)
|
|
if (/[\\/:*?"<>|]/.test(folderName)) {
|
|
showToast('sidebar.dragDrop.invalidFolderName', {}, 'error');
|
|
return;
|
|
}
|
|
|
|
// Build target path - use the base path captured when the input was
|
|
// opened (context-menu folder or current selection), or root
|
|
const parentPath = this._createFolderBasePath || '';
|
|
const targetRelativePath = parentPath ? `${parentPath}/${folderName}` : folderName;
|
|
|
|
this.hideCreateFolderInput();
|
|
|
|
await this._createFolder(targetRelativePath, parentPath);
|
|
}
|
|
|
|
async _createFolder(targetRelativePath, parentPath) {
|
|
if (!this._supportsFolderManagement() || typeof this.apiClient.createFolder !== 'function') {
|
|
showToast('sidebar.createFolderResult.unsupported', {}, 'error');
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const rootsData = await this.apiClient.fetchModelRoots();
|
|
const roots = rootsData?.roots || [];
|
|
const root = this._resolveDefaultRoot(roots);
|
|
if (!root) {
|
|
showToast('sidebar.createFolderResult.noRoot', {}, 'error');
|
|
return false;
|
|
}
|
|
|
|
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.
|
|
if (!this.showEmptyFolders) {
|
|
this.showEmptyFolders = true;
|
|
setStorageItem(`${this.pageType}_showEmptyFolders`, true);
|
|
this.updateViewOptionsMenu();
|
|
}
|
|
|
|
// Expand the parent folder to reveal the new node
|
|
if (parentPath) {
|
|
this.expandedNodes.add(parentPath);
|
|
this.saveExpandedState();
|
|
}
|
|
|
|
await this.refresh();
|
|
|
|
showToast('sidebar.createFolderResult.success', { name: result.folder || targetRelativePath }, 'success');
|
|
return true;
|
|
} catch (error) {
|
|
console.error('[SidebarManager] Error creating folder:', error);
|
|
showToast('sidebar.createFolderResult.failed', { message: error.message || 'Unknown error' }, 'error');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
_resolveDefaultRoot(roots) {
|
|
if (!roots || roots.length === 0) {
|
|
return '';
|
|
}
|
|
|
|
const singularName = this.apiClient?.apiConfig?.config?.singularName;
|
|
const defaultRoot = singularName
|
|
? state.global?.settings?.[`default_${singularName}_root`]
|
|
: '';
|
|
if (defaultRoot && roots.includes(defaultRoot)) {
|
|
return defaultRoot;
|
|
}
|
|
|
|
return roots[0];
|
|
}
|
|
|
|
handleCreateFolderCancel() {
|
|
this.hideCreateFolderInput();
|
|
}
|
|
|
|
// ===== Folder rename (inline row, file-explorer style) =====
|
|
|
|
/**
|
|
* Turn the folder node at *path* into an editable row.
|
|
*
|
|
* Mirrors the create-folder inline row (Enter confirms, Escape/blur
|
|
* cancels) but is inserted where the node sits and hides that node while
|
|
* editing, so the tree does not jump.
|
|
*/
|
|
showRenameFolderInput(path) {
|
|
if (!path) return;
|
|
|
|
this.hideRenameFolderInput();
|
|
|
|
const folderTree = document.getElementById('sidebarFolderTree');
|
|
if (!folderTree) return;
|
|
|
|
const node = this._findFolderNodeElement(folderTree, path);
|
|
if (!node) return;
|
|
|
|
const row = this._buildRenameFolderRow(this._folderLeafName(path));
|
|
node.parentElement.insertBefore(row, node);
|
|
node.style.display = 'none';
|
|
|
|
this._renameFolderNode = node;
|
|
this._renameFolderPath = path;
|
|
|
|
const input = row.querySelector('.sidebar-rename-folder-input');
|
|
if (!input) return;
|
|
input.focus();
|
|
input.select();
|
|
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
this.handleRenameFolderSubmit();
|
|
} else if (e.key === 'Escape') {
|
|
this.handleRenameFolderCancel();
|
|
}
|
|
});
|
|
// Clicking away cancels, mirroring the create-folder row
|
|
input.addEventListener('blur', () => {
|
|
setTimeout(() => {
|
|
if (this._renameFolderPath) {
|
|
this.handleRenameFolderCancel();
|
|
}
|
|
}, 100);
|
|
});
|
|
}
|
|
|
|
_buildRenameFolderRow(currentName) {
|
|
const isListMode = this.displayMode === 'list';
|
|
const row = document.createElement('div');
|
|
row.id = 'sidebarRenameFolderInput';
|
|
row.className = 'sidebar-create-folder-node sidebar-rename-folder-node';
|
|
row.innerHTML = `
|
|
<div class="${isListMode ? 'sidebar-node-content' : 'sidebar-tree-node-content'} sidebar-create-folder-row">
|
|
${isListMode ? '' : `
|
|
<div class="sidebar-tree-expand-icon sidebar-create-folder-spacer">
|
|
<i class="fas fa-chevron-right"></i>
|
|
</div>`}
|
|
<i class="fas fa-i-cursor sidebar-tree-folder-icon"></i>
|
|
<input type="text"
|
|
class="sidebar-create-folder-input sidebar-rename-folder-input"
|
|
aria-label="${escapeAttribute(translate('sidebar.renameFolder', {}, 'Rename folder'))}"
|
|
value="${escapeAttribute(currentName)}" />
|
|
</div>
|
|
`;
|
|
return row;
|
|
}
|
|
|
|
_findFolderNodeElement(folderTree, path) {
|
|
return [...folderTree.querySelectorAll('.sidebar-tree-node, .sidebar-folder-item')]
|
|
.find(element => element.dataset.path === path) || null;
|
|
}
|
|
|
|
_folderLeafName(path) {
|
|
if (!path) return '';
|
|
const index = path.lastIndexOf('/');
|
|
return index === -1 ? path : path.slice(index + 1);
|
|
}
|
|
|
|
hideRenameFolderInput() {
|
|
// Clear the flag first so the input's blur handler does not treat
|
|
// removing the row as a cancel.
|
|
this._renameFolderPath = null;
|
|
|
|
const row = document.getElementById('sidebarRenameFolderInput');
|
|
if (row) {
|
|
row.remove();
|
|
}
|
|
|
|
const node = this._renameFolderNode;
|
|
this._renameFolderNode = null;
|
|
if (node && node.isConnected) {
|
|
node.style.display = '';
|
|
}
|
|
}
|
|
|
|
handleRenameFolderCancel() {
|
|
this.hideRenameFolderInput();
|
|
}
|
|
|
|
async handleRenameFolderSubmit() {
|
|
const input = document.querySelector('#sidebarRenameFolderInput .sidebar-rename-folder-input');
|
|
const path = this._renameFolderPath;
|
|
if (!input || !path) {
|
|
return;
|
|
}
|
|
|
|
const newName = input.value.trim();
|
|
if (!newName) {
|
|
showToast('sidebar.dragDrop.emptyFolderName', {}, 'warning');
|
|
return;
|
|
}
|
|
|
|
if (/[\\/:*?"<>|]/.test(newName)) {
|
|
showToast('sidebar.dragDrop.invalidFolderName', {}, 'error');
|
|
return;
|
|
}
|
|
|
|
this.hideRenameFolderInput();
|
|
|
|
if (newName === this._folderLeafName(path)) {
|
|
return;
|
|
}
|
|
|
|
await this._renameFolder(path, newName);
|
|
}
|
|
|
|
async _renameFolder(relativePath, newName) {
|
|
if (!this._supportsFolderManagement() || typeof this.apiClient.renameFolder !== 'function') {
|
|
showToast('sidebar.renameFolderResult.unsupported', {}, 'error');
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const rootsData = await this.apiClient.fetchModelRoots();
|
|
const roots = rootsData?.roots || [];
|
|
const root = this._resolveDefaultRoot(roots);
|
|
if (!root) {
|
|
showToast('sidebar.renameFolderResult.noRoot', {}, 'error');
|
|
return false;
|
|
}
|
|
|
|
const absolutePath = this.combineRootAndRelativePath(root, relativePath);
|
|
const result = await this.apiClient.renameFolder(absolutePath, newName);
|
|
|
|
// Carry the user's place across the rename: the persisted
|
|
// selection and the expanded set would otherwise point at a folder
|
|
// the refreshed tree no longer contains.
|
|
const newPath = result.folder || this._siblingFolderPath(relativePath, newName);
|
|
this._rekeyFolderPath(relativePath, newPath);
|
|
|
|
await this.refresh();
|
|
|
|
showToast('sidebar.renameFolderResult.success', { name: newName }, 'success');
|
|
return true;
|
|
} catch (error) {
|
|
console.error('[SidebarManager] Error renaming folder:', error);
|
|
if (error?.code === 'target_exists') {
|
|
showToast('sidebar.renameFolderResult.targetExists', {}, 'warning');
|
|
} else if (error?.code === 'busy') {
|
|
showToast('sidebar.renameFolderResult.busy', {}, 'warning');
|
|
} else {
|
|
showToast(
|
|
'sidebar.renameFolderResult.failed',
|
|
{ message: error?.message || 'Unknown error' },
|
|
'error'
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
_siblingFolderPath(relativePath, newName) {
|
|
const index = relativePath.lastIndexOf('/');
|
|
const parent = index === -1 ? '' : relativePath.slice(0, index);
|
|
return parent ? `${parent}/${newName}` : newName;
|
|
}
|
|
|
|
_rekeyFolderPath(previousPath, newPath) {
|
|
if (!previousPath || !newPath || previousPath === newPath) return;
|
|
|
|
const prefix = `${previousPath}/`;
|
|
const newPrefix = `${newPath}/`;
|
|
const rekey = (value) => {
|
|
if (value === previousPath) return newPath;
|
|
if (value.startsWith(prefix)) return newPrefix + value.slice(prefix.length);
|
|
return value;
|
|
};
|
|
|
|
if (this.expandedNodes.size > 0) {
|
|
this.expandedNodes = new Set([...this.expandedNodes].map(rekey));
|
|
this.saveExpandedState();
|
|
}
|
|
|
|
if (this.selectedPath) {
|
|
const rekeyed = rekey(this.selectedPath);
|
|
if (rekeyed !== this.selectedPath) {
|
|
this.selectedPath = rekeyed;
|
|
if (this.pageControls?.pageState) {
|
|
this.pageControls.pageState.activeFolder = rekeyed;
|
|
}
|
|
setStorageItem(`${this.pageType}_activeFolder`, rekeyed);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open the folder delete modal for *path*.
|
|
*
|
|
* The tree already knows whether the subtree holds models (the same
|
|
* models-only set that dims empty nodes), so the modal opens in one of two
|
|
* states without a round trip: a confirmation for a model-free folder, or
|
|
* an explanation when models would have to be cascaded over — a
|
|
* folder-level cascade would bypass the per-model lifecycle bookkeeping,
|
|
* so the backend refuses it and the UI says why.
|
|
*/
|
|
showDeleteFolderModal(path) {
|
|
const modal = document.getElementById('deleteFolderModal');
|
|
if (!modal) return;
|
|
|
|
// Defensive: the modal may have been absent when listeners were wired.
|
|
this._wireDeleteFolderModal();
|
|
|
|
const title = modal.querySelector('[data-role="title"]');
|
|
const message = modal.querySelector('[data-role="message"]');
|
|
const info = modal.querySelector('[data-role="info"]');
|
|
const confirmBtn = modal.querySelector('[data-action="confirm-delete-folder"]');
|
|
|
|
const holdsModels = this.nonEmptyFolders ? this.nonEmptyFolders.has(path) : false;
|
|
|
|
const pathLine = `<strong>${escapeHtml(translate('sidebar.deleteFolderModal.folderLabel', {}, 'Folder'))}:</strong> ${escapeHtml(path)}`;
|
|
|
|
if (holdsModels) {
|
|
this._pendingDeleteFolderPath = null;
|
|
title.textContent = translate(
|
|
'sidebar.deleteFolderModal.notEmptyTitle', {}, 'Folder is not empty'
|
|
);
|
|
message.textContent = translate(
|
|
'sidebar.deleteFolderModal.notEmptyMessage', {},
|
|
'This folder still contains models. Delete or move them first.'
|
|
);
|
|
info.innerHTML = pathLine;
|
|
confirmBtn.style.display = 'none';
|
|
modal.dataset.state = 'blocked';
|
|
} else {
|
|
this._pendingDeleteFolderPath = path;
|
|
title.textContent = translate(
|
|
'sidebar.deleteFolderModal.title', {}, 'Delete folder?'
|
|
);
|
|
message.textContent = translate(
|
|
'sidebar.deleteFolderModal.message', {},
|
|
'The folder and everything inside it will be permanently removed from disk.'
|
|
);
|
|
info.innerHTML = `${pathLine}<br>${escapeHtml(translate(
|
|
'sidebar.deleteFolderModal.emptyNote', {}, 'This folder contains no models.'
|
|
))}`;
|
|
confirmBtn.style.display = '';
|
|
modal.dataset.state = 'confirm';
|
|
}
|
|
|
|
modalManager.showModal('deleteFolderModal');
|
|
}
|
|
|
|
hideDeleteFolderModal() {
|
|
this._pendingDeleteFolderPath = null;
|
|
modalManager.closeModal('deleteFolderModal');
|
|
}
|
|
|
|
async handleDeleteFolderConfirm() {
|
|
const path = this._pendingDeleteFolderPath;
|
|
this.hideDeleteFolderModal();
|
|
|
|
if (!path) return false;
|
|
|
|
return this._deleteFolder(path);
|
|
}
|
|
|
|
async _deleteFolder(relativePath) {
|
|
if (!this._supportsFolderManagement() || typeof this.apiClient.deleteFolder !== 'function') {
|
|
showToast('sidebar.deleteFolderResult.unsupported', {}, 'error');
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const rootsData = await this.apiClient.fetchModelRoots();
|
|
const roots = rootsData?.roots || [];
|
|
const root = this._resolveDefaultRoot(roots);
|
|
if (!root) {
|
|
showToast('sidebar.deleteFolderResult.noRoot', {}, 'error');
|
|
return false;
|
|
}
|
|
|
|
const absolutePath = this.combineRootAndRelativePath(root, relativePath);
|
|
const result = await this.apiClient.deleteFolder(absolutePath);
|
|
|
|
// Drop the node (and its subtree) from the persisted expand state
|
|
// before refreshing, otherwise stale keys accumulate forever. A
|
|
// selection inside the removed subtree is left to
|
|
// restoreSelectedFolder(), which falls back to the root and
|
|
// reloads the grid when the folder is gone from the fresh tree.
|
|
this._forgetRemovedFolder(relativePath);
|
|
|
|
await this.refresh();
|
|
|
|
const name = result.folder || relativePath;
|
|
if (result.restorable) {
|
|
// A truly empty folder is reproducible one-for-one, so offer
|
|
// the same 20s undo affordance the model delete flow uses.
|
|
showActionToast('sidebar.deleteFolderResult.success', { name }, 'success', {
|
|
actionText: translate('toast.undo.action', {}, 'Undo'),
|
|
onAction: () => this._restoreDeletedFolder(absolutePath, relativePath),
|
|
});
|
|
} else {
|
|
showToast(
|
|
'sidebar.deleteFolderResult.successWithFiles',
|
|
{ name, count: (result.file_count || 0) + (result.dir_count || 0) },
|
|
'success'
|
|
);
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('[SidebarManager] Error deleting folder:', error);
|
|
if (error?.code === 'not_empty') {
|
|
showToast('sidebar.deleteFolderResult.notEmpty', {}, 'warning');
|
|
} else if (error?.code === 'busy') {
|
|
showToast('sidebar.deleteFolderResult.busy', {}, 'warning');
|
|
} else {
|
|
showToast(
|
|
'sidebar.deleteFolderResult.failed',
|
|
{ message: error?.message || 'Unknown error' },
|
|
'error'
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async _restoreDeletedFolder(absolutePath, relativePath) {
|
|
try {
|
|
await this.apiClient.createFolder(absolutePath);
|
|
this._forgetRemovedFolder(relativePath);
|
|
await this.refresh();
|
|
showToast('sidebar.deleteFolderResult.restored', {}, 'success');
|
|
return true;
|
|
} catch (error) {
|
|
console.error('[SidebarManager] Error restoring deleted folder:', error);
|
|
showToast('toast.undo.failed', { error: error?.message || '' }, 'error');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
_forgetRemovedFolder(folderPath) {
|
|
if (!folderPath) return;
|
|
|
|
const prefix = `${folderPath}/`;
|
|
let changed = false;
|
|
for (const node of Array.from(this.expandedNodes)) {
|
|
if (node === folderPath || node.startsWith(prefix)) {
|
|
this.expandedNodes.delete(node);
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) {
|
|
this.saveExpandedState();
|
|
}
|
|
}
|
|
|
|
_wireDeleteFolderModal() {
|
|
if (this._deleteFolderModalWired) return;
|
|
|
|
const modal = document.getElementById('deleteFolderModal');
|
|
if (!modal) return;
|
|
|
|
modal.addEventListener('click', (event) => {
|
|
const item = event.target.closest('[data-action]');
|
|
if (!item) return;
|
|
const action = item.dataset.action;
|
|
if (action === 'cancel-delete-folder') {
|
|
this.hideDeleteFolderModal();
|
|
} else if (action === 'confirm-delete-folder') {
|
|
this.handleDeleteFolderConfirm();
|
|
}
|
|
});
|
|
|
|
this._deleteFolderModalWired = true;
|
|
}
|
|
|
|
saveSelectedFolder() {
|
|
setStorageItem(`${this.pageType}_activeFolder`, this.selectedPath);
|
|
}
|
|
|
|
clearAllDropHighlights() {
|
|
const highlighted = document.querySelectorAll('.sidebar-tree-node-content.drop-target, .sidebar-node-content.drop-target');
|
|
highlighted.forEach((element) => element.classList.remove('drop-target'));
|
|
this.currentDropTarget = null;
|
|
}
|
|
|
|
updateSidebarTitle() {
|
|
const sidebarTitle = document.getElementById('sidebarTitle');
|
|
if (sidebarTitle) {
|
|
sidebarTitle.textContent = translate('sidebar.modelRoot');
|
|
}
|
|
}
|
|
|
|
setupEventHandlers() {
|
|
// Sidebar header (root selection) - only trigger on title area
|
|
const sidebarHeader = document.getElementById('sidebarHeader');
|
|
if (sidebarHeader) {
|
|
sidebarHeader.addEventListener('click', this.handleSidebarHeaderClick);
|
|
}
|
|
|
|
// View options menu button
|
|
const viewOptionsBtn = document.getElementById('sidebarViewOptions');
|
|
if (viewOptionsBtn) {
|
|
viewOptionsBtn.addEventListener('click', this.handleViewOptionsButton);
|
|
}
|
|
|
|
// View options menu items
|
|
const viewOptionsMenu = document.getElementById('sidebarViewOptionsMenu');
|
|
if (viewOptionsMenu) {
|
|
viewOptionsMenu.addEventListener('click', (e) => {
|
|
const item = e.target.closest('.context-menu-item');
|
|
if (!item || item.classList.contains('disabled')) return;
|
|
const action = item.dataset.action;
|
|
if (action) {
|
|
this.handleViewOptionsAction(action);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Create folder button
|
|
const createFolderBtn = document.getElementById('sidebarCreateFolder');
|
|
if (createFolderBtn) {
|
|
createFolderBtn.addEventListener('click', this.handleCreateFolderButton);
|
|
}
|
|
|
|
// Collapse all button
|
|
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
|
if (collapseAllBtn) {
|
|
collapseAllBtn.addEventListener('click', this.handleCollapseAll);
|
|
}
|
|
|
|
// Tree click handler
|
|
const folderTree = document.getElementById('sidebarFolderTree');
|
|
if (folderTree) {
|
|
folderTree.addEventListener('click', this.handleTreeClick);
|
|
folderTree.addEventListener('contextmenu', this.handleTreeContextMenu);
|
|
}
|
|
|
|
// Breadcrumb click handler
|
|
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
|
|
if (sidebarBreadcrumbNav) {
|
|
sidebarBreadcrumbNav.addEventListener('click', this.handleBreadcrumbClick);
|
|
}
|
|
|
|
// Close sidebar when clicking outside on mobile
|
|
document.addEventListener('click', (e) => {
|
|
if (window.innerWidth <= 1024) {
|
|
const sidebar = document.getElementById('folderSidebar');
|
|
if (sidebar && !sidebar.contains(e.target) && !this.isDisabledByPage) {
|
|
sidebar.classList.remove('visible');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Handle window resize
|
|
window.addEventListener('resize', () => {
|
|
this.updateContainerMargin();
|
|
});
|
|
|
|
// Add document click handler for closing dropdowns
|
|
document.addEventListener('click', this.handleDocumentClick);
|
|
|
|
// Add dedicated resize listener for container margin updates
|
|
window.addEventListener('resize', this.updateContainerMargin);
|
|
|
|
// Sidebar folder context menu click handler
|
|
const sidebarFolderMenu = document.getElementById('sidebarFolderContextMenu');
|
|
if (sidebarFolderMenu) {
|
|
sidebarFolderMenu.addEventListener('click', (e) => {
|
|
const item = e.target.closest('.context-menu-item');
|
|
if (!item) return;
|
|
const action = item.dataset.action;
|
|
if (action) {
|
|
this.handleFolderContextMenuAction(action);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Dedicated hide sidebar button
|
|
const hideToggle = document.getElementById('sidebarHideToggle');
|
|
if (hideToggle) {
|
|
hideToggle.addEventListener('click', this.handleHideToggle);
|
|
}
|
|
|
|
// Folder delete confirmation modal buttons
|
|
this._wireDeleteFolderModal();
|
|
}
|
|
|
|
handleDocumentClick(event) {
|
|
// Close open dropdown when clicking outside
|
|
if (this.openDropdown && !event.target.closest('.breadcrumb-dropdown')) {
|
|
this.closeDropdown();
|
|
}
|
|
}
|
|
|
|
handleSidebarHeaderClick(event) {
|
|
// Only trigger root selection if clicking on the title area, not the buttons
|
|
if (!event.target.closest('.sidebar-header-actions')) {
|
|
this.selectFolder(null);
|
|
}
|
|
}
|
|
|
|
handleHideToggle(event) {
|
|
event.stopPropagation();
|
|
this.toggleHideOnThisPage();
|
|
}
|
|
|
|
handleCollapseAll(event) {
|
|
event?.stopPropagation();
|
|
this.expandedNodes.clear();
|
|
this.renderFolderDisplay();
|
|
this.saveExpandedState();
|
|
}
|
|
|
|
// ===== Sidebar visibility (per-page) and container margin =====
|
|
|
|
updateContainerMargin() {
|
|
const container = document.querySelector('.container');
|
|
const sidebar = document.getElementById('folderSidebar');
|
|
|
|
if (!container || !sidebar) return;
|
|
|
|
// Always reset margin first — needed when transitioning from visible to hidden
|
|
container.style.marginLeft = '';
|
|
|
|
// When per-page disabled, skip adjustment but margin is already reset
|
|
if (this.isDisabledByPage) return;
|
|
|
|
// Sidebar is visible — adjust margin if we need room
|
|
const sidebarWidth = sidebar.offsetWidth;
|
|
const viewportWidth = window.innerWidth;
|
|
const containerWidth = container.offsetWidth;
|
|
|
|
if (sidebarWidth + containerWidth + sidebarWidth > viewportWidth) {
|
|
container.style.marginLeft = `${sidebarWidth + 10}px`;
|
|
}
|
|
}
|
|
|
|
updateDomVisibility() {
|
|
const isHidden = this.isDisabledByPage;
|
|
const sidebar = document.getElementById('folderSidebar');
|
|
|
|
if (sidebar) {
|
|
sidebar.classList.toggle('visible', !isHidden);
|
|
sidebar.classList.toggle('hidden-by-setting', isHidden);
|
|
sidebar.setAttribute('aria-hidden', isHidden.toString());
|
|
}
|
|
|
|
// Show or hide the "sidebar hidden" edge indicator
|
|
if (isHidden) {
|
|
this.showSidebarHiddenIndicator();
|
|
} else {
|
|
this.hideSidebarHiddenIndicator();
|
|
}
|
|
}
|
|
|
|
|
|
toggleHideOnThisPage() {
|
|
this.isDisabledByPage = !this.isDisabledByPage;
|
|
setStorageItem(`${this.pageType}_sidebarDisabled`, this.isDisabledByPage);
|
|
this.updateDomVisibility();
|
|
this.updateContainerMargin();
|
|
}
|
|
|
|
getPageDisplayName() {
|
|
const names = {
|
|
loras: 'LoRAs',
|
|
recipes: 'Recipes',
|
|
checkpoints: 'Checkpoints',
|
|
embeddings: 'Embeddings',
|
|
other: 'Other Models',
|
|
};
|
|
return names[this.pageType] || this.pageType;
|
|
}
|
|
|
|
showSidebarHiddenIndicator() {
|
|
if (document.getElementById('sidebarHiddenIndicator')) return;
|
|
|
|
const indicator = document.createElement('div');
|
|
indicator.id = 'sidebarHiddenIndicator';
|
|
indicator.className = 'sidebar-hidden-indicator';
|
|
indicator.innerHTML = `
|
|
<i class="fas fa-chevron-right"></i>
|
|
<span class="sidebar-hidden-indicator-tooltip">${translate('sidebar.showSidebar')}</span>
|
|
`;
|
|
|
|
// Subtle breathing animation on first sight to aid discoverability;
|
|
// stops permanently after user clicks the restore button once
|
|
const restoreKey = `${this.pageType}_restoreButtonUsed`;
|
|
if (!getStorageItem(restoreKey, false)) {
|
|
indicator.classList.add('breathing');
|
|
}
|
|
|
|
indicator.addEventListener('click', () => {
|
|
setStorageItem(restoreKey, true);
|
|
this.toggleHideOnThisPage();
|
|
});
|
|
|
|
document.body.appendChild(indicator);
|
|
}
|
|
|
|
hideSidebarHiddenIndicator() {
|
|
const indicator = document.getElementById('sidebarHiddenIndicator');
|
|
if (indicator) {
|
|
indicator.remove();
|
|
}
|
|
}
|
|
|
|
async loadFolderTree() {
|
|
try {
|
|
const includeEmpty = this.showEmptyFolders && this._supportsFolderManagement();
|
|
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;
|
|
}
|
|
} 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;
|
|
}
|
|
}
|
|
this.folderTreeLoaded = true;
|
|
this.renderFolderDisplay();
|
|
} catch (error) {
|
|
this.folderTreeLoaded = false;
|
|
console.error('Failed to load folder data:', error);
|
|
this.renderEmptyState();
|
|
}
|
|
}
|
|
|
|
_supportsFolderManagement() {
|
|
return Boolean(this.apiClient?.apiConfig?.config?.supportsFolderManagement);
|
|
}
|
|
|
|
// The models-only folder list only contains directories that directly
|
|
// hold model files. Expand it with every ancestor prefix so a folder
|
|
// whose subtree contains models (e.g. "a" with models in "a/b") is not
|
|
// dimmed as empty — the "empty" style means "no models anywhere below".
|
|
_buildNonEmptyFolderSet(folders) {
|
|
const set = new Set();
|
|
for (const folder of folders) {
|
|
set.add(folder);
|
|
if (!folder) continue;
|
|
const parts = folder.split('/');
|
|
for (let i = 1; i < parts.length; i++) {
|
|
set.add(parts.slice(0, i).join('/'));
|
|
}
|
|
}
|
|
return set;
|
|
}
|
|
|
|
folderExistsInTree(path) {
|
|
if (!path) return true;
|
|
|
|
if (this.displayMode === 'tree') {
|
|
let node = this.treeData;
|
|
for (const segment of path.split('/')) {
|
|
if (!node || typeof node !== 'object' || !(segment in node)) {
|
|
return false;
|
|
}
|
|
node = node[segment];
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return this.foldersList.includes(path);
|
|
}
|
|
|
|
renderFolderDisplay() {
|
|
if (this.displayMode === 'tree') {
|
|
this.renderTree();
|
|
} else {
|
|
this.renderFolderList();
|
|
}
|
|
this.initializeDragAndDrop();
|
|
}
|
|
|
|
renderTree() {
|
|
const folderTree = document.getElementById('sidebarFolderTree');
|
|
if (!folderTree) return;
|
|
|
|
if (!this.treeData || Object.keys(this.treeData).length === 0) {
|
|
this.renderEmptyState();
|
|
return;
|
|
}
|
|
|
|
folderTree.innerHTML = this.renderTreeNode(this.treeData, '');
|
|
}
|
|
|
|
renderTreeNode(nodeData, basePath) {
|
|
const entries = Object.entries(nodeData);
|
|
if (entries.length === 0) return '';
|
|
|
|
return entries.map(([folderName, children]) => {
|
|
const currentPath = basePath ? `${basePath}/${folderName}` : folderName;
|
|
const hasChildren = Object.keys(children).length > 0;
|
|
const isExpanded = this.expandedNodes.has(currentPath);
|
|
const isSelected = this.selectedPath === currentPath;
|
|
const isEmpty = this.nonEmptyFolders ? !this.nonEmptyFolders.has(currentPath) : false;
|
|
|
|
const escapedPath = escapeAttribute(currentPath);
|
|
const escapedFolderName = escapeHtml(folderName);
|
|
const escapedTitle = escapeAttribute(folderName);
|
|
|
|
return `
|
|
<div class="sidebar-tree-node" data-path="${escapedPath}">
|
|
<div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''} ${isEmpty ? 'empty' : ''}" data-path="${escapedPath}">
|
|
<div class="sidebar-tree-expand-icon ${isExpanded ? 'expanded' : ''}"
|
|
style="${hasChildren ? '' : 'opacity: 0; pointer-events: none;'}">
|
|
<i class="fas fa-chevron-right"></i>
|
|
</div>
|
|
<i class="fas fa-folder${isEmpty ? '-open' : ''} sidebar-tree-folder-icon"></i>
|
|
<div class="sidebar-tree-folder-name" title="${escapedTitle}">${escapedFolderName}</div>
|
|
</div>
|
|
${hasChildren ? `
|
|
<div class="sidebar-tree-children ${isExpanded ? 'expanded' : ''}">
|
|
${this.renderTreeNode(children, currentPath)}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
renderEmptyState() {
|
|
const folderTree = document.getElementById('sidebarFolderTree');
|
|
if (!folderTree) return;
|
|
|
|
// Only pages with folder management (model libraries) offer the
|
|
// header create button; other pages just show the empty label.
|
|
const hintHtml = this._supportsFolderManagement() ? `
|
|
<div class="sidebar-empty-hint">
|
|
<i class="fas fa-hand-pointer"></i>
|
|
${translate('sidebar.empty.createHint', {}, 'Click the New Folder button above to create folders')}
|
|
</div>` : '';
|
|
|
|
folderTree.innerHTML = `
|
|
<div class="sidebar-tree-placeholder">
|
|
<i class="fas fa-folder-open"></i>
|
|
<div>${translate('sidebar.empty.noFolders', {}, 'No folders found')}</div>${hintHtml}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
renderFolderList() {
|
|
const folderTree = document.getElementById('sidebarFolderTree');
|
|
if (!folderTree) return;
|
|
|
|
if (!this.foldersList || this.foldersList.length === 0) {
|
|
this.renderEmptyState();
|
|
return;
|
|
}
|
|
|
|
const foldersHtml = this.foldersList.map(folder => {
|
|
const displayName = folder === '' ? '/' : folder;
|
|
const isSelected = this.selectedPath === folder;
|
|
const isEmpty = this.nonEmptyFolders ? !this.nonEmptyFolders.has(folder) : false;
|
|
const escapedPath = escapeAttribute(folder);
|
|
const escapedDisplayName = escapeHtml(displayName);
|
|
const escapedTitle = escapeAttribute(displayName);
|
|
|
|
return `
|
|
<div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${escapedPath}">
|
|
<div class="sidebar-node-content ${isEmpty ? 'empty' : ''}" data-path="${escapedPath}">
|
|
<i class="fas fa-folder${isEmpty ? '-open' : ''} sidebar-folder-icon"></i>
|
|
<div class="sidebar-folder-name" title="${escapedTitle}">${escapedDisplayName}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
folderTree.innerHTML = foldersHtml;
|
|
}
|
|
|
|
handleTreeClick(event) {
|
|
// Clicks on the inline create-folder row must not select/toggle nodes
|
|
if (event.target.closest('.sidebar-create-folder-node')) return;
|
|
|
|
if (this.displayMode === 'list') {
|
|
this.handleFolderListClick(event);
|
|
return;
|
|
}
|
|
|
|
const expandIcon = event.target.closest('.sidebar-tree-expand-icon');
|
|
const nodeContent = event.target.closest('.sidebar-tree-node-content');
|
|
|
|
if (expandIcon) {
|
|
// Toggle expand/collapse
|
|
const treeNode = expandIcon.closest('.sidebar-tree-node');
|
|
const path = treeNode.dataset.path;
|
|
const children = treeNode.querySelector('.sidebar-tree-children');
|
|
|
|
if (this.expandedNodes.has(path)) {
|
|
this.expandedNodes.delete(path);
|
|
expandIcon.classList.remove('expanded');
|
|
if (children) children.classList.remove('expanded');
|
|
} else {
|
|
this.expandedNodes.add(path);
|
|
expandIcon.classList.add('expanded');
|
|
if (children) children.classList.add('expanded');
|
|
}
|
|
|
|
this.saveExpandedState();
|
|
} else if (nodeContent) {
|
|
// Select folder
|
|
const treeNode = nodeContent.closest('.sidebar-tree-node');
|
|
const path = treeNode.dataset.path;
|
|
this.selectFolder(path);
|
|
}
|
|
}
|
|
|
|
handleTreeContextMenu(event) {
|
|
// No context menu on the inline create-folder row
|
|
if (event.target.closest('.sidebar-create-folder-node')) return;
|
|
|
|
const nodeContent = event.target.closest('.sidebar-tree-node, .sidebar-folder-item');
|
|
if (!nodeContent) return;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const path = nodeContent.dataset.path;
|
|
if (path === undefined || path === null || path === '') return;
|
|
|
|
this._showFolderContextMenu(event.clientX, event.clientY, path);
|
|
}
|
|
|
|
_showFolderContextMenu(x, y, path) {
|
|
this._closeFolderContextMenu();
|
|
this._closeViewOptionsMenu();
|
|
|
|
const menu = document.getElementById('sidebarFolderContextMenu');
|
|
if (!menu) return;
|
|
|
|
// Folder creation is only available on pages backed by model library
|
|
// roots (not recipes, which have no on-disk folder management).
|
|
const createItem = menu.querySelector('[data-action="create-subfolder"]');
|
|
if (createItem) {
|
|
createItem.style.display = this._supportsFolderManagement() ? '' : 'none';
|
|
}
|
|
|
|
// Deletion is gated the same way: recipes have virtual folders only.
|
|
const deleteItem = menu.querySelector('[data-action="delete-folder"]');
|
|
if (deleteItem) {
|
|
deleteItem.style.display = this._supportsFolderManagement() ? '' : 'none';
|
|
}
|
|
|
|
// Renaming an on-disk folder is likewise library-only.
|
|
const renameItem = menu.querySelector('[data-action="rename-folder"]');
|
|
if (renameItem) {
|
|
renameItem.style.display = this._supportsFolderManagement() ? '' : 'none';
|
|
}
|
|
|
|
menu.style.left = `${x}px`;
|
|
menu.style.top = `${y}px`;
|
|
menu.style.display = 'block';
|
|
menu.dataset.folderPath = path;
|
|
|
|
this._folderContextOpen = true;
|
|
|
|
// Close on next click outside
|
|
this._folderContextCloseHandler = (e) => {
|
|
if (!menu.contains(e.target)) {
|
|
this._closeFolderContextMenu();
|
|
}
|
|
};
|
|
setTimeout(() => {
|
|
document.addEventListener('click', this._folderContextCloseHandler);
|
|
}, 0);
|
|
}
|
|
|
|
_closeFolderContextMenu() {
|
|
const menu = document.getElementById('sidebarFolderContextMenu');
|
|
if (menu) {
|
|
menu.style.display = 'none';
|
|
delete menu.dataset.folderPath;
|
|
}
|
|
if (this._folderContextCloseHandler) {
|
|
document.removeEventListener('click', this._folderContextCloseHandler);
|
|
this._folderContextCloseHandler = null;
|
|
}
|
|
this._folderContextOpen = false;
|
|
}
|
|
|
|
handleFolderContextMenuAction(action) {
|
|
const menu = document.getElementById('sidebarFolderContextMenu');
|
|
if (!menu) return;
|
|
|
|
const path = menu.dataset.folderPath;
|
|
this._closeFolderContextMenu();
|
|
|
|
if (!path) return;
|
|
|
|
this._performFolderAction(action, path);
|
|
}
|
|
|
|
async _performFolderAction(action, path) {
|
|
switch (action) {
|
|
case 'create-subfolder':
|
|
this.showCreateFolderInput(path);
|
|
break;
|
|
case 'rename-folder':
|
|
this.showRenameFolderInput(path);
|
|
break;
|
|
case 'delete-folder':
|
|
this.showDeleteFolderModal(path);
|
|
break;
|
|
case 'check-folder-updates':
|
|
try {
|
|
await performFolderUpdateCheck(path);
|
|
} catch (error) {
|
|
console.error('Folder update check failed:', error);
|
|
}
|
|
break;
|
|
default:
|
|
console.warn('Unknown folder action:', action);
|
|
}
|
|
}
|
|
|
|
handleBreadcrumbClick(event) {
|
|
const breadcrumbItem = event.target.closest('.sidebar-breadcrumb-item');
|
|
const dropdownItem = event.target.closest('.breadcrumb-dropdown-item');
|
|
|
|
if (dropdownItem) {
|
|
// Handle dropdown item selection
|
|
const path = dropdownItem.dataset.path || '';
|
|
this.selectFolder(path);
|
|
this.closeDropdown();
|
|
} else if (breadcrumbItem) {
|
|
// Handle breadcrumb item click
|
|
const path = breadcrumbItem.dataset.path || null; // null for showing all models
|
|
const isPlaceholder = breadcrumbItem.classList.contains('placeholder');
|
|
const isActive = breadcrumbItem.classList.contains('active');
|
|
const dropdown = breadcrumbItem.closest('.breadcrumb-dropdown');
|
|
|
|
if (isPlaceholder || (isActive && path === this.selectedPath)) {
|
|
// Open dropdown for placeholders or active items
|
|
// Close any open dropdown first
|
|
if (this.openDropdown && this.openDropdown !== dropdown) {
|
|
this.openDropdown.classList.remove('open');
|
|
}
|
|
|
|
// Toggle current dropdown
|
|
dropdown.classList.toggle('open');
|
|
|
|
// Update open dropdown reference
|
|
this.openDropdown = dropdown.classList.contains('open') ? dropdown : null;
|
|
} else {
|
|
// Navigate to the selected path
|
|
this.selectFolder(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
closeDropdown() {
|
|
if (this.openDropdown) {
|
|
this.openDropdown.classList.remove('open');
|
|
this.openDropdown = null;
|
|
}
|
|
}
|
|
|
|
async selectFolder(path) {
|
|
// Normalize path: null or undefined means root
|
|
const normalizedPath = (path === null || path === undefined) ? '' : path;
|
|
|
|
// Update selected path
|
|
this.selectedPath = normalizedPath;
|
|
|
|
// Update UI
|
|
this.updateTreeSelection();
|
|
this.updateBreadcrumbs();
|
|
this.updateSidebarHeader();
|
|
|
|
// Update page state
|
|
this.pageControls.pageState.activeFolder = normalizedPath;
|
|
setStorageItem(`${this.pageType}_activeFolder`, normalizedPath);
|
|
|
|
// Reload models with new filter (loadMoreWithVirtualScroll will scroll to top)
|
|
await this.pageControls.resetAndReload();
|
|
}
|
|
|
|
handleFolderListClick(event) {
|
|
const folderItem = event.target.closest('.sidebar-folder-item');
|
|
|
|
if (folderItem) {
|
|
const path = folderItem.dataset.path;
|
|
this.selectFolder(path);
|
|
}
|
|
}
|
|
|
|
handleDisplayModeToggle(event) {
|
|
event?.stopPropagation();
|
|
this.displayMode = this.displayMode === 'tree' ? 'list' : 'tree';
|
|
this.updateViewOptionsMenu();
|
|
this.updateCollapseAllButton();
|
|
this.updateSearchRecursiveOption();
|
|
this.saveDisplayMode();
|
|
this.loadFolderTree(); // Reload with new display mode
|
|
}
|
|
|
|
async handleRecursiveToggle(event) {
|
|
event?.stopPropagation();
|
|
|
|
if (this.displayMode !== 'tree') {
|
|
return;
|
|
}
|
|
|
|
this.recursiveSearchEnabled = !this.recursiveSearchEnabled;
|
|
setStorageItem(`${this.pageType}_recursiveSearch`, this.recursiveSearchEnabled);
|
|
this.updateSearchRecursiveOption();
|
|
this.updateViewOptionsMenu();
|
|
|
|
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') {
|
|
try {
|
|
await this.pageControls.resetAndReload(true);
|
|
} catch (error) {
|
|
console.error('Failed to reload models after toggling recursive search:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
handleEmptyFoldersToggle(event) {
|
|
event?.stopPropagation();
|
|
|
|
if (!this._supportsFolderManagement()) {
|
|
return;
|
|
}
|
|
|
|
this.showEmptyFolders = !this.showEmptyFolders;
|
|
setStorageItem(`${this.pageType}_showEmptyFolders`, this.showEmptyFolders);
|
|
this.updateViewOptionsMenu();
|
|
this.loadFolderTree();
|
|
}
|
|
|
|
handleCreateFolderButton(event) {
|
|
event.stopPropagation();
|
|
|
|
if (!this._supportsFolderManagement()) {
|
|
return;
|
|
}
|
|
|
|
this.showCreateFolderInput();
|
|
}
|
|
|
|
handleViewOptionsButton(event) {
|
|
event.stopPropagation();
|
|
|
|
const menu = document.getElementById('sidebarViewOptionsMenu');
|
|
if (!menu) return;
|
|
|
|
if (menu.style.display === 'block') {
|
|
this._closeViewOptionsMenu();
|
|
return;
|
|
}
|
|
|
|
this._closeFolderContextMenu();
|
|
this.updateViewOptionsMenu();
|
|
|
|
const anchor = event.currentTarget;
|
|
const rect = anchor.getBoundingClientRect();
|
|
menu.style.display = 'block';
|
|
// Right-align the menu under the button so it stays within the sidebar
|
|
menu.style.left = `${Math.max(8, rect.right - menu.offsetWidth)}px`;
|
|
menu.style.top = `${rect.bottom + 4}px`;
|
|
|
|
this._viewOptionsCloseHandler = (e) => {
|
|
if (!menu.contains(e.target)) {
|
|
this._closeViewOptionsMenu();
|
|
}
|
|
};
|
|
setTimeout(() => {
|
|
document.addEventListener('click', this._viewOptionsCloseHandler);
|
|
}, 0);
|
|
}
|
|
|
|
_closeViewOptionsMenu() {
|
|
const menu = document.getElementById('sidebarViewOptionsMenu');
|
|
if (menu) {
|
|
menu.style.display = 'none';
|
|
}
|
|
if (this._viewOptionsCloseHandler) {
|
|
document.removeEventListener('click', this._viewOptionsCloseHandler);
|
|
this._viewOptionsCloseHandler = null;
|
|
}
|
|
}
|
|
|
|
handleViewOptionsAction(action) {
|
|
switch (action) {
|
|
case 'view-mode-tree':
|
|
if (this.displayMode !== 'tree') {
|
|
this.handleDisplayModeToggle();
|
|
}
|
|
this._closeViewOptionsMenu();
|
|
break;
|
|
case 'view-mode-list':
|
|
if (this.displayMode !== 'list') {
|
|
this.handleDisplayModeToggle();
|
|
}
|
|
this._closeViewOptionsMenu();
|
|
break;
|
|
case 'toggle-recursive':
|
|
// Keep the menu open so view preferences can be combined
|
|
this.handleRecursiveToggle();
|
|
break;
|
|
case 'toggle-empty-folders':
|
|
this.handleEmptyFoldersToggle();
|
|
break;
|
|
default:
|
|
console.warn('Unknown view options action:', action);
|
|
}
|
|
}
|
|
|
|
updateFolderManagementButtons() {
|
|
const supported = this._supportsFolderManagement();
|
|
|
|
const createFolderBtn = document.getElementById('sidebarCreateFolder');
|
|
if (createFolderBtn) {
|
|
createFolderBtn.style.display = supported ? '' : 'none';
|
|
}
|
|
|
|
this.updateViewOptionsMenu();
|
|
}
|
|
|
|
updateViewOptionsMenu() {
|
|
const menu = document.getElementById('sidebarViewOptionsMenu');
|
|
if (!menu) return;
|
|
|
|
const setCheck = (action, checked) => {
|
|
const check = menu.querySelector(`[data-action="${action}"] .check-indicator`);
|
|
if (check) {
|
|
check.style.display = checked ? 'block' : 'none';
|
|
}
|
|
};
|
|
const setDisabled = (action, disabled) => {
|
|
const item = menu.querySelector(`[data-action="${action}"]`);
|
|
if (item) {
|
|
item.classList.toggle('disabled', disabled);
|
|
}
|
|
};
|
|
|
|
const isTreeMode = this.displayMode === 'tree';
|
|
setCheck('view-mode-tree', isTreeMode);
|
|
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
|
|
const emptyFoldersItem = menu.querySelector('[data-action="toggle-empty-folders"]');
|
|
if (emptyFoldersItem) {
|
|
emptyFoldersItem.style.display = this._supportsFolderManagement() ? '' : 'none';
|
|
}
|
|
}
|
|
|
|
updateCollapseAllButton() {
|
|
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
|
if (!collapseAllBtn) return;
|
|
|
|
const isTreeMode = this.displayMode === 'tree';
|
|
collapseAllBtn.disabled = !isTreeMode;
|
|
collapseAllBtn.classList.toggle('disabled', !isTreeMode);
|
|
collapseAllBtn.title = isTreeMode
|
|
? translate('sidebar.collapseAll')
|
|
: translate('sidebar.collapseAllDisabled', {}, 'Not available in list view');
|
|
}
|
|
|
|
updateSearchRecursiveOption() {
|
|
const isRecursive = this.displayMode === 'tree' && this.recursiveSearchEnabled;
|
|
this.pageControls.pageState.searchOptions.recursive = isRecursive;
|
|
}
|
|
|
|
updateTreeSelection() {
|
|
const folderTree = document.getElementById('sidebarFolderTree');
|
|
if (!folderTree) return;
|
|
|
|
if (this.displayMode === 'list') {
|
|
// Remove all selections in list mode
|
|
folderTree.querySelectorAll('.sidebar-folder-item').forEach(item => {
|
|
item.classList.remove('selected');
|
|
});
|
|
|
|
// Add selection to current path
|
|
if (this.selectedPath !== null && this.selectedPath !== undefined) {
|
|
const escapedPathSelector = CSS.escape(this.selectedPath);
|
|
const selectedItem = folderTree.querySelector(`[data-path="${escapedPathSelector}"]`);
|
|
if (selectedItem) {
|
|
selectedItem.classList.add('selected');
|
|
}
|
|
}
|
|
} else {
|
|
folderTree.querySelectorAll('.sidebar-tree-node-content').forEach(node => {
|
|
node.classList.remove('selected');
|
|
});
|
|
|
|
if (this.selectedPath !== null && this.selectedPath !== undefined) {
|
|
const escapedPathSelector = CSS.escape(this.selectedPath);
|
|
const selectedNode = folderTree.querySelector(`[data-path="${escapedPathSelector}"] .sidebar-tree-node-content`);
|
|
if (selectedNode) {
|
|
selectedNode.classList.add('selected');
|
|
this.expandPathParents(this.selectedPath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
expandPathParents(path) {
|
|
if (!path) return;
|
|
|
|
const parts = path.split('/');
|
|
let currentPath = '';
|
|
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
|
|
this.expandedNodes.add(currentPath);
|
|
}
|
|
|
|
this.renderTree();
|
|
}
|
|
|
|
// Get sibling folders for a given path level
|
|
getSiblingFolders(pathParts, level) {
|
|
if (level === 0) {
|
|
// Root level siblings are top-level folders
|
|
return Object.keys(this.treeData);
|
|
}
|
|
|
|
// Navigate to the parent folder to get siblings
|
|
let currentNode = this.treeData;
|
|
for (let i = 0; i < level; i++) {
|
|
if (!currentNode[pathParts[i]]) {
|
|
return [];
|
|
}
|
|
currentNode = currentNode[pathParts[i]];
|
|
}
|
|
|
|
return Object.keys(currentNode);
|
|
}
|
|
|
|
// Get child folders for a given path
|
|
getChildFolders(path) {
|
|
if (!path) {
|
|
return Object.keys(this.treeData);
|
|
}
|
|
|
|
const parts = path.split('/');
|
|
let currentNode = this.treeData;
|
|
|
|
for (const part of parts) {
|
|
if (!currentNode[part]) {
|
|
return [];
|
|
}
|
|
currentNode = currentNode[part];
|
|
}
|
|
|
|
return Object.keys(currentNode);
|
|
}
|
|
|
|
updateBreadcrumbs() {
|
|
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
|
|
if (!sidebarBreadcrumbNav) return;
|
|
|
|
const parts = this.selectedPath ? this.selectedPath.split('/') : [];
|
|
let currentPath = '';
|
|
|
|
// Start with root breadcrumb
|
|
const rootSiblings = Object.keys(this.treeData);
|
|
const isRootSelected = !this.selectedPath;
|
|
const breadcrumbs = [`
|
|
<div class="breadcrumb-dropdown">
|
|
<span class="sidebar-breadcrumb-item ${isRootSelected ? 'active' : ''}" data-path="">
|
|
<i class="fas fa-home"></i> ${escapeHtml(this.apiClient.apiConfig.config.displayName)} root
|
|
</span>
|
|
</div>
|
|
`];
|
|
|
|
// Add separator and placeholder for next level if we're at root
|
|
if (!this.selectedPath) {
|
|
const nextLevelFolders = rootSiblings;
|
|
if (nextLevelFolders.length > 0) {
|
|
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
|
|
breadcrumbs.push(`
|
|
<div class="breadcrumb-dropdown">
|
|
<span class="sidebar-breadcrumb-item placeholder">
|
|
--
|
|
<span class="breadcrumb-dropdown-indicator">
|
|
<i class="fas fa-caret-down"></i>
|
|
</span>
|
|
</span>
|
|
<div class="breadcrumb-dropdown-menu">
|
|
${nextLevelFolders.map(folder => `
|
|
<div class="breadcrumb-dropdown-item" data-path="${escapeAttribute(folder)}">
|
|
${escapeHtml(folder)}
|
|
</div>`).join('')
|
|
}
|
|
</div>
|
|
</div>
|
|
`);
|
|
}
|
|
}
|
|
|
|
// Add breadcrumb items for each path segment
|
|
parts.forEach((part, index) => {
|
|
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
const isLast = index === parts.length - 1;
|
|
|
|
// Get siblings for this level
|
|
const siblings = this.getSiblingFolders(parts, index);
|
|
const escapedCurrentPath = escapeAttribute(currentPath);
|
|
const escapedPart = escapeHtml(part);
|
|
|
|
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
|
|
breadcrumbs.push(`
|
|
<div class="breadcrumb-dropdown">
|
|
<span class="sidebar-breadcrumb-item ${isLast ? 'active' : ''}" data-path="${escapedCurrentPath}">
|
|
${escapedPart}
|
|
${siblings.length > 1 ? `
|
|
<span class="breadcrumb-dropdown-indicator">
|
|
<i class="fas fa-caret-down"></i>
|
|
</span>
|
|
` : ''}
|
|
</span>
|
|
${siblings.length > 1 ? `
|
|
<div class="breadcrumb-dropdown-menu">
|
|
${siblings.map(folder => {
|
|
const siblingPath = parts.slice(0, index).concat(folder).join('/');
|
|
return `
|
|
<div class="breadcrumb-dropdown-item ${folder === part ? 'active' : ''}"
|
|
data-path="${escapeAttribute(siblingPath)}">
|
|
${escapeHtml(folder)}
|
|
</div>`;
|
|
}).join('')
|
|
}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`);
|
|
|
|
// Add separator and placeholder for next level if not the last item
|
|
if (isLast) {
|
|
const childFolders = this.getChildFolders(currentPath);
|
|
if (childFolders.length > 0) {
|
|
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
|
|
breadcrumbs.push(`
|
|
<div class="breadcrumb-dropdown">
|
|
<span class="sidebar-breadcrumb-item placeholder">
|
|
--
|
|
<span class="breadcrumb-dropdown-indicator">
|
|
<i class="fas fa-caret-down"></i>
|
|
</span>
|
|
</span>
|
|
<div class="breadcrumb-dropdown-menu">
|
|
${childFolders.map(folder => `
|
|
<div class="breadcrumb-dropdown-item" data-path="${escapeAttribute(currentPath + '/' + folder)}">
|
|
${escapeHtml(folder)}
|
|
</div>`).join('')
|
|
}
|
|
</div>
|
|
</div>
|
|
`);
|
|
}
|
|
}
|
|
});
|
|
|
|
sidebarBreadcrumbNav.innerHTML = breadcrumbs.join('');
|
|
}
|
|
|
|
updateSidebarHeader() {
|
|
const sidebarHeader = document.getElementById('sidebarHeader');
|
|
if (!sidebarHeader) return;
|
|
|
|
if (!this.selectedPath) {
|
|
sidebarHeader.classList.add('root-selected');
|
|
} else {
|
|
sidebarHeader.classList.remove('root-selected');
|
|
}
|
|
}
|
|
|
|
restoreSidebarState() {
|
|
// Migration: old pin/unpin and global hide → per-page hide
|
|
this._migrateOldSettings();
|
|
|
|
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
|
|
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
|
|
const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true);
|
|
this.isDisabledByPage = getStorageItem(
|
|
`${this.pageType}_sidebarDisabled`,
|
|
SIDEBAR_DEFAULT_HIDDEN_PAGES.has(this.pageType)
|
|
);
|
|
|
|
this.expandedNodes = new Set(expandedPaths);
|
|
this.displayMode = displayMode;
|
|
this.recursiveSearchEnabled = recursiveSearchEnabled;
|
|
this.showEmptyFolders = getStorageItem(`${this.pageType}_showEmptyFolders`, false);
|
|
|
|
this.updateSearchRecursiveOption();
|
|
this.updateFolderManagementButtons();
|
|
this.updateCollapseAllButton();
|
|
}
|
|
|
|
/**
|
|
* One-time migration: old pin/unpin and global show_folder_sidebar → per-page hide
|
|
* - sidebarPinned=false (was auto-hide) → sidebarDisabled=true for that page
|
|
* - show_folder_sidebar=false (global) → sidebarDisabled=true for ALL pages
|
|
*/
|
|
_migrateOldSettings() {
|
|
if (getStorageItem('_sidebar_migration_done')) return;
|
|
|
|
const PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings', 'other'];
|
|
|
|
// 1. Migrate global hide setting to per-page
|
|
if (state?.global?.settings?.show_folder_sidebar === false) {
|
|
PAGES.forEach(p => setStorageItem(`${p}_sidebarDisabled`, true));
|
|
}
|
|
|
|
// 2. Migrate unpinned (auto-hide) to per-page hide
|
|
PAGES.forEach(p => {
|
|
const wasPinned = getStorageItem(`${p}_sidebarPinned`, true);
|
|
const alreadyDisabled = getStorageItem(`${p}_sidebarDisabled`, false);
|
|
if (wasPinned === false && !alreadyDisabled) {
|
|
// Was auto-hide → user didn't want sidebar taking space
|
|
setStorageItem(`${p}_sidebarDisabled`, true);
|
|
}
|
|
// Clean up old keys
|
|
localStorage.removeItem(`${p}_sidebarPinned`);
|
|
});
|
|
|
|
setStorageItem('_sidebar_migration_done', true);
|
|
}
|
|
|
|
restoreSelectedFolder() {
|
|
const activeFolder = getStorageItem(`${this.pageType}_activeFolder`);
|
|
if (activeFolder && typeof activeFolder === 'string') {
|
|
// Fall back to the root when the persisted folder no longer
|
|
// exists in the freshly loaded tree (e.g. it was moved or
|
|
// deleted); otherwise the grid stays empty with a phantom
|
|
// breadcrumb. Skip validation when the tree failed to load so a
|
|
// transient API error doesn't wipe the saved location.
|
|
if (this.folderTreeLoaded && !this.folderExistsInTree(activeFolder)) {
|
|
console.warn(`Persisted folder "${activeFolder}" not found in folder tree, falling back to root`);
|
|
this.selectedPath = '';
|
|
if (this.pageControls?.pageState) {
|
|
this.pageControls.pageState.activeFolder = '';
|
|
}
|
|
setStorageItem(`${this.pageType}_activeFolder`, '');
|
|
// When the reset happens after initialization (e.g. via
|
|
// refresh() after a drag move emptied the folder), reload the
|
|
// listing so the grid shows the root contents instead of
|
|
// staying empty. Skipped during initialize() — the first load
|
|
// picks up the cleared filter on its own.
|
|
if (this.isInitialized && typeof this.pageControls?.resetAndReload === 'function') {
|
|
this.pageControls.resetAndReload().catch((error) => {
|
|
console.error('Failed to reload after resetting folder selection:', error);
|
|
});
|
|
}
|
|
} else {
|
|
this.selectedPath = activeFolder;
|
|
}
|
|
this.updateTreeSelection();
|
|
this.updateBreadcrumbs();
|
|
this.updateSidebarHeader();
|
|
} else {
|
|
this.selectedPath = '';
|
|
this.updateSidebarHeader();
|
|
this.updateBreadcrumbs(); // Always update breadcrumbs
|
|
}
|
|
}
|
|
|
|
saveExpandedState() {
|
|
setStorageItem(`${this.pageType}_expandedNodes`, Array.from(this.expandedNodes));
|
|
}
|
|
|
|
saveDisplayMode() {
|
|
setStorageItem(`${this.pageType}_displayMode`, this.displayMode);
|
|
}
|
|
|
|
async refresh() {
|
|
if (!this.isInitialized) {
|
|
return;
|
|
}
|
|
|
|
await this.loadFolderTree();
|
|
this.restoreSelectedFolder();
|
|
}
|
|
|
|
destroy() {
|
|
this.cleanup();
|
|
}
|
|
}
|
|
|
|
// Create and export global instance
|
|
export const sidebarManager = new SidebarManager();
|