mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
b1a653f18f
Other-model downloads now default to a flat layout, so a fresh library shows an empty folder tree there while the sidebar still consumes 230px. Default the per-page visibility to hidden for "other" through a small per-page default set. The preference stays persisted per page, so an explicit show/hide toggle wins afterwards, and the existing edge indicator keeps the hidden sidebar discoverable and recoverable. Primary pages keep their visible default.
1902 lines
75 KiB
JavaScript
1902 lines
75 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 { showToast } 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.sidebarDragHandlersInitialized = false;
|
|
this.folderTreeElement = null;
|
|
this.currentDropTarget = null;
|
|
this.lastPageControls = null;
|
|
this.isDisabledByPage = false;
|
|
this.initializationPromise = null;
|
|
this.isCreatingFolder = false;
|
|
this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态
|
|
|
|
// 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.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.handleSidebarDragEnter = this.handleSidebarDragEnter.bind(this);
|
|
this.handleSidebarDragOver = this.handleSidebarDragOver.bind(this);
|
|
this.handleSidebarDragLeave = this.handleSidebarDragLeave.bind(this);
|
|
this.handleSidebarDrop = this.handleSidebarDrop.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();
|
|
|
|
// Cleanup sidebar drag handlers
|
|
const sidebar = document.getElementById('folderSidebar');
|
|
if (sidebar && this.sidebarDragHandlersInitialized) {
|
|
sidebar.removeEventListener('dragenter', this.handleSidebarDragEnter);
|
|
sidebar.removeEventListener('dragover', this.handleSidebarDragOver);
|
|
sidebar.removeEventListener('dragleave', this.handleSidebarDragLeave);
|
|
sidebar.removeEventListener('drop', this.handleSidebarDrop);
|
|
this.sidebarDragHandlersInitialized = false;
|
|
}
|
|
|
|
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;
|
|
|
|
// 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 collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
|
const folderTree = document.getElementById('sidebarFolderTree');
|
|
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
|
|
const sidebarHeader = document.getElementById('sidebarHeader');
|
|
const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle');
|
|
const recursiveToggleBtn = document.getElementById('sidebarRecursiveToggle');
|
|
|
|
if (collapseAllBtn) {
|
|
collapseAllBtn.removeEventListener('click', this.handleCollapseAll);
|
|
}
|
|
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);
|
|
|
|
if (displayModeToggleBtn) {
|
|
displayModeToggleBtn.removeEventListener('click', this.handleDisplayModeToggle);
|
|
}
|
|
if (recursiveToggleBtn) {
|
|
recursiveToggleBtn.removeEventListener('click', this.handleRecursiveToggle);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Add sidebar-level drag handlers for creating new folders
|
|
const sidebar = document.getElementById('folderSidebar');
|
|
if (sidebar && !this.sidebarDragHandlersInitialized) {
|
|
sidebar.addEventListener('dragenter', this.handleSidebarDragEnter);
|
|
sidebar.addEventListener('dragover', this.handleSidebarDragOver);
|
|
sidebar.addEventListener('dragleave', this.handleSidebarDragLeave);
|
|
sidebar.addEventListener('drop', this.handleSidebarDrop);
|
|
this.sidebarDragHandlersInitialized = true;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Version of performDragMove that accepts state as parameters (for create folder submit)
|
|
async performDragMoveWithState(targetRelativePath, draggedFilePaths, draggedRootPath, draggedFromBulk) {
|
|
console.log('[SidebarManager] performDragMoveWithState called with:', { targetRelativePath, draggedFilePaths, draggedRootPath, draggedFromBulk });
|
|
|
|
if (!draggedFilePaths || draggedFilePaths.length === 0) {
|
|
console.log('[SidebarManager] performDragMoveWithState 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] performDragMoveWithState 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 = draggedRootPath ? draggedRootPath.replace(/\\/g, '/') : '';
|
|
console.log('[SidebarManager] rootPath:', rootPath);
|
|
if (!rootPath) {
|
|
console.log('[SidebarManager] performDragMoveWithState 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 = draggedFromBulk || 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(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(draggedFilePaths[0], destination);
|
|
if (result) {
|
|
movedFiles.push({
|
|
original_file_path: result.original_file_path || 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 (draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
|
|
bulkManager.toggleBulkMode();
|
|
}
|
|
|
|
console.log('[SidebarManager] performDragMoveWithState 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] performDragMoveWithState returning false due to error');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ===== Sidebar-level drag handlers for creating new folders =====
|
|
|
|
handleSidebarDragEnter(event) {
|
|
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
|
|
|
|
const sidebar = document.getElementById('folderSidebar');
|
|
if (!sidebar) return;
|
|
|
|
// Only show create folder zone if not hovering over an existing folder
|
|
const folderElement = this.getFolderElementFromEvent(event);
|
|
if (folderElement) {
|
|
this.hideCreateFolderZone();
|
|
return;
|
|
}
|
|
|
|
// Check if drag is within the sidebar tree container area
|
|
const treeContainer = document.querySelector('.sidebar-tree-container');
|
|
if (treeContainer && treeContainer.contains(event.target)) {
|
|
event.preventDefault();
|
|
this.showCreateFolderZone();
|
|
}
|
|
}
|
|
|
|
handleSidebarDragOver(event) {
|
|
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
|
|
|
|
const folderElement = this.getFolderElementFromEvent(event);
|
|
if (folderElement) {
|
|
this.hideCreateFolderZone();
|
|
return;
|
|
}
|
|
|
|
const treeContainer = document.querySelector('.sidebar-tree-container');
|
|
if (treeContainer && treeContainer.contains(event.target)) {
|
|
event.preventDefault();
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.dropEffect = 'move';
|
|
}
|
|
}
|
|
}
|
|
|
|
handleSidebarDragLeave(event) {
|
|
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
|
|
|
|
const sidebar = document.getElementById('folderSidebar');
|
|
if (!sidebar) return;
|
|
|
|
const relatedTarget = event.relatedTarget instanceof Element ? event.relatedTarget : null;
|
|
|
|
// Only hide if leaving the sidebar entirely
|
|
if (!relatedTarget || !sidebar.contains(relatedTarget)) {
|
|
this.hideCreateFolderZone();
|
|
}
|
|
}
|
|
|
|
async handleSidebarDrop(event) {
|
|
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
|
|
|
|
const folderElement = this.getFolderElementFromEvent(event);
|
|
if (folderElement) {
|
|
// Let the folder drop handler take over
|
|
return;
|
|
}
|
|
|
|
const treeContainer = document.querySelector('.sidebar-tree-container');
|
|
if (!treeContainer || !treeContainer.contains(event.target)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
// Show create folder input
|
|
this.showCreateFolderInput();
|
|
}
|
|
|
|
showCreateFolderZone() {
|
|
if (this.isCreatingFolder) return;
|
|
|
|
const treeContainer = document.querySelector('.sidebar-tree-container');
|
|
if (!treeContainer) return;
|
|
|
|
let zone = document.getElementById('sidebarCreateFolderZone');
|
|
if (!zone) {
|
|
zone = document.createElement('div');
|
|
zone.id = 'sidebarCreateFolderZone';
|
|
zone.className = 'sidebar-create-folder-zone';
|
|
zone.innerHTML = `
|
|
<div class="sidebar-create-folder-content">
|
|
<i class="fas fa-plus-circle"></i>
|
|
<span>${translate('sidebar.dragDrop.createFolderHint', {}, 'Release to create new folder')}</span>
|
|
</div>
|
|
`;
|
|
treeContainer.appendChild(zone);
|
|
}
|
|
|
|
zone.classList.add('active');
|
|
}
|
|
|
|
hideCreateFolderZone() {
|
|
const zone = document.getElementById('sidebarCreateFolderZone');
|
|
if (zone) {
|
|
zone.classList.remove('active');
|
|
}
|
|
}
|
|
|
|
showCreateFolderInput() {
|
|
console.log('[SidebarManager] showCreateFolderInput called');
|
|
this.isCreatingFolder = true;
|
|
|
|
// 立即保存拖拽状态,防止后续事件(如blur)清空状态
|
|
this._pendingDragState = {
|
|
filePaths: this.draggedFilePaths ? [...this.draggedFilePaths] : null,
|
|
rootPath: this.draggedRootPath,
|
|
fromBulk: this.draggedFromBulk
|
|
};
|
|
console.log('[SidebarManager] saved pending drag state:', this._pendingDragState);
|
|
|
|
this.hideCreateFolderZone();
|
|
|
|
const treeContainer = document.querySelector('.sidebar-tree-container');
|
|
if (!treeContainer) return;
|
|
|
|
// Remove existing input if any
|
|
this.hideCreateFolderInput();
|
|
|
|
const inputContainer = document.createElement('div');
|
|
inputContainer.id = 'sidebarCreateFolderInput';
|
|
inputContainer.className = 'sidebar-create-folder-input-container';
|
|
inputContainer.innerHTML = `
|
|
<div class="sidebar-create-folder-input-wrapper">
|
|
<i class="fas fa-folder-plus"></i>
|
|
<input type="text"
|
|
class="sidebar-create-folder-input"
|
|
placeholder="${translate('sidebar.dragDrop.newFolderName', {}, 'New folder name')}"
|
|
autofocus />
|
|
<button class="sidebar-create-folder-btn sidebar-create-folder-confirm" title="${translate('common.confirm', {}, 'Confirm')}">
|
|
<i class="fas fa-check"></i>
|
|
</button>
|
|
<button class="sidebar-create-folder-btn sidebar-create-folder-cancel" title="${translate('common.cancel', {}, 'Cancel')}">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
<div class="sidebar-create-folder-hint">
|
|
${translate('sidebar.dragDrop.folderNameHint', {}, 'Press Enter to confirm, Escape to cancel')}
|
|
</div>
|
|
`;
|
|
|
|
treeContainer.appendChild(inputContainer);
|
|
|
|
// Focus input
|
|
const input = inputContainer.querySelector('.sidebar-create-folder-input');
|
|
if (input) {
|
|
input.focus();
|
|
}
|
|
|
|
// Bind events
|
|
const confirmBtn = inputContainer.querySelector('.sidebar-create-folder-confirm');
|
|
const cancelBtn = inputContainer.querySelector('.sidebar-create-folder-cancel');
|
|
|
|
// Flag to prevent blur from canceling when clicking buttons
|
|
let isButtonClick = false;
|
|
|
|
confirmBtn?.addEventListener('mousedown', () => {
|
|
isButtonClick = true;
|
|
console.log('[SidebarManager] confirmBtn mousedown - isButtonClick set to true');
|
|
});
|
|
cancelBtn?.addEventListener('mousedown', () => {
|
|
isButtonClick = true;
|
|
console.log('[SidebarManager] cancelBtn mousedown - isButtonClick set to true');
|
|
});
|
|
|
|
confirmBtn?.addEventListener('click', (e) => {
|
|
console.log('[SidebarManager] confirmBtn click event triggered');
|
|
this.handleCreateFolderSubmit();
|
|
});
|
|
cancelBtn?.addEventListener('click', () => {
|
|
console.log('[SidebarManager] cancelBtn click event triggered');
|
|
this.handleCreateFolderCancel();
|
|
});
|
|
input?.addEventListener('keydown', (e) => {
|
|
console.log('[SidebarManager] input keydown:', e.key);
|
|
if (e.key === 'Enter') {
|
|
console.log('[SidebarManager] Enter pressed, calling handleCreateFolderSubmit');
|
|
this.handleCreateFolderSubmit();
|
|
} else if (e.key === 'Escape') {
|
|
console.log('[SidebarManager] Escape pressed, calling handleCreateFolderCancel');
|
|
this.handleCreateFolderCancel();
|
|
}
|
|
});
|
|
input?.addEventListener('blur', () => {
|
|
console.log('[SidebarManager] input blur event - isButtonClick:', isButtonClick);
|
|
// Delay to allow button clicks to process first
|
|
setTimeout(() => {
|
|
console.log('[SidebarManager] blur timeout - isButtonClick:', isButtonClick, 'activeElement:', document.activeElement?.className);
|
|
if (!isButtonClick && document.activeElement !== confirmBtn && document.activeElement !== cancelBtn) {
|
|
console.log('[SidebarManager] blur timeout - calling handleCreateFolderCancel');
|
|
this.handleCreateFolderCancel();
|
|
} else {
|
|
console.log('[SidebarManager] blur timeout - NOT canceling (button click detected)');
|
|
}
|
|
isButtonClick = false;
|
|
}, 200);
|
|
});
|
|
}
|
|
|
|
hideCreateFolderInput() {
|
|
console.log('[SidebarManager] hideCreateFolderInput called');
|
|
const inputContainer = document.getElementById('sidebarCreateFolderInput');
|
|
console.log('[SidebarManager] inputContainer:', inputContainer);
|
|
if (inputContainer) {
|
|
inputContainer.remove();
|
|
console.log('[SidebarManager] inputContainer removed');
|
|
}
|
|
this.isCreatingFolder = false;
|
|
console.log('[SidebarManager] isCreatingFolder set to false');
|
|
}
|
|
|
|
async handleCreateFolderSubmit() {
|
|
console.log('[SidebarManager] handleCreateFolderSubmit called');
|
|
const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input');
|
|
console.log('[SidebarManager] input element:', input);
|
|
if (!input) {
|
|
console.log('[SidebarManager] input not found, returning');
|
|
return;
|
|
}
|
|
|
|
const folderName = input.value.trim();
|
|
console.log('[SidebarManager] folderName:', folderName);
|
|
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 selected path as parent, or root if none selected
|
|
const parentPath = this.selectedPath || '';
|
|
const targetRelativePath = parentPath ? `${parentPath}/${folderName}` : folderName;
|
|
console.log('[SidebarManager] targetRelativePath:', targetRelativePath);
|
|
|
|
// 使用 showCreateFolderInput 时保存的拖拽状态
|
|
const pendingState = this._pendingDragState;
|
|
console.log('[SidebarManager] using pending drag state:', pendingState);
|
|
|
|
if (!pendingState || !pendingState.filePaths || pendingState.filePaths.length === 0) {
|
|
console.log('[SidebarManager] no pending drag state found, cannot proceed');
|
|
showToast('sidebar.dragDrop.noDragState', {}, 'error');
|
|
this.hideCreateFolderInput();
|
|
return;
|
|
}
|
|
|
|
this.hideCreateFolderInput();
|
|
|
|
// Perform the move with saved state
|
|
console.log('[SidebarManager] calling performDragMove with pending state');
|
|
const success = await this.performDragMoveWithState(targetRelativePath, pendingState.filePaths, pendingState.rootPath, pendingState.fromBulk);
|
|
console.log('[SidebarManager] performDragMove result:', success);
|
|
|
|
if (success) {
|
|
// Expand the parent folder to show the new folder
|
|
if (parentPath) {
|
|
this.expandedNodes.add(parentPath);
|
|
this.saveExpandedState();
|
|
}
|
|
// Refresh the tree to show the newly created folder
|
|
// restoreSelectedFolder() inside refresh() will maintain the current active folder
|
|
await this.refresh();
|
|
}
|
|
|
|
// 清理待处理的拖拽状态
|
|
this._pendingDragState = null;
|
|
this.resetDragState();
|
|
this.clearAllDropHighlights();
|
|
}
|
|
|
|
handleCreateFolderCancel() {
|
|
this.hideCreateFolderInput();
|
|
// 清理待处理的拖拽状态
|
|
this._pendingDragState = null;
|
|
this.resetDragState();
|
|
this.clearAllDropHighlights();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// Collapse all button
|
|
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
|
if (collapseAllBtn) {
|
|
collapseAllBtn.addEventListener('click', this.handleCollapseAll);
|
|
}
|
|
|
|
// Recursive toggle button
|
|
const recursiveToggleBtn = document.getElementById('sidebarRecursiveToggle');
|
|
if (recursiveToggleBtn) {
|
|
recursiveToggleBtn.addEventListener('click', this.handleRecursiveToggle);
|
|
}
|
|
|
|
// 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);
|
|
|
|
// Display mode toggle button
|
|
const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle');
|
|
if (displayModeToggleBtn) {
|
|
displayModeToggleBtn.addEventListener('click', this.handleDisplayModeToggle);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
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 {
|
|
if (this.displayMode === 'tree') {
|
|
const response = await this.apiClient.fetchUnifiedFolderTree();
|
|
this.treeData = response.tree || {};
|
|
} else {
|
|
const response = await this.apiClient.fetchModelFolders();
|
|
this.foldersList = response.folders || [];
|
|
}
|
|
this.folderTreeLoaded = true;
|
|
this.renderFolderDisplay();
|
|
} catch (error) {
|
|
this.folderTreeLoaded = false;
|
|
console.error('Failed to load folder data:', error);
|
|
this.renderEmptyState();
|
|
}
|
|
}
|
|
|
|
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 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' : ''}" 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 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;
|
|
|
|
folderTree.innerHTML = `
|
|
<div class="sidebar-tree-placeholder">
|
|
<i class="fas fa-folder-open"></i>
|
|
<div>${translate('sidebar.empty.noFolders', {}, 'No folders found')}</div>
|
|
<div class="sidebar-empty-hint">
|
|
<i class="fas fa-hand-pointer"></i>
|
|
${translate('sidebar.empty.dragHint', {}, 'Drag items here to create folders')}
|
|
</div>
|
|
</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 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" data-path="${escapedPath}">
|
|
<i class="fas fa-folder sidebar-folder-icon"></i>
|
|
<div class="sidebar-folder-name" title="${escapedTitle}">${escapedDisplayName}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
folderTree.innerHTML = foldersHtml;
|
|
}
|
|
|
|
handleTreeClick(event) {
|
|
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) {
|
|
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();
|
|
|
|
const menu = document.getElementById('sidebarFolderContextMenu');
|
|
if (!menu) return;
|
|
|
|
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 '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.updateDisplayModeButton();
|
|
this.updateCollapseAllButton();
|
|
this.updateRecursiveToggleButton();
|
|
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.updateRecursiveToggleButton();
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
updateDisplayModeButton() {
|
|
const displayModeBtn = document.getElementById('sidebarDisplayModeToggle');
|
|
if (displayModeBtn) {
|
|
const icon = displayModeBtn.querySelector('i');
|
|
if (this.displayMode === 'tree') {
|
|
icon.className = 'fas fa-sitemap';
|
|
displayModeBtn.title = translate('sidebar.switchToListView');
|
|
} else {
|
|
icon.className = 'fas fa-list';
|
|
displayModeBtn.title = translate('sidebar.switchToTreeView');
|
|
}
|
|
}
|
|
}
|
|
|
|
updateCollapseAllButton() {
|
|
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
|
if (collapseAllBtn) {
|
|
if (this.displayMode === 'list') {
|
|
collapseAllBtn.disabled = true;
|
|
collapseAllBtn.classList.add('disabled');
|
|
collapseAllBtn.title = translate('sidebar.collapseAllDisabled');
|
|
} else {
|
|
collapseAllBtn.disabled = false;
|
|
collapseAllBtn.classList.remove('disabled');
|
|
collapseAllBtn.title = translate('sidebar.collapseAll');
|
|
}
|
|
}
|
|
}
|
|
|
|
updateRecursiveToggleButton() {
|
|
const recursiveToggleBtn = document.getElementById('sidebarRecursiveToggle');
|
|
if (!recursiveToggleBtn) return;
|
|
|
|
const icon = recursiveToggleBtn.querySelector('i');
|
|
const isTreeMode = this.displayMode === 'tree';
|
|
const isActive = isTreeMode && this.recursiveSearchEnabled;
|
|
|
|
recursiveToggleBtn.classList.toggle('active', isActive);
|
|
recursiveToggleBtn.classList.toggle('disabled', !isTreeMode);
|
|
recursiveToggleBtn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
|
recursiveToggleBtn.setAttribute('aria-disabled', isTreeMode ? 'false' : 'true');
|
|
|
|
if (icon) {
|
|
icon.className = 'fas fa-code-branch';
|
|
}
|
|
|
|
if (!isTreeMode) {
|
|
recursiveToggleBtn.title = translate('sidebar.recursiveUnavailable');
|
|
} else if (this.recursiveSearchEnabled) {
|
|
recursiveToggleBtn.title = translate('sidebar.recursiveOn');
|
|
} else {
|
|
recursiveToggleBtn.title = translate('sidebar.recursiveOff');
|
|
}
|
|
}
|
|
|
|
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.updateDisplayModeButton();
|
|
this.updateCollapseAllButton();
|
|
this.updateSearchRecursiveOption();
|
|
this.updateRecursiveToggleButton();
|
|
}
|
|
|
|
/**
|
|
* 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();
|