model-explorer / js /ui /modelListDisplay.js
mr4's picture
Upload 71 files
9bd422a verified
/**
* 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 = `
<div class="d-flex align-items-center">
<i class="fas fa-cube me-2 text-primary"></i>
<div class="overflow-hidden">
<div class="text-truncate fw-medium">${this._escapeHtml(displayName)}</div>
${model.description ? `<small class="text-muted text-truncate d-block">${this._escapeHtml(model.description)}</small>` : ''}
</div>
</div>
`;
// 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<ModelInfo>} 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 = `<i class="fas fa-inbox me-2"></i>${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;