/**
* SidebarManager - Manages hierarchical folder navigation sidebar
*/
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { translate } from '../utils/i18nHelpers.js';
import { state, getCurrentPageState } from '../state/index.js';
import { bulkManager } from '../managers/BulkManager.js';
import { modalManager } from '../managers/ModalManager.js';
import { showToast, showActionToast } from '../utils/uiHelpers.js';
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
import { escapeHtml, escapeAttribute } from './shared/utils.js';
import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js';
// Pages whose folder sidebar starts hidden. "other" downloads default to a flat
// layout (no subfolders are created), so on a fresh library the tree is empty
// there and the sidebar would only consume horizontal space. The preference is
// still persisted per page once the user toggles it, and the edge indicator
// makes the hidden sidebar discoverable/recoverable.
const SIDEBAR_DEFAULT_HIDDEN_PAGES = new Set(['other']);
export class SidebarManager {
constructor() {
this.pageControls = null;
this.pageType = null;
this.treeData = {};
this.folderTreeLoaded = false;
this.selectedPath = '';
this.expandedNodes = new Set();
this.apiClient = null;
this.openDropdown = null;
this.isInitialized = false;
this.displayMode = 'tree'; // 'tree' or 'list'
this.foldersList = [];
this.recursiveSearchEnabled = true;
this.draggedFilePaths = null;
this.draggedRootPath = null;
this.draggedFromBulk = false;
this.dragHandlersInitialized = false;
this.folderTreeElement = null;
this.currentDropTarget = null;
this.lastPageControls = null;
this.isDisabledByPage = false;
this.initializationPromise = null;
this.isCreatingFolder = false;
this.showEmptyFolders = 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;
// 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
}
}
card.classList.add('dragging');
// Add dragging state to sidebar for visual feedback
const sidebar = document.getElementById('folderSidebar');
if (sidebar) {
sidebar.classList.add('dragging-active');
}
}
handleCardDragEnd(event) {
const card = event.target.closest('.model-card');
if (card) {
card.classList.remove('dragging');
}
// Remove dragging state from sidebar
const sidebar = document.getElementById('folderSidebar');
if (sidebar) {
sidebar.classList.remove('dragging-active');
}
this.clearAllDropHighlights();
this.resetDragState();
}
getRootPathFromCard(card) {
if (!card) return null;
const filePathRaw = card.dataset.filepath || '';
const normalizedFilePath = filePathRaw.replace(/\\/g, '/');
const lastSlashIndex = normalizedFilePath.lastIndexOf('/');
if (lastSlashIndex === -1) {
return null;
}
const directory = normalizedFilePath.substring(0, lastSlashIndex);
let folderValue = card.dataset.folder;
if (!folderValue || folderValue === 'undefined') {
folderValue = '';
}
const normalizedFolder = folderValue.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (!normalizedFolder) {
return directory;
}
const suffix = `/${normalizedFolder}`;
if (directory.endsWith(suffix)) {
return directory.slice(0, -suffix.length);
}
return directory;
}
combineRootAndRelativePath(root, relative) {
const normalizedRoot = (root || '').replace(/\\/g, '/').replace(/\/+$/g, '');
const normalizedRelative = (relative || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (!normalizedRoot) {
return normalizedRelative;
}
if (!normalizedRelative) {
return normalizedRoot;
}
return `${normalizedRoot}/${normalizedRelative}`;
}
getFolderElementFromEvent(event) {
const folderTree = this.folderTreeElement || document.getElementById('sidebarFolderTree');
if (!folderTree) return null;
const target = event.target instanceof Element ? event.target.closest('[data-path]') : null;
if (!target || !folderTree.contains(target)) {
return null;
}
return target;
}
setDropTargetHighlight(element, shouldAdd) {
if (!element) return;
let targetElement = element;
if (!targetElement.classList.contains('sidebar-tree-node-content') &&
!targetElement.classList.contains('sidebar-node-content')) {
targetElement = element.querySelector('.sidebar-tree-node-content, .sidebar-node-content');
}
if (targetElement) {
targetElement.classList.toggle('drop-target', shouldAdd);
}
}
handleFolderDragEnter(event) {
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
const folderElement = this.getFolderElementFromEvent(event);
if (!folderElement) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
this.setDropTargetHighlight(folderElement, true);
this.currentDropTarget = folderElement;
}
handleFolderDragOver(event) {
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
const folderElement = this.getFolderElementFromEvent(event);
if (!folderElement) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
}
handleFolderDragLeave(event) {
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
const folderElement = this.getFolderElementFromEvent(event);
if (!folderElement) return;
const relatedTarget = event.relatedTarget instanceof Element ? event.relatedTarget : null;
if (!relatedTarget || !folderElement.contains(relatedTarget)) {
this.setDropTargetHighlight(folderElement, false);
if (this.currentDropTarget === folderElement) {
this.currentDropTarget = null;
}
}
}
async handleFolderDrop(event) {
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
const folderElement = this.getFolderElementFromEvent(event);
if (!folderElement) return;
event.preventDefault();
event.stopPropagation();
this.setDropTargetHighlight(folderElement, false);
this.currentDropTarget = null;
const targetPath = folderElement.dataset.path || '';
await this.performDragMove(targetPath);
this.resetDragState();
this.clearAllDropHighlights();
}
async performDragMove(targetRelativePath) {
console.log('[SidebarManager] performDragMove called with targetRelativePath:', targetRelativePath);
console.log('[SidebarManager] draggedFilePaths:', this.draggedFilePaths);
console.log('[SidebarManager] draggedRootPath:', this.draggedRootPath);
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) {
console.log('[SidebarManager] performDragMove returning false - no draggedFilePaths');
return false;
}
if (!this.apiClient) {
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|| this.pageControls?.sidebarApiClient
|| getModelApiClient();
}
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
console.log('[SidebarManager] performDragMove returning false - supportsMove is false');
showToast('toast.models.moveFailed', { message: translate('sidebar.dragDrop.moveUnsupported', {}, 'Move not supported for this page') }, 'error');
return false;
}
const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : '';
console.log('[SidebarManager] rootPath:', rootPath);
if (!rootPath) {
console.log('[SidebarManager] performDragMove returning false - no rootPath');
showToast(
'toast.models.moveFailed',
{ message: translate('sidebar.dragDrop.unableToResolveRoot', {}, 'Unable to determine destination path for move.') },
'error'
);
return false;
}
const destination = this.combineRootAndRelativePath(rootPath, targetRelativePath);
const useBulkMove = this.draggedFromBulk || this.draggedFilePaths.length > 1;
try {
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
let movedFiles = []; // Array of { original_file_path, new_file_path }
if (useBulkMove) {
const results = await this.apiClient.moveBulkModels(this.draggedFilePaths, destination);
movedFiles = (results || [])
.filter(r => r.success)
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
} else {
const result = await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination);
if (result) {
movedFiles.push({
original_file_path: result.original_file_path || this.draggedFilePaths[0],
new_file_path: result.new_file_path
});
}
}
console.log('[SidebarManager] apiClient.move successful');
// Update VirtualScroller in-place instead of full reload
if (movedFiles.length > 0 && state.virtualScroller) {
const pageState = getCurrentPageState();
const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
const isRecursive = pageState.searchOptions?.recursive ?? true;
const isFolderFiltered = pageState.activeFolder !== null;
const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, '');
// Determine if items in the target folder are visible in the current view
let itemsRemainVisible = true;
if (isFolderFiltered) {
if (isRecursive) {
itemsRemainVisible = normalizedActive === '' ||
normalizedTarget === normalizedActive ||
normalizedTarget.startsWith(normalizedActive + '/');
} else {
itemsRemainVisible = normalizedTarget === normalizedActive;
}
}
if (itemsRemainVisible) {
// Items stay visible — update each item's file_path to reflect new location
for (const moved of movedFiles) {
if (moved.original_file_path && moved.new_file_path) {
state.virtualScroller.updateSingleItem(moved.original_file_path, {
file_path: moved.new_file_path,
folder: normalizedTarget
});
}
}
} else {
// Items no longer visible in current folder — remove from VirtualScroller
const pathsToRemove = movedFiles
.map(m => m.original_file_path)
.filter(Boolean);
if (pathsToRemove.length > 0) {
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
}
}
}
// Refresh sidebar folder tree only (no model data reload)
await this.refresh();
if (this.draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
bulkManager.toggleBulkMode();
}
console.log('[SidebarManager] performDragMove returning true');
return true;
} catch (error) {
console.error('[SidebarManager] Error moving model(s) via drag-and-drop:', error);
showToast('toast.models.moveFailed', { message: error.message || 'Unknown error' }, 'error');
console.log('[SidebarManager] performDragMove returning false due to error');
return false;
}
}
resetDragState() {
this.draggedFilePaths = null;
this.draggedRootPath = null;
this.draggedFromBulk = false;
}
showCreateFolderInput(basePath = null) {
// Remove any existing input first — hideCreateFolderInput() also
// clears isCreatingFolder, so it must run before the flag is set.
this.hideCreateFolderInput();
this.isCreatingFolder = true;
// The folder is created under the given base path; falls back to the
// currently selected folder or the root.
this._createFolderBasePath = basePath !== null ? basePath : (this.selectedPath || '');
const folderTree = document.getElementById('sidebarFolderTree');
if (!folderTree) {
this.isCreatingFolder = false;
return;
}
// Inline row at the creation location, file-explorer style: the new
// folder will appear exactly where the input row is shown.
const row = this._buildCreateFolderRow();
this._insertCreateFolderRow(folderTree, row);
row.scrollIntoView?.({ block: 'nearest' });
const input = row.querySelector('.sidebar-create-folder-input');
if (!input) return;
input.focus();
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
this.handleCreateFolderSubmit();
} else if (e.key === 'Escape') {
this.handleCreateFolderCancel();
}
});
// Clicking away cancels creation, mirroring file-explorer behavior
input.addEventListener('blur', () => {
setTimeout(() => {
if (this.isCreatingFolder) {
this.handleCreateFolderCancel();
}
}, 100);
});
}
_buildCreateFolderRow() {
const isListMode = this.displayMode === 'list';
const row = document.createElement('div');
row.id = 'sidebarCreateFolderInput';
row.className = 'sidebar-create-folder-node';
row.innerHTML = `
${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