/** * SageUtils Sidebar Tab with Multiple Sub-tabs * Multi-tabbed interface for model browser and file manager * * Performance Optimization: Uses hide/show approach instead of recreating tab content * each time, which maintains state and reduces loading times for subsequent tab switches. */ // Import centralized state management import { getState, subscribe, actions, selectors } from "../shared/stateManager.js"; import { handleError } from "../shared/errorHandler.js"; // Import decomposed tab modules // import { createModelsTab } from "./modelsTab.js"; import { createModelsTabV2 } from "./modelsTabV2.js"; import { createFilesTab } from "./filesTab.js"; import { createCivitaiSearchTab } from "./civitaiSearchTab.js"; import { createImageGalleryTab } from "./imageGalleryTab.js"; import { createPromptBuilderTab } from "./promptBuilderTab.js"; import { createLLMTab } from "./llmTab.js"; // Import cache API functions import { fetchCacheHash, fetchCacheInfo } from "../shared/api/cacheApi.js"; // Import global data cache import { DataCache, CacheKeys } from "../shared/dataCache.js"; // Import gallery API for background preloading import { loadImagesFromFolder } from "../shared/api/galleryApi.js"; // Import shared UI components import { createMainContainer } from "../components/cacheUI.js"; // Import TabManager import { TabManager } from "../components/tabs.js"; // Import API for settings import { api } from '../../../../scripts/api.js'; // Import performance timing utilities for telemetry persistence import { startTimer, endTimer, javascriptTimer } from "../shared/performanceTimer.js"; // Always use Models Tab V2 // Track the current sidebar element for reloading let currentSidebarElement = null; /** * Load tab visibility settings from the backend * @returns {Promise} Tab visibility settings */ async function loadTabVisibilitySettings() { try { startTimer('Sidebar.Settings.Total'); startTimer('Sidebar.Settings.Fetch'); const t0 = performance.now(); const response = await api.fetchApi('/sage_utils/settings'); endTimer('Sidebar.Settings.Fetch'); const t1 = performance.now(); if (response.ok) { startTimer('Sidebar.Settings.Parse'); const jsonStart = performance.now(); const data = await response.json(); endTimer('Sidebar.Settings.Parse'); const jsonEnd = performance.now(); if (data.success && data.settings) { const settings = data.settings; startTimer('Sidebar.Settings.Map'); const mappedStart = performance.now(); const result = { show_models_tab: settings.show_models_tab?.current_value !== false, show_files_tab: settings.show_files_tab?.current_value !== false, show_search_tab: settings.show_search_tab?.current_value !== false, show_gallery_tab: settings.show_gallery_tab?.current_value !== false, show_prompts_tab: settings.show_prompts_tab?.current_value !== false, show_llm_tab: settings.show_llm_tab?.current_value !== false, _timing: { fetch_ms: +(t1 - t0).toFixed(2), parse_ms: +(jsonEnd - jsonStart).toFixed(2), map_ms: +(performance.now() - mappedStart).toFixed(2), total_ms: +(performance.now() - t0).toFixed(2) } }; endTimer('Sidebar.Settings.Map'); console.info('[Sidebar] Settings loaded', result._timing); endTimer('Sidebar.Settings.Total'); return result; } } } catch (error) { console.warn('Failed to load tab visibility settings, using defaults:', error); } // Return all tabs visible by default const fallback = { show_models_tab: true, show_files_tab: true, show_search_tab: true, show_gallery_tab: true, show_prompts_tab: true, show_llm_tab: true, _timing: { fallback: true } }; console.info('[Sidebar] Using default visibility settings'); endTimer('Sidebar.Settings.Total'); return fallback; } /** * Creates the tab manager with all tabs * @param {HTMLElement} container - Container element for tabs * @param {Object} tabVisibility - Object containing visibility settings for each tab * @param {Object} tabContentFactories - Object containing content factory functions for each tab * @returns {TabManager} Configured TabManager instance */ function createTabManager(container, tabVisibility = {}, tabContentFactories = {}) { const tabManager = new TabManager({ container: container, lazyLoad: true, onTabSwitch: (tabId) => { console.debug(`Switched to tab: ${tabId}`); }, onTabInit: (tabId) => { console.debug(`Initialized tab: ${tabId}`); } }); // Initialize the tab manager tabManager.init(); // Add all tabs with their visibility settings and content factories if (tabVisibility.show_models_tab !== false) { tabManager.addTab('models', 'Models', tabContentFactories.models, { active: true }); } if (tabVisibility.show_files_tab !== false) { tabManager.addTab('notes', 'Files', tabContentFactories.notes); } if (tabVisibility.show_search_tab !== false) { tabManager.addTab('civitai', 'Search', tabContentFactories.civitai); } if (tabVisibility.show_gallery_tab !== false) { tabManager.addTab('gallery', 'Gallery', tabContentFactories.gallery); } if (tabVisibility.show_prompts_tab !== false) { tabManager.addTab('promptBuilder', 'Prompts', tabContentFactories.promptBuilder); } if (tabVisibility.show_llm_tab !== false) { tabManager.addTab('llm', 'LLM', tabContentFactories.llm); } // Add settings button to the tab header const settingsButton = document.createElement('button'); settingsButton.innerHTML = '⚙️'; settingsButton.title = 'Settings'; settingsButton.className = 'sidebar-settings-button'; settingsButton.style.cssText = ` position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: #2a2a2a; border: 1px solid #444; border-radius: 4px; color: #ccc; padding: 6px 10px; cursor: pointer; font-size: 18px; transition: all 0.2s ease; z-index: 10; flex-shrink: 0; `; // Hover effect settingsButton.addEventListener('mouseenter', () => { settingsButton.style.background = '#4CAF50'; settingsButton.style.borderColor = '#4CAF50'; settingsButton.style.transform = 'translateY(-50%) scale(1.1)'; }); settingsButton.addEventListener('mouseleave', () => { settingsButton.style.background = '#2a2a2a'; settingsButton.style.borderColor = '#444'; settingsButton.style.transform = 'translateY(-50%) scale(1)'; }); // Click handler settingsButton.addEventListener('click', async () => { try { const { showSettingsDialog } = await import('../dialogs/settingsDialog.js'); showSettingsDialog(); } catch (error) { console.error('Failed to open settings dialog:', error); handleError(error, 'Settings Dialog Error'); } }); tabManager.tabHeader.appendChild(settingsButton); // Check if settings button overlaps with tabs and hide it if necessary const checkSettingsButtonVisibility = () => { const header = tabManager.tabHeader; const tabs = header.querySelectorAll('.tab-button'); if (tabs.length === 0) return; // Calculate total width needed for tabs let tabsWidth = 0; tabs.forEach(tab => { tabsWidth += tab.offsetWidth; }); // Add padding and gap spacing const headerPadding = 20; // 10px on each side const tabGaps = (tabs.length - 1) * 2; // 2px gap between tabs const settingsButtonWidth = 50; // Approximate width of settings button const requiredWidth = tabsWidth + tabGaps + headerPadding + settingsButtonWidth; // Hide button if there's not enough space if (header.offsetWidth < requiredWidth) { settingsButton.style.display = 'none'; } else { settingsButton.style.display = 'block'; } }; // Check visibility on load and resize setTimeout(checkSettingsButtonVisibility, 100); // Use ResizeObserver to dynamically check when header size changes const resizeObserver = new ResizeObserver(() => { checkSettingsButtonVisibility(); }); resizeObserver.observe(tabManager.tabHeader); // Store observer for cleanup if needed tabManager.settingsButtonResizeObserver = resizeObserver; return tabManager; } // Track if global error handlers have been registered to prevent duplicates let errorHandlersRegistered = false; /** * Initialize the cache sidebar data and state */ async function initializeSidebarData() { try { // Set loading state actions.setModelsLoading(true); // Set up error handling for the entire sidebar (only once) if (!errorHandlersRegistered) { const sidebarErrorHandler = (event) => { console.error('Sidebar error caught:', event.error); // Only handle error if it exists if (event.error) { handleError(event.error, { component: 'Sidebar', operation: 'Global Error Handler' }); } }; const sidebarRejectionHandler = (event) => { console.error('Sidebar promise rejection:', event.reason); // Only handle error if it exists if (event.reason) { handleError(event.reason, { component: 'Sidebar', operation: 'Promise Rejection' }); } }; window.addEventListener('error', sidebarErrorHandler); window.addEventListener('unhandledrejection', sidebarRejectionHandler); errorHandlersRegistered = true; } // Set up state subscriptions for debugging (optional) subscribe((state, prevState) => { // Guard against undefined prevState (initial state) if (!prevState) return; // Only log significant state changes if (state.activeTab !== prevState.activeTab) { console.debug('Active tab changed:', prevState.activeTab, '->', state.activeTab); } if (state.models?.selectedHash !== prevState.models?.selectedHash) { console.debug('Selected model changed:', prevState.models?.selectedHash, '->', state.models?.selectedHash); } if (state.models?.isLoading !== prevState.models?.isLoading) { console.debug('Models loading state changed:', state.models?.isLoading); } if (state.notes?.isLoading !== prevState.notes?.isLoading) { console.debug('Notes loading state changed:', state.notes?.isLoading); } }); // Check if data is already cached from preload const cacheHashReady = DataCache.isReady(CacheKeys.CACHE_HASH); const cacheInfoReady = DataCache.isReady(CacheKeys.CACHE_INFO); if (cacheHashReady && cacheInfoReady) { console.debug('[Sidebar] Using preloaded cache data'); // Get cached data const hashData = DataCache.get(CacheKeys.CACHE_HASH); const infoData = DataCache.get(CacheKeys.CACHE_INFO); // Store in state management actions.setCacheData({ hash: hashData, info: infoData }); console.debug('Cache data loaded from preload', { hashEntries: Object.keys(hashData).length, infoEntries: Object.keys(infoData).length }); } else { // Fallback: Load cache data if not preloaded console.debug('[Sidebar] Cache not preloaded, loading now...'); try { const [hashData, infoData] = await Promise.all([ fetchCacheHash(), fetchCacheInfo() ]); // Store in cache for future use DataCache.set(CacheKeys.CACHE_HASH, hashData); DataCache.set(CacheKeys.CACHE_INFO, infoData); // Store in state management actions.setCacheData({ hash: hashData, info: infoData }); console.debug('Cache data loaded', { hashEntries: Object.keys(hashData).length, infoEntries: Object.keys(infoData).length }); } catch (cacheError) { console.warn('Failed to load cache data:', cacheError); // Don't throw - let individual tabs handle their own loading } } // Initialize API connection status check try { const response = await fetch('/sage_cache/stats'); if (response.ok) { console.debug('Cache API connection verified'); } else { console.warn('Cache API connection issue:', response.status); } } catch (apiError) { console.warn('Failed to verify API connection:', apiError); } // Set up periodic cache refresh (every 5 minutes) setInterval(async () => { try { const currentState = getState(); // Only refresh if not currently loading and a model is selected if (!currentState.models.isLoading && currentState.models.selectedHash) { const [hashData, infoData] = await Promise.all([ fetchCacheHash(), fetchCacheInfo() ]); // Update cache DataCache.set(CacheKeys.CACHE_HASH, hashData); DataCache.set(CacheKeys.CACHE_INFO, infoData); // Update state actions.setCacheData({ hash: hashData, info: infoData }); console.debug('Periodic cache refresh completed'); } } catch (refreshError) { console.warn('Periodic cache refresh failed:', refreshError); } }, 5 * 60 * 1000); // 5 minutes } catch (error) { console.error('Failed to initialize sidebar data:', error); handleError(error, 'Sidebar Initialization Error'); } finally { actions.setModelsLoading(false); } } /** * Main function to create the cache sidebar with tabs * @param {HTMLElement} el - Element to populate with the sidebar */ export function createCacheSidebar(el) { // Store reference for potential reload currentSidebarElement = el; // Start with safe defaults (all tabs visible) to avoid blank UI on slow startup const defaultVisibility = { show_models_tab: true, show_files_tab: true, show_search_tab: true, show_gallery_tab: true, show_prompts_tab: true, show_llm_tab: true }; let tabVisibility = defaultVisibility; // Create main container const mainContainer = createMainContainer(); // Create tab content factories for lazy loading const tabContentFactories = { models: createModelsTabV2, notes: createFilesTab, civitai: createCivitaiSearchTab, gallery: createImageGalleryTab, promptBuilder: createPromptBuilderTab, llm: createLLMTab }; // Create tab manager const tabManager = createTabManager(mainContainer, tabVisibility, tabContentFactories); // Add to provided element el.appendChild(mainContainer); // Add a lightweight settings loading indicator in the tab header const settingsIndicator = document.createElement('div'); settingsIndicator.textContent = 'Loading settings…'; settingsIndicator.className = 'sidebar-settings-indicator'; settingsIndicator.style.cssText = ` position: absolute; right: 60px; top: 50%; transform: translateY(-50%); background: #2a2a2a; border: 1px solid #444; border-radius: 4px; color: #ddd; padding: 4px 8px; font-size: 12px; opacity: 0.9; z-index: 9; `; // The tabHeader exists post-init inside TabManager try { tabManager.tabHeader.appendChild(settingsIndicator); } catch { /* no-op */ } // Initialize sidebar data and state initializeSidebarData(); // Activate the first visible tab tabManager.activateFirstTab(); // Load tab visibility settings asynchronously and apply when ready // This prevents a blank sidebar if the settings endpoint is slow or unavailable at startup const settingsLoadStart = performance.now(); loadTabVisibilitySettings().then((settings) => { try { tabVisibility = settings || defaultVisibility; const mapStart = performance.now(); const visibilityMap = { models: tabVisibility.show_models_tab !== false, notes: tabVisibility.show_files_tab !== false, civitai: tabVisibility.show_search_tab !== false, gallery: tabVisibility.show_gallery_tab !== false, promptBuilder: tabVisibility.show_prompts_tab !== false, llm: tabVisibility.show_llm_tab !== false }; // Ensure at least one tab remains visible to avoid a blank sidebar const anyVisible = Object.values(visibilityMap).some(v => v); if (!anyVisible) { console.warn('[Sidebar] All tabs were configured hidden; forcing Models tab visible to avoid blank UI.'); visibilityMap.models = true; } const mapEnd = performance.now(); const updateStart = performance.now(); startTimer('Sidebar.Visibility.Update'); tabManager.updateVisibility(visibilityMap); endTimer('Sidebar.Visibility.Update'); const updateEnd = performance.now(); // If current active tab became hidden, activate the first visible tab const activeId = tabManager.getActiveTab(); const activateStart = performance.now(); startTimer('Sidebar.Visibility.Activate'); if (!activeId || !visibilityMap[activeId]) { tabManager.activateFirstTab(); } endTimer('Sidebar.Visibility.Activate'); const activateEnd = performance.now(); // Log a breakdown of where time was spent const totalMs = +(performance.now() - settingsLoadStart).toFixed(2); const fetchMs = settings?._timing?.fetch_ms ?? null; const parseMs = settings?._timing?.parse_ms ?? null; const mapMs = settings?._timing?.map_ms ?? +(mapEnd - mapStart).toFixed(2); const updateMs = +(updateEnd - updateStart).toFixed(2); const activateMs = +(activateEnd - activateStart).toFixed(2); console.info('[Sidebar] Visibility settings applied', { total_ms: totalMs, fetch_ms: fetchMs, parse_ms: parseMs, map_ms: mapMs, update_visibility_ms: updateMs, activate_ms: activateMs }); // Optionally persist timing immediately if telemetry is enabled const shouldSendTiming = localStorage.getItem('sageutils_send_timing') === 'true' || new URLSearchParams(window.location.search).get('sageutils_timing') === '1'; if (shouldSendTiming) { javascriptTimer.sendTimingDataToServer?.().catch(() => {}); } // Remove or update the indicator if (settingsIndicator?.parentNode) { settingsIndicator.textContent = `Settings applied in ${totalMs} ms`; setTimeout(() => { try { settingsIndicator.parentNode.removeChild(settingsIndicator); } catch { /* no-op */ } }, 1200); } } catch (e) { console.warn('[Sidebar] Failed applying visibility settings, continuing with defaults:', e); if (settingsIndicator?.parentNode) { try { settingsIndicator.parentNode.removeChild(settingsIndicator); } catch { /* no-op */ } } } }).catch((e) => { console.warn('[Sidebar] Visibility settings load failed, using defaults:', e); if (settingsIndicator?.parentNode) { try { settingsIndicator.parentNode.removeChild(settingsIndicator); } catch { /* no-op */ } } }); // Start background preloading of other tabs during idle time // Priority order: commonly used tabs first setTimeout(() => { console.debug('[Sidebar] Starting background tab preloading...'); tabManager.preloadTabsDuringIdle({ maxIdleTime: 20, // Lower threshold to ensure we have real idle time timeout: 5000, // Longer timeout to wait for idle periods priority: ['llm', 'gallery', 'promptBuilder', 'notes', 'civitai'] }); }, 2000); // Wait 2 seconds after initial load to ensure UI is responsive // Preload gallery images in the background for better UX // This loads the default folder (usually 'notes') so data is ready when user clicks Gallery tab setTimeout(() => { const defaultFolder = actions ? (typeof actions.selectedFolder === 'function' ? actions.selectedFolder() : 'notes') : 'notes'; const savedFolder = selectors.selectedFolder ? selectors.selectedFolder() : defaultFolder; const galleryFolder = savedFolder !== 'custom' ? savedFolder : 'notes'; // Don't auto-load custom folders // Check if already preloaded in cache const cacheKey = `galleryImages:${galleryFolder}`; if (DataCache.isReady(cacheKey)) { console.debug(`[Sidebar] Gallery images for '${galleryFolder}' already preloaded`); // Store in state management const cachedData = DataCache.get(cacheKey); if (cachedData && cachedData.images) { actions.setImages(cachedData.images); actions.setFolders(cachedData.folders || []); console.debug(`[Sidebar] Gallery cache hit: ${cachedData.images.length} images, ${cachedData.folders?.length || 0} folders`); } return; } console.debug(`[Sidebar] Preloading gallery images from '${galleryFolder}' folder in background...`); loadImagesFromFolder(galleryFolder, null, (msg) => { // Only log start and completion messages, skip progress updates to reduce console noise if (msg.includes('Loading images from') || msg.includes('Error') || msg.includes('Complete')) { console.debug(`[Sidebar Gallery Preload] ${msg}`); } }).then((result) => { console.debug(`[Sidebar] Gallery preload complete for '${galleryFolder}' folder`); // Store in cache for future use DataCache.set(cacheKey, result); // Also store in state management if (result && result.images) { actions.setImages(result.images); actions.setFolders(result.folders || []); } }).catch(err => { console.warn(`[Sidebar] Gallery preload failed (non-critical):`, err); }); }, 1500); // Slightly longer delay to let tab preloading start first // Store references for potential external access el._sidebarData = { tabManager, switchTab: (tabId) => tabManager.switchTab(tabId), initializedTabs: tabManager.initializedTabs, cleanup: () => { // Cleanup cross-tab subscription if (crossTabUnsubscribe) { try { crossTabUnsubscribe(); crossTabUnsubscribe = null; } catch (err) { console.warn('[Sidebar] Error unsubscribing from cross-tab messaging:', err); } } // Destroy tab manager tabManager.destroy(); } }; // Subscribe to cross-tab messaging for tab switching // Use a slight delay to ensure all components are fully initialized let crossTabUnsubscribe = null; setTimeout(() => { import('../shared/crossTabMessaging.js').then(({ getEventBus, MessageTypes }) => { const bus = getEventBus(); crossTabUnsubscribe = bus.subscribe(MessageTypes.TAB_SWITCH_REQUEST, (message) => { const { tabId, source } = message.data; // Map tab IDs to actual tab keys used in switchTab() const tabKeyMap = { 'llm': 'llm', 'gallery': 'gallery', 'prompts': 'promptBuilder', 'prompt-builder': 'promptBuilder', // Alias 'files': 'notes', 'notes': 'notes', 'models': 'models', 'civitai': 'civitai' }; const tabKey = tabKeyMap[tabId] || tabId; // Validate that tab manager exists if (!tabManager) { console.warn('[Sidebar] TabManager not available'); return; } console.debug(`[Sidebar] Received cross-tab switch request for '${tabId}' from ${source}, mapped to '${tabKey}'`); // Switch to the requested tab using TabManager try { tabManager.switchTab(tabKey); bus.publish(MessageTypes.TAB_SWITCHED, { tabId: tabKey, source: 'sidebar' }); console.debug(`[Sidebar] Successfully switched to tab: ${tabKey}`); } catch (error) { console.error(`[Sidebar] Error switching to tab '${tabKey}':`, error); } }); console.debug('[Sidebar] Subscribed to cross-tab switch requests'); }).catch(err => { console.warn('[Sidebar] Failed to load cross-tab messaging:', err); }); }, 100); } /** * Reload the sidebar with updated settings * This is called when tab visibility settings change */ export async function reloadCacheSidebar() { if (!currentSidebarElement) { console.warn('No sidebar element reference found for reload'); return; } console.log('Reloading sidebar with updated settings...'); // Clear the current sidebar content currentSidebarElement.innerHTML = ''; // Recreate the sidebar await createCacheSidebar(currentSidebarElement); console.log('Sidebar reloaded successfully'); }