/** * ModelListDisplay - Displays the list of available ONNX models * Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6 */ class ModelListDisplay { /** * @param {string} containerId - ID of the container element */ constructor(containerId) { this._containerId = containerId; this._container = document.getElementById(containerId); this._selectedModelId = null; this._unsubscribe = null; if (!this._container) { console.warn(`[ModelListDisplay] Container #${containerId} not found`); return; } this._bindStateSubscriptions(); } // ─── Private ────────────────────────────────────────────────────────────── _bindStateSubscriptions() { // Restore persisted selection const persistedId = window.StateManager ? window.StateManager.getPersistedSelectedModelId() : null; if (persistedId) { this._selectedModelId = persistedId; } // Subscribe to currentModel changes to keep selection in sync if (window.StateManager) { this._unsubscribe = window.StateManager.subscribe('currentModel', (model) => { const id = model && model.metadata ? model.metadata.fileName : null; if (id !== this._selectedModelId) { this._selectedModelId = id; this._updateSelectedState(); } }); } } /** * Format a file path to a human-readable name (show only the file name). * @param {string} filePath * @returns {string} */ _formatFileName(filePath) { if (!filePath) return 'Unknown'; // Use helper if available if (typeof formatFileName === 'function') { return formatFileName(filePath); } // Fallback: strip path separators const parts = filePath.replace(/\\/g, '/').split('/'); return parts[parts.length - 1] || filePath; } /** * Build a single list item element for a model. * @param {ModelInfo} model * @returns {HTMLElement} */ _buildItem(model) { const item = document.createElement('a'); item.href = '#'; item.className = 'list-group-item list-group-item-action model-list-item'; item.dataset.modelId = model.id || model.path; item.setAttribute('role', 'button'); item.setAttribute('aria-label', `Select model ${this._formatFileName(model.name || model.path)}`); const displayName = this._formatFileName(model.name || model.path); item.innerHTML = `
${this._escapeHtml(displayName)}
${model.description ? `${this._escapeHtml(model.description)}` : ''}
`; // Mark as selected if this is the current model if (this._selectedModelId && (model.id === this._selectedModelId || model.path === this._selectedModelId)) { item.classList.add('active'); item.setAttribute('aria-current', 'true'); } item.addEventListener('click', (e) => { e.preventDefault(); this._onModelClick(model, item); }); return item; } /** * Handle model item click. * @param {ModelInfo} model * @param {HTMLElement} item */ _onModelClick(model, item) { // Update visual selection this._selectedModelId = model.id || model.path; this._updateSelectedState(); // Emit event via EventBus if (window.EventBus) { window.EventBus.emit(CONFIG.EVENTS.MODEL_SELECTED, model); } // Update StateManager if available (without triggering re-render loop) if (window.StateManager) { // Persist selection try { localStorage.setItem(CONFIG.STORAGE.SELECTED_MODEL, this._selectedModelId); } catch (_) { /* ignore */ } } } /** * Update the active/selected CSS class on all items. */ _updateSelectedState() { if (!this._container) return; const items = this._container.querySelectorAll('.model-list-item'); items.forEach((item) => { const isSelected = item.dataset.modelId === this._selectedModelId; item.classList.toggle('active', isSelected); if (isSelected) { item.setAttribute('aria-current', 'true'); } else { item.removeAttribute('aria-current'); } }); } /** * Escape HTML special characters to prevent XSS. * @param {string} str * @returns {string} */ _escapeHtml(str) { const div = document.createElement('div'); div.appendChild(document.createTextNode(str)); return div.innerHTML; } // ─── Public API ─────────────────────────────────────────────────────────── /** * Render the model list. * @param {Array} models */ render(models) { if (!this._container) return; this._container.innerHTML = ''; if (!models || models.length === 0) { const empty = document.createElement('div'); empty.className = 'list-group-item text-muted text-center py-3'; empty.setAttribute('role', 'status'); empty.innerHTML = `${CONFIG.ERRORS.NO_MODELS_AVAILABLE}`; this._container.appendChild(empty); return; } const fragment = document.createDocumentFragment(); models.forEach((model) => { fragment.appendChild(this._buildItem(model)); }); this._container.appendChild(fragment); } /** * Clear the model list display. */ clear() { if (!this._container) return; this._container.innerHTML = ''; } /** * Destroy the component and clean up subscriptions. */ destroy() { if (this._unsubscribe) { this._unsubscribe(); this._unsubscribe = null; } } } window.ModelListDisplay = ModelListDisplay;