/** * 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 = ` `; 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 = ` `; 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 = `${escapeHtml(translate('sidebar.deleteFolderModal.folderLabel', {}, 'Folder'))}: ${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}
${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 = ` ${translate('sidebar.showSidebar')} `; // 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 ` `; }).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() ? ` ` : ''; folderTree.innerHTML = ` `; } 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 ` `; }).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; // The folder operations are only available on pages backed by model // library roots (recipes have virtual folders only). Their dividers are // collapsed afterwards so such a page shows the update check alone // instead of dangling separators. const supportsFolderManagement = this._supportsFolderManagement(); for (const action of ['create-subfolder', 'rename-folder', 'delete-folder']) { const item = menu.querySelector(`[data-action="${action}"]`); if (item) { item.style.display = supportsFolderManagement ? '' : 'none'; } } this._updateContextMenuSeparators(menu); 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); } /** * Hide separators that no longer divide anything. * * Context-menu entries are gated per page, so a divider can end up * leading, trailing or doubled once its group is hidden — the recipes page, * for example, keeps only "check for updates". A separator survives only * when a visible entry sits on both of its sides, and a run of consecutive * separators collapses to a single line. */ _updateContextMenuSeparators(menu) { const children = [...menu.children]; const isSeparator = (element) => element.classList.contains('context-menu-separator'); const visibleIndexes = children .map((element, index) => (!isSeparator(element) && element.style.display !== 'none' ? index : -1)) .filter((index) => index !== -1); const first = visibleIndexes[0]; const last = visibleIndexes[visibleIndexes.length - 1]; let inSeparatorRun = false; children.forEach((element, index) => { if (!isSeparator(element)) { inSeparatorRun = false; return; } const keep = visibleIndexes.length >= 2 && index > first && index < last && !inSeparatorRun; element.style.display = keep ? '' : 'none'; inSeparatorRun = true; }); } _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 = [` `]; // Add separator and placeholder for next level if we're at root if (!this.selectedPath) { const nextLevelFolders = rootSiblings; if (nextLevelFolders.length > 0) { breadcrumbs.push(`/`); breadcrumbs.push(` `); } } // 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(`/`); breadcrumbs.push(` `); // 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(`/`); breadcrumbs.push(` `); } } }); 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();