/**
* 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 { positionFixedMenu } from '../utils/menuPosition.js';
import { closeContextMenus, registerContextMenuCloser } from '../utils/contextMenuCoordinator.js';
import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js';
// Sidebar menu elements. They are also the ids used to keep these menus
// mutually exclusive with the page-side context menus.
const SIDEBAR_FOLDER_MENU_ID = 'sidebarFolderContextMenu';
const SIDEBAR_VIEW_OPTIONS_MENU_ID = 'sidebarViewOptionsMenu';
// 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']);
// Hover a collapsed folder this long while dragging to auto-expand it.
const DRAG_HOVER_EXPAND_DELAY_MS = 600;
// Distance from the tree container's top/bottom edge that starts
// auto-scrolling during a drag, and the scroll speed per animation frame.
const SIDEBAR_AUTOSCROLL_ZONE_PX = 48;
const SIDEBAR_AUTOSCROLL_SPEED_PX = 12;
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 = true;
this.nonEmptyFolders = null; // models-only folder set used to dim empty nodes
this.emptyFolderCount = null; // null = not known yet (no models-only list)
this._createFolderBasePath = null;
this._createFolderTempChildren = null; // children container added for a leaf parent during inline creation
this._renameFolderPath = null;
this._renameFolderNode = null;
// Absolute path the pending delete will hit, and the node it came from:
// the tree path is relative to *some* root, so only the resolved
// absolute path may be handed to the delete API.
this._pendingDeleteFolderPath = null;
this._pendingDeleteFolderRelative = null;
this._deleteFolderCandidates = [];
this._deleteFolderRootRows = new Map();
this._deleteFolderKeptNote = null;
this._deleteFolderModalWired = false;
// Bumped on every modal open/close so a late dry-run answer can never
// repaint a modal the user has already dismissed or retargeted.
this._deleteFolderProbeToken = 0;
// Drag-and-drop transient state: the custom drag image element, the
// pending hover-to-expand timer, and the edge auto-scroll loop.
this._dragPreviewElement = null;
this._dragExpandTimer = null;
this._dragExpandPath = null;
this._autoScrollFrame = null;
this._autoScrollDirection = 0;
// 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 = true;
this.nonEmptyFolders = null;
this.emptyFolderCount = 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
}
// Replace the browser's default full-card snapshot with a small
// chip (thumbnail + name + count) so the drag image does not
// cover the folder tree the user is aiming at.
this._setDragPreviewImage(event, card, filePaths.length);
}
card.classList.add('dragging');
// Add dragging state to sidebar for visual feedback
const sidebar = document.getElementById('folderSidebar');
if (sidebar) {
sidebar.classList.add('dragging-active');
}
this._startSidebarAutoScroll();
}
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._removeDragPreviewImage();
this._cancelDragExpand();
this._stopSidebarAutoScroll();
this.clearAllDropHighlights();
this.resetDragState();
}
/**
* Swap the default drag image (a full-size snapshot of the card) for a
* compact chip: preview thumbnail, model name, and a count badge for
* bulk drags. The chip is anchored below-right of the cursor so it never
* covers the folder row the pointer is hovering.
*/
_setDragPreviewImage(event, card, count) {
const dataTransfer = event.dataTransfer;
if (!dataTransfer || typeof dataTransfer.setDragImage !== 'function') return;
this._removeDragPreviewImage();
const chip = document.createElement('div');
chip.className = 'model-card-drag-chip';
// A blurred (mature-content) preview stays hidden in the chip too.
const isBlurred = Boolean(card.querySelector('.card-preview.blurred'));
const previewImg = card.querySelector('.card-preview img');
if (previewImg && previewImg.src && !isBlurred) {
const thumb = document.createElement('img');
thumb.className = 'model-card-drag-chip-thumb';
thumb.src = previewImg.src;
thumb.alt = '';
chip.appendChild(thumb);
} else {
const icon = document.createElement('i');
const isVideo = Boolean(card.querySelector('.card-preview video'));
icon.className = `model-card-drag-chip-icon fas ${isVideo ? 'fa-film' : 'fa-image'}`;
chip.appendChild(icon);
}
const name = document.createElement('span');
name.className = 'model-card-drag-chip-name';
name.textContent = card.dataset.name || card.dataset.file_name || '';
chip.appendChild(name);
if (count > 1) {
const badge = document.createElement('span');
badge.className = 'model-card-drag-chip-count';
badge.textContent = `×${count}`;
chip.appendChild(badge);
}
document.body.appendChild(chip);
this._dragPreviewElement = chip;
try {
// Negative offsets place the chip below-right of the cursor.
dataTransfer.setDragImage(chip, -12, -12);
} catch (error) {
// A failed setDragImage keeps the default image; still fine.
}
}
_removeDragPreviewImage() {
if (this._dragPreviewElement) {
this._dragPreviewElement.remove();
this._dragPreviewElement = null;
}
}
/**
* Expand a collapsed tree folder after a short hover during a drag, so
* nested folders can be dropped into without a separate click.
*/
_scheduleDragExpand(folderElement) {
if (this.displayMode !== 'tree') return;
const treeNode = folderElement.classList.contains('sidebar-tree-node')
? folderElement
: folderElement.closest('.sidebar-tree-node');
const path = treeNode?.dataset.path;
if (!treeNode || !path) return;
if (this.expandedNodes.has(path)) return;
if (!treeNode.querySelector(':scope > .sidebar-tree-children')) return;
if (this._dragExpandTimer && this._dragExpandPath === path) return;
this._cancelDragExpand();
this._dragExpandPath = path;
this._dragExpandTimer = setTimeout(() => {
this._dragExpandTimer = null;
this._dragExpandPath = null;
// Re-validate against the live DOM: the drag may have ended or the
// tree may have re-rendered while the timer was pending.
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
const folderTree = this.folderTreeElement || document.getElementById('sidebarFolderTree');
const node = folderTree
? [...folderTree.querySelectorAll('.sidebar-tree-node')].find(el => el.dataset.path === path)
: null;
if (!node || this.expandedNodes.has(path)) return;
this.expandedNodes.add(path);
// Toggle in place instead of re-rendering so the drop highlight
// and the hover position survive the expansion.
node.querySelector(':scope > .sidebar-tree-node-content > .sidebar-tree-expand-icon')
?.classList.add('expanded');
node.querySelector(':scope > .sidebar-tree-children')?.classList.add('expanded');
this.saveExpandedState();
}, DRAG_HOVER_EXPAND_DELAY_MS);
}
_cancelDragExpand() {
if (this._dragExpandTimer) {
clearTimeout(this._dragExpandTimer);
this._dragExpandTimer = null;
}
this._dragExpandPath = null;
}
_startSidebarAutoScroll() {
this._stopSidebarAutoScroll();
this._autoScrollDirection = 0;
if (typeof requestAnimationFrame !== 'function') return;
const step = () => {
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) {
this._autoScrollFrame = null;
return;
}
if (this._autoScrollDirection !== 0) {
const container = this.folderTreeElement?.closest('.sidebar-tree-container');
if (container) {
container.scrollTop += this._autoScrollDirection * SIDEBAR_AUTOSCROLL_SPEED_PX;
}
}
this._autoScrollFrame = requestAnimationFrame(step);
};
this._autoScrollFrame = requestAnimationFrame(step);
}
_stopSidebarAutoScroll() {
if (this._autoScrollFrame !== null && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(this._autoScrollFrame);
}
this._autoScrollFrame = null;
this._autoScrollDirection = 0;
}
_updateAutoScrollDirection(clientY) {
const container = this.folderTreeElement?.closest('.sidebar-tree-container');
if (!container || typeof clientY !== 'number') {
this._autoScrollDirection = 0;
return;
}
const rect = container.getBoundingClientRect();
if (clientY < rect.top + SIDEBAR_AUTOSCROLL_ZONE_PX) {
this._autoScrollDirection = -1;
} else if (clientY > rect.bottom - SIDEBAR_AUTOSCROLL_ZONE_PX) {
this._autoScrollDirection = 1;
} else {
this._autoScrollDirection = 0;
}
}
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}`;
// Case-insensitive: a cached folder may differ from the file path in
// case alone, which is the same directory on Windows/macOS.
if (directory.toLowerCase().endsWith(suffix.toLowerCase())) {
return directory.slice(0, -suffix.length);
}
// Folder and file path disagree (stale cache entry): returning the
// file's own directory here would make the move nest the target under
// the source folder (issue #1148). Let the caller resolve the root
// from the library roots instead.
return null;
}
/**
* Resolve the library root a dragged file lives under.
*
* Prefers the authoritative root list (longest root prefixing the file
* path) over the card heuristic, which silently derives a wrong root when
* the cached folder disagrees with the file path (issue #1148).
*
* `fallbackRoot` is passed in instead of read from `this.draggedRootPath`:
* the drag state is cleared by `dragend` while this request is in flight.
*/
async _resolveMoveRoot(filePath, fallbackRoot = '') {
try {
if (typeof this.apiClient?.fetchModelRoots === 'function') {
const rootsData = await this.apiClient.fetchModelRoots();
const roots = rootsData?.roots || [];
const normalizedFile = (filePath || '').replace(/\\/g, '/').toLowerCase();
let best = '';
for (const root of roots) {
const normalizedRoot = (root || '').replace(/\\/g, '/').replace(/\/+$/, '');
if (!normalizedRoot) continue;
const key = normalizedRoot.toLowerCase();
if ((normalizedFile === key || normalizedFile.startsWith(key + '/')) &&
normalizedRoot.length > best.length) {
best = normalizedRoot;
}
}
if (best) return best;
}
} catch (error) {
console.warn('[SidebarManager] Failed to resolve move root from library roots:', error);
}
return fallbackRoot ? fallbackRoot.replace(/\\/g, '/') : '';
}
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;
this._scheduleDragExpand(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';
}
this._updateAutoScrollDirection(event.clientY);
}
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;
}
if (this._dragExpandPath && folderElement.dataset.path === this._dragExpandPath) {
this._cancelDragExpand();
}
}
// Pointer left the tree entirely: stop any edge auto-scroll.
const folderTree = this.folderTreeElement || document.getElementById('sidebarFolderTree');
if (folderTree && (!relatedTarget || !folderTree.contains(relatedTarget))) {
this._autoScrollDirection = 0;
}
}
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;
this._cancelDragExpand();
this._autoScrollDirection = 0;
const targetPath = folderElement.dataset.path || '';
await this.performDragMove(targetPath);
this.resetDragState();
this.clearAllDropHighlights();
}
async performDragMove(targetRelativePath) {
// Snapshot the drag state before the first await. The browser dispatches
// `dragend` right after `drop`, and handleCardDragEnd() calls
// resetDragState() while this move is still awaiting the roots request.
// Reading the instance fields after that await would either throw
// (null.length) or abort the move (issue: drag-drop move did nothing).
const filePaths = Array.isArray(this.draggedFilePaths) ? [...this.draggedFilePaths] : [];
const fromBulk = Boolean(this.draggedFromBulk);
const fallbackRoot = this.draggedRootPath
? this.draggedRootPath.replace(/\\/g, '/')
: '';
console.log('[SidebarManager] performDragMove called with targetRelativePath:', targetRelativePath);
console.log('[SidebarManager] draggedFilePaths:', filePaths);
console.log('[SidebarManager] draggedRootPath:', fallbackRoot);
if (filePaths.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(this._moveFailedKey(), { message: translate('sidebar.dragDrop.moveUnsupported', {}, 'Move not supported for this page') }, 'error');
return false;
}
const rootPath = await this._resolveMoveRoot(filePaths[0], fallbackRoot);
console.log('[SidebarManager] rootPath:', rootPath);
if (!rootPath) {
console.log('[SidebarManager] performDragMove returning false - no rootPath');
showToast(
this._moveFailedKey(),
{ message: translate('sidebar.dragDrop.unableToResolveRoot', {}, 'Unable to determine destination path for move.') },
'error'
);
return false;
}
const destination = this.combineRootAndRelativePath(rootPath, targetRelativePath);
const useBulkMove = fromBulk || filePaths.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(filePaths, 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(filePaths[0], destination);
if (result) {
movedFiles.push({
original_file_path: result.original_file_path || filePaths[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 (fromBulk && 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(this._moveFailedKey(), { 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 = `
${isListMode ? '' : `
`}
`;
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 root = await this._createFolderRoot(parentPath);
if (!root) {
showToast('sidebar.createFolderResult.noRoot', {}, 'error');
return false;
}
const absolutePath = this.combineRootAndRelativePath(root, targetRelativePath);
const result = await this.apiClient.createFolder(absolutePath);
// A newly created folder is empty by definition. If the user turned
// empty-folder display off, switch it back on so the folder they
// just asked for is actually visible in the tree.
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 defaultRoot = this._configuredDefaultRoot();
if (defaultRoot && roots.includes(defaultRoot)) {
return defaultRoot;
}
return roots[0];
}
/** The library's configured default root (may be empty or not a known root). */
_configuredDefaultRoot() {
const singularName = this.apiClient?.apiConfig?.config?.singularName;
if (!singularName) return '';
return state.global?.settings?.[`default_${singularName}_root`] || '';
}
/**
* Map a tree-relative folder onto the concrete directories it stands for.
*
* The unified tree merges every model root into one relative-path
* namespace, so a node does not carry the root it came from: the same
* relative folder can exist under several roots, or only under one that is
* not the default root. Folder operations take an absolute business path,
* so they ask the backend which directories the node maps to instead of
* prefixing a root (which is how a folder sitting right there used to fail
* with "Folder no longer exists", and how a same-named folder in another
* root could be the one that got deleted).
*
* @returns {Promise<{candidates: Array