feat(controls): keep model counts readable in large libraries
Counts in the filter panel chips (base models, tags, model types), the All Types
dropdown and the Refresh scope rows were rendered as raw digits, so a library with
10k+ models turned every chip into a five digit pill that dominated the row it sat
in — and the Refresh menu grouped its numbers while the filter panel did not.
One formatter now renders all of them (`formatCount`): exact and locale-grouped
below 10,000, abbreviated to three significant digits above it, and abbreviated
only where the locale's Intl compact form is a real unit and narrower than the
plain number — German keeps 18.475 instead of the rounded 18.500, Russian keeps
18 475 instead of the wider 18,5 тыс.
Their hover text is always present and always exact ({name} — {count} models, and
... recipes on the Recipes page): an affordance that only exists above a threshold
is undiscoverable, and the tooltip is also what reveals a chip label that CSS has
ellipsized.
Chips gain a two-slot layout (the name ellipsizes, the count keeps a tabular
right-aligned slot) so a long tag name can no longer push the count out of the
chip; Refresh rows move from float:right to a flex row, which keeps the counts in
a right-hand column and lets a long root label ellipsize. The All Types group
tooltip moves onto its trigger so it stops leaking onto the menu options.
The two new strings ship as [TODO: Translate] placeholders in the nine locales;
the translation pass is the next commit.
This commit is contained in:
@@ -264,6 +264,7 @@
|
||||
},
|
||||
"filter": {
|
||||
"title": "Modelle filtern",
|
||||
"countTitle": "[TODO: Translate] {name} — {count} models",
|
||||
"presets": "Voreinstellungen",
|
||||
"savePreset": "Aktive Filter als neue Voreinstellung speichern.",
|
||||
"savePresetDisabledActive": "Speichern nicht möglich: Eine Voreinstellung ist bereits aktiv. Ändern Sie die Filter, um eine neue Voreinstellung zu speichern",
|
||||
@@ -1097,6 +1098,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "LoRA-Rezepte",
|
||||
"filter": {
|
||||
"countTitle": "[TODO: Translate] {name} — {count} recipes"
|
||||
},
|
||||
"actions": {
|
||||
"sendCheckpoint": "Send to ComfyUI",
|
||||
"sendRecipe": "Send to ComfyUI",
|
||||
|
||||
@@ -264,6 +264,7 @@
|
||||
},
|
||||
"filter": {
|
||||
"title": "Filter Models",
|
||||
"countTitle": "{name} — {count} models",
|
||||
"presets": "Presets",
|
||||
"savePreset": "Save current active filters as a new preset.",
|
||||
"savePresetDisabledActive": "Cannot save: A preset is already active. Modify filters to save new preset.",
|
||||
@@ -1097,6 +1098,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "LoRA Recipes",
|
||||
"filter": {
|
||||
"countTitle": "{name} — {count} recipes"
|
||||
},
|
||||
"actions": {
|
||||
"sendCheckpoint": "Send to ComfyUI",
|
||||
"sendRecipe": "Send to ComfyUI",
|
||||
|
||||
@@ -264,6 +264,7 @@
|
||||
},
|
||||
"filter": {
|
||||
"title": "Filtrar modelos",
|
||||
"countTitle": "[TODO: Translate] {name} — {count} models",
|
||||
"presets": "Preajustes",
|
||||
"savePreset": "Guardar filtros activos como nuevo preajuste.",
|
||||
"savePresetDisabledActive": "No se puede guardar: Ya hay un preajuste activo. Modifique los filtros para guardar un nuevo preajuste",
|
||||
@@ -1097,6 +1098,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "Recetas de LoRA",
|
||||
"filter": {
|
||||
"countTitle": "[TODO: Translate] {name} — {count} recipes"
|
||||
},
|
||||
"actions": {
|
||||
"sendCheckpoint": "Enviar a ComfyUI",
|
||||
"sendRecipe": "Enviar a ComfyUI",
|
||||
|
||||
@@ -264,6 +264,7 @@
|
||||
},
|
||||
"filter": {
|
||||
"title": "Filtrer les modèles",
|
||||
"countTitle": "[TODO: Translate] {name} — {count} models",
|
||||
"presets": "Préréglages",
|
||||
"savePreset": "Enregistrer les filtres actifs comme nouveau préréglage.",
|
||||
"savePresetDisabledActive": "Impossible d'enregistrer : Un préréglage est déjà actif. Modifiez les filtres pour enregistrer un nouveau préréglage",
|
||||
@@ -1097,6 +1098,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "LoRA Recipes",
|
||||
"filter": {
|
||||
"countTitle": "[TODO: Translate] {name} — {count} recipes"
|
||||
},
|
||||
"actions": {
|
||||
"sendCheckpoint": "Envoyer vers ComfyUI",
|
||||
"sendRecipe": "Envoyer vers ComfyUI",
|
||||
|
||||
@@ -264,6 +264,7 @@
|
||||
},
|
||||
"filter": {
|
||||
"title": "סנן מודלים",
|
||||
"countTitle": "[TODO: Translate] {name} — {count} models",
|
||||
"presets": "קביעות מראש",
|
||||
"savePreset": "שמור מסננים פעילים כקביעה מראש חדשה.",
|
||||
"savePresetDisabledActive": "לא ניתן לשמור: קביעה מראש כבר פעילה. שנה מסננים כדי לשמור קביעה מראש חדשה",
|
||||
@@ -1097,6 +1098,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "מתכוני LoRA",
|
||||
"filter": {
|
||||
"countTitle": "[TODO: Translate] {name} — {count} recipes"
|
||||
},
|
||||
"actions": {
|
||||
"sendCheckpoint": "שלח ל-ComfyUI",
|
||||
"sendRecipe": "שלח ל-ComfyUI",
|
||||
|
||||
@@ -264,6 +264,7 @@
|
||||
},
|
||||
"filter": {
|
||||
"title": "モデルをフィルタ",
|
||||
"countTitle": "[TODO: Translate] {name} — {count} models",
|
||||
"presets": "プリセット",
|
||||
"savePreset": "現在のアクティブフィルタを新しいプリセットとして保存。",
|
||||
"savePresetDisabledActive": "保存できません:プリセットがすでにアクティブです。フィルタを変更して新しいプリセットを保存してください",
|
||||
@@ -1097,6 +1098,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "LoRAレシピ",
|
||||
"filter": {
|
||||
"countTitle": "[TODO: Translate] {name} — {count} recipes"
|
||||
},
|
||||
"actions": {
|
||||
"sendCheckpoint": "ComfyUIへ送信",
|
||||
"sendRecipe": "ComfyUIへ送信",
|
||||
|
||||
@@ -264,6 +264,7 @@
|
||||
},
|
||||
"filter": {
|
||||
"title": "모델 필터",
|
||||
"countTitle": "[TODO: Translate] {name} — {count} models",
|
||||
"presets": "프리셋",
|
||||
"savePreset": "현재 활성 필터를 새 프리셋으로 저장.",
|
||||
"savePresetDisabledActive": "저장할 수 없음: 프리셋이 이미 활성화되어 있습니다. 필터를 수정한 후 새 프리셋을 저장하세요",
|
||||
@@ -1097,6 +1098,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "LoRA 레시피",
|
||||
"filter": {
|
||||
"countTitle": "[TODO: Translate] {name} — {count} recipes"
|
||||
},
|
||||
"actions": {
|
||||
"sendCheckpoint": "ComfyUI로 보내기",
|
||||
"sendRecipe": "ComfyUI로 보내기",
|
||||
|
||||
@@ -264,6 +264,7 @@
|
||||
},
|
||||
"filter": {
|
||||
"title": "Фильтр моделей",
|
||||
"countTitle": "[TODO: Translate] {name} — {count} models",
|
||||
"presets": "Пресеты",
|
||||
"savePreset": "Сохранить текущие активные фильтры как новый пресет.",
|
||||
"savePresetDisabledActive": "Невозможно сохранить: Пресет уже активен. Измените фильтры, чтобы сохранить новый пресет",
|
||||
@@ -1097,6 +1098,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "Рецепты LoRA",
|
||||
"filter": {
|
||||
"countTitle": "[TODO: Translate] {name} — {count} recipes"
|
||||
},
|
||||
"actions": {
|
||||
"sendCheckpoint": "Отправить в ComfyUI",
|
||||
"sendRecipe": "Отправить в ComfyUI",
|
||||
|
||||
@@ -264,6 +264,7 @@
|
||||
},
|
||||
"filter": {
|
||||
"title": "筛选模型",
|
||||
"countTitle": "[TODO: Translate] {name} — {count} models",
|
||||
"presets": "预设",
|
||||
"savePreset": "将当前激活的筛选器保存为新预设。",
|
||||
"savePresetDisabledActive": "无法保存:已有预设处于激活状态。修改筛选器后可保存新预设",
|
||||
@@ -1097,6 +1098,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "LoRA 配方",
|
||||
"filter": {
|
||||
"countTitle": "[TODO: Translate] {name} — {count} recipes"
|
||||
},
|
||||
"actions": {
|
||||
"sendCheckpoint": "发送到 ComfyUI",
|
||||
"sendRecipe": "发送到 ComfyUI",
|
||||
|
||||
@@ -264,6 +264,7 @@
|
||||
},
|
||||
"filter": {
|
||||
"title": "篩選模型",
|
||||
"countTitle": "[TODO: Translate] {name} — {count} models",
|
||||
"presets": "預設",
|
||||
"savePreset": "將目前啟用的篩選器儲存為新預設。",
|
||||
"savePresetDisabledActive": "無法儲存:已有預設處於啟用狀態。修改篩選器後可儲存新預設",
|
||||
@@ -1097,6 +1098,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "LoRA 配方",
|
||||
"filter": {
|
||||
"countTitle": "[TODO: Translate] {name} — {count} recipes"
|
||||
},
|
||||
"actions": {
|
||||
"sendCheckpoint": "傳送到 ComfyUI",
|
||||
"sendRecipe": "傳送到 ComfyUI",
|
||||
|
||||
@@ -295,12 +295,44 @@
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
/* Chips that carry a count get a stable two-slot layout: the label shrinks and
|
||||
ellipsizes, the count keeps its own slot. Without it a long tag name and a
|
||||
5+ digit count (large libraries) fight over the chip and the count pill can
|
||||
be pushed past the chip's border. */
|
||||
.filter-tag:has(.tag-count) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
min-width: 60px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.filter-tag:has(.tag-count) .tag-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-tag:has(.tag-count) .tag-count {
|
||||
margin-left: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tag-count {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.8em;
|
||||
margin-left: 4px;
|
||||
/* Counts reach 5+ digits in large libraries: tabular digits keep the pill
|
||||
from resizing as the data refreshes, right alignment pins the digits to
|
||||
the chip edge, and the min-width keeps 1-3 digit counts from producing a
|
||||
cramped pill next to a 4-digit one. */
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
min-width: 2.5ch;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tag-count {
|
||||
|
||||
+20
-1
@@ -750,16 +750,35 @@ body.sticky-controls .sticky-topbar {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Label and count as two slots instead of label + floated count: the label
|
||||
column absorbs the row's spare width (so counts still line up on the right)
|
||||
and ellipsizes rather than pushing the count out of the menu. */
|
||||
.scan-root-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scan-root-item i {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scan-root-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-right: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scan-root-count,
|
||||
.scan-root-offline {
|
||||
float: right;
|
||||
margin-left: 16px;
|
||||
font-size: 0.9em;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scan-root-item.is-offline .scan-root-offline {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { getCurrentPageState } from '../../state/index.js';
|
||||
import { getStorageItem, setStorageItem } from '../../utils/storageHelpers.js';
|
||||
import { resetAndReload } from '../../api/modelApiFactory.js';
|
||||
import { formatCount, formatCountExact } from '../../utils/formatters.js';
|
||||
|
||||
/**
|
||||
* AlphabetBar class - Handles the alphabet filtering UI and interactions
|
||||
@@ -77,7 +78,7 @@ export class AlphabetBar {
|
||||
|
||||
// Update the title attribute for tooltip display
|
||||
if (count > 0) {
|
||||
chip.title = `${letter}: ${count} LoRAs`;
|
||||
chip.title = `${letter}: ${formatCountExact(count)} LoRAs`;
|
||||
chip.classList.remove('disabled');
|
||||
} else {
|
||||
chip.title = `${letter}: No LoRAs`;
|
||||
@@ -87,7 +88,7 @@ export class AlphabetBar {
|
||||
// Keep the count span for backward compatibility
|
||||
const countSpan = chip.querySelector('.count');
|
||||
if (countSpan) {
|
||||
countSpan.textContent = ` (${count})`;
|
||||
countSpan.textContent = ` (${formatCount(count)})`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import { MODEL_TYPE_DISPLAY_NAMES } from '../../utils/constants.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { formatCount, formatCountTitle } from '../../utils/formatters.js';
|
||||
|
||||
const GROUP_ID = 'modelTypeDropdown';
|
||||
const ALL_TYPES_VALUE = '';
|
||||
@@ -89,7 +90,13 @@ export async function initModelTypeDropdown(pageType) {
|
||||
item.setAttribute('role', 'option');
|
||||
item.tabIndex = -1;
|
||||
item.dataset.value = type;
|
||||
item.textContent = `${MODEL_TYPE_DISPLAY_NAMES[type]} (${count})`;
|
||||
item.textContent = `${MODEL_TYPE_DISPLAY_NAMES[type]} (${formatCount(count)})`;
|
||||
// Same rule as the filter chips: the tooltip carries the exact count
|
||||
// at every magnitude, so hovering behaves the same whether or not
|
||||
// the menu had to abbreviate. It also replaces the tooltip this
|
||||
// option would otherwise inherit from the group, which describes the
|
||||
// control rather than this entry.
|
||||
item.title = formatCountTitle(MODEL_TYPE_DISPLAY_NAMES[type], count);
|
||||
fragment.appendChild(item);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// ScanScopeMenu.js - per-root entries of the Refresh dropdown (issue #1108)
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { formatCount, formatCountExact } from '../../utils/formatters.js';
|
||||
|
||||
/**
|
||||
* Render one row per configured model root.
|
||||
@@ -39,13 +40,23 @@ export function renderScanScopeMenu(menu, details = []) {
|
||||
|
||||
const meta = document.createElement('span');
|
||||
if (detail.reachable) {
|
||||
const count = Number(detail.models || 0).toLocaleString();
|
||||
const count = Number(detail.models || 0);
|
||||
// The visible count is abbreviated at large magnitudes, like the
|
||||
// filter chips; the row tooltip always names the path and the exact
|
||||
// count, so its shape does not change with the library size.
|
||||
const exact = formatCountExact(count);
|
||||
const shown = formatCount(count);
|
||||
meta.className = 'scan-root-count';
|
||||
meta.textContent = translate(
|
||||
'loras.controls.refresh.rootModels',
|
||||
{ count },
|
||||
`${count} models`
|
||||
{ count: shown },
|
||||
`${shown} models`
|
||||
);
|
||||
item.title = `${detail.path}\n${translate(
|
||||
'loras.controls.refresh.rootModels',
|
||||
{ count: exact },
|
||||
`${exact} models`
|
||||
)}`;
|
||||
} else {
|
||||
meta.className = 'scan-root-offline';
|
||||
meta.textContent = translate('loras.controls.refresh.rootOffline', {}, 'Offline');
|
||||
|
||||
@@ -5,12 +5,49 @@ import { getApiEndpoints } from '../api/apiConfig.js';
|
||||
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
||||
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { formatCount, formatCountTitle } from '../utils/formatters.js';
|
||||
import { FilterPresetManager, EMPTY_WILDCARD_MARKER } from './FilterPresetManager.js';
|
||||
|
||||
// LoRA availability statuses available on the recipes page. No statuses
|
||||
// selected (the default) means no filtering.
|
||||
const LORA_AVAILABILITY_STATUSES = ['ready', 'missing', 'deleted'];
|
||||
|
||||
/**
|
||||
* Render a filter chip's label and its optional count.
|
||||
*
|
||||
* The name gets its own span so CSS can ellipsize it instead of letting a long
|
||||
* name squeeze the count pill out of the chip; the chip tooltip repeats the
|
||||
* full name next to the exact count, which stays readable there even when the
|
||||
* number on screen is abbreviated. Both parts are set through textContent, so
|
||||
* a tag name is never parsed as HTML.
|
||||
*
|
||||
* @param {HTMLElement} chip - The chip element to fill
|
||||
* @param {string} name - Label text
|
||||
* @param {number|null} [count] - Entry count; omit to render a count-less chip
|
||||
* @param {string} [titleKey] - Translation key for the count sentence
|
||||
*/
|
||||
function renderChipContent(chip, name, count, titleKey) {
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'tag-name';
|
||||
nameEl.textContent = name;
|
||||
chip.appendChild(nameEl);
|
||||
|
||||
if (count === null || count === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const countEl = document.createElement('span');
|
||||
countEl.className = 'tag-count';
|
||||
countEl.textContent = formatCount(count);
|
||||
chip.appendChild(countEl);
|
||||
|
||||
const title = formatCountTitle(name, count, titleKey);
|
||||
chip.title = title;
|
||||
// title is unreliable for assistive tech, and the visible name may be
|
||||
// ellipsized, so the accessible name carries the full sentence too.
|
||||
chip.setAttribute('aria-label', title);
|
||||
}
|
||||
|
||||
export class FilterManager {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
@@ -18,6 +55,11 @@ export class FilterManager {
|
||||
};
|
||||
|
||||
this.currentPage = options.page || document.body.dataset.page || 'loras';
|
||||
// The filter chips count models on every page except Recipes, where the
|
||||
// same sections count recipes.
|
||||
this.countTitleKey = this.currentPage === 'recipes'
|
||||
? 'recipes.filter.countTitle'
|
||||
: 'header.filter.countTitle';
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
this.filters = this.initializeFilters(pageState ? pageState.filters : undefined);
|
||||
@@ -309,11 +351,7 @@ export class FilterManager {
|
||||
tagEl.dataset.tag = tagName;
|
||||
|
||||
// Show count only if it's > 0 (known count)
|
||||
if (tag.count > 0) {
|
||||
tagEl.innerHTML = `${tagName} <span class="tag-count">${tag.count}</span>`;
|
||||
} else {
|
||||
tagEl.textContent = tagName;
|
||||
}
|
||||
renderChipContent(tagEl, tagName, tag.count > 0 ? tag.count : null, this.countTitleKey);
|
||||
|
||||
// Add click handler to cycle through tri-state filter and automatically apply
|
||||
tagEl.addEventListener('click', async () => {
|
||||
@@ -543,7 +581,7 @@ export class FilterManager {
|
||||
// bucket shows "Unknown" but filters via a dedicated marker).
|
||||
const filterValue = model.value ?? model.name;
|
||||
tag.dataset.baseModel = filterValue;
|
||||
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
|
||||
renderChipContent(tag, model.name, model.count, this.countTitleKey);
|
||||
|
||||
tag.addEventListener('click', async () => {
|
||||
tag.classList.toggle('active');
|
||||
@@ -614,7 +652,7 @@ export class FilterManager {
|
||||
const tag = document.createElement('div');
|
||||
tag.className = 'filter-tag model-type-tag';
|
||||
tag.dataset.modelType = type;
|
||||
tag.innerHTML = `${MODEL_TYPE_DISPLAY_NAMES[type]} <span class="tag-count">${count}</span>`;
|
||||
renderChipContent(tag, MODEL_TYPE_DISPLAY_NAMES[type], count, this.countTitleKey);
|
||||
|
||||
if (this.filters.modelTypes.includes(type)) {
|
||||
tag.classList.add('active');
|
||||
|
||||
@@ -1,3 +1,81 @@
|
||||
import { i18n } from '../i18n/index.js';
|
||||
import { translate } from './i18nHelpers.js';
|
||||
|
||||
/**
|
||||
* Magnitude from which counts are abbreviated in dense UI (filter chips,
|
||||
* dropdown rows). Below it the count is shown exact, so ordinary libraries
|
||||
* look exactly like before; above it a five digit number would otherwise
|
||||
* become the widest and loudest element of the row it sits in. The exact value
|
||||
* stays available through `formatCountExact` for tooltips and aria-labels.
|
||||
*/
|
||||
const COUNT_COMPACT_FROM = 10000;
|
||||
|
||||
/**
|
||||
* Format an entry count (models, recipes, tags) for dense UI.
|
||||
*
|
||||
* Abbreviated to ~3 significant digits above `compactFrom`, so a five digit
|
||||
* count does not become the widest element of the row it sits in. The
|
||||
* abbreviation is left to Intl, which follows the UI language ("19.9K",
|
||||
* "1.99万", "19,9 k").
|
||||
*
|
||||
* Locales without a compact unit for the magnitude are detected and fall back
|
||||
* to the exact, grouped count: German has no unit below a million, so its
|
||||
* "compact" form is a *rounded* plain number ("18.500" for 18,475) that reads
|
||||
* as exact but is not, and Russian's "19,9 тыс." is wider than "18 475". An
|
||||
* abbreviation is only used when the locale actually produced one and it is
|
||||
* shorter than the exact count.
|
||||
*
|
||||
* @param {number} value - The count
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.compactFrom] - Magnitude to start abbreviating at
|
||||
* @returns {string} Formatted count (e.g. "42", "5,210", "19.9K")
|
||||
*/
|
||||
export function formatCount(value, { compactFrom = COUNT_COMPACT_FROM } = {}) {
|
||||
const count = Number(value) || 0;
|
||||
const exact = i18n.formatNumber(count);
|
||||
if (count < compactFrom) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
const compact = i18n.formatNumber(count, {
|
||||
notation: 'compact',
|
||||
maximumSignificantDigits: 3,
|
||||
});
|
||||
const roundedPlain = i18n.formatNumber(Number(count.toPrecision(3)));
|
||||
|
||||
return compact !== roundedPlain && compact.length < exact.length ? compact : exact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an entry count in full, for tooltips and aria-labels that must not
|
||||
* lose precision to `formatCount`'s abbreviation.
|
||||
*
|
||||
* @param {number} value - The count
|
||||
* @returns {string} Exact, locale-grouped count (e.g. "19,876")
|
||||
*/
|
||||
export function formatCountExact(value) {
|
||||
return i18n.formatNumber(Number(value) || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover text for a counted chip or dropdown row: the full name plus the exact
|
||||
* count.
|
||||
*
|
||||
* It is set unconditionally rather than only when the rendered count was
|
||||
* abbreviated, for two reasons: the name is what CSS ellipsizes when a chip is
|
||||
* narrow, so the tooltip is the only place the full label can be read; and an
|
||||
* affordance that appears at some magnitudes and not others is unpredictable.
|
||||
*
|
||||
* @param {string} name - Label the count belongs to
|
||||
* @param {number} value - The count
|
||||
* @param {string} [key] - Translation key for the sentence
|
||||
* @returns {string} e.g. "character — 18,475 models"
|
||||
*/
|
||||
export function formatCountTitle(name, value, key = 'header.filter.countTitle') {
|
||||
const exact = formatCountExact(value);
|
||||
return translate(key, { name, count: exact }, `${name} — ${exact} models`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a file size in bytes to a human-readable string
|
||||
* @param {number} bytes - The size in bytes
|
||||
|
||||
@@ -65,8 +65,10 @@
|
||||
</select>
|
||||
</div>
|
||||
{% if page_id in ['loras', 'checkpoints', 'other'] %}
|
||||
<div class="control-group model-type-dropdown-group dropdown-group hidden" id="modelTypeDropdown" title="{{ t('loras.controls.modelType.title') }}">
|
||||
<button type="button" class="model-type-trigger" aria-haspopup="listbox" aria-expanded="false">
|
||||
{# The tooltip sits on the trigger, not the group: a group-level title
|
||||
leaks onto every menu option that has no title of its own. #}
|
||||
<div class="control-group model-type-dropdown-group dropdown-group hidden" id="modelTypeDropdown">
|
||||
<button type="button" class="model-type-trigger" aria-haspopup="listbox" aria-expanded="false" title="{{ t('loras.controls.modelType.title') }}">
|
||||
<span class="model-type-trigger__label"></span>
|
||||
<i class="fas fa-caret-down model-type-trigger__caret" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
@@ -18,6 +18,9 @@ vi.mock(I18N_MODULE, () => ({
|
||||
}));
|
||||
|
||||
const { renderScanScopeMenu, resolveScanScopeTarget } = await import(MODULE);
|
||||
const { i18n } = await import(
|
||||
new URL('../../../../static/js/i18n/index.js', import.meta.url).pathname
|
||||
);
|
||||
|
||||
describe('ScanScopeMenu', () => {
|
||||
let menu;
|
||||
@@ -38,7 +41,8 @@ describe('ScanScopeMenu', () => {
|
||||
|
||||
expect(rows[0].dataset.action).toBe('scan-root');
|
||||
expect(rows[0].dataset.root).toBe('/mnt/a/loras');
|
||||
expect(rows[0].title).toBe('/mnt/a/loras');
|
||||
// Tooltip is always two lines: full path, then the exact count.
|
||||
expect(rows[0].title).toBe('/mnt/a/loras\n7 models');
|
||||
expect(rows[0].querySelector('.scan-root-label').textContent).toBe('a/loras');
|
||||
expect(rows[0].querySelector('.scan-root-count').textContent).toBe('7 models');
|
||||
expect(rows[0].classList.contains('is-offline')).toBe(false);
|
||||
@@ -49,6 +53,29 @@ describe('ScanScopeMenu', () => {
|
||||
expect(rows[1].dataset.action).toBe('scan-root');
|
||||
});
|
||||
|
||||
it('abbreviates five digit counts and keeps the exact value in the row tooltip', async () => {
|
||||
// The abbreviation itself is locale specific (K vs 万), so pin it.
|
||||
const originalLocale = i18n.currentLocale;
|
||||
i18n.currentLocale = 'en';
|
||||
try {
|
||||
renderScanScopeMenu(menu, [
|
||||
{ path: '/mnt/a/loras', label: 'a/loras', reachable: true, models: 12345 },
|
||||
{ path: '/mnt/b/loras', label: 'b/loras', reachable: true, models: 42 },
|
||||
]);
|
||||
|
||||
const rows = menu.querySelectorAll('.scan-root-item');
|
||||
expect(rows[0].querySelector('.scan-root-count').textContent).toBe('12.3K models');
|
||||
// Path stays in the tooltip; the exact count is always the second line, so
|
||||
// the tooltip has the same shape at every magnitude.
|
||||
expect(rows[0].title).toBe('/mnt/a/loras\n12,345 models');
|
||||
|
||||
expect(rows[1].querySelector('.scan-root-count').textContent).toBe('42 models');
|
||||
expect(rows[1].title).toBe('/mnt/b/loras\n42 models');
|
||||
} finally {
|
||||
i18n.currentLocale = originalLocale;
|
||||
}
|
||||
});
|
||||
|
||||
it('resolveScanScopeTarget reads path, label and offline state', () => {
|
||||
renderScanScopeMenu(menu, [
|
||||
{ path: '/mnt/b/loras', label: 'b/loras', reachable: false, models: 0 },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
import { initModelTypeDropdown } from '../../../static/js/components/controls/ModelTypeDropdown.js';
|
||||
import { i18n } from '../../../static/js/i18n/index.js';
|
||||
import { readTemplate } from '../utils/domFixtures.js';
|
||||
|
||||
function renderDropdownDom() {
|
||||
@@ -93,6 +94,31 @@ describe('ModelTypeDropdown', () => {
|
||||
expect(options[3].textContent).toBe('Text Encoder (30)');
|
||||
});
|
||||
|
||||
it('abbreviates large counts and keeps the exact value in the option tooltip', async () => {
|
||||
const originalLocale = i18n.currentLocale;
|
||||
i18n.currentLocale = 'en';
|
||||
try {
|
||||
mockModelTypesResponse([
|
||||
{ type: 'lora', count: 19876 },
|
||||
{ type: 'vae', count: 12 },
|
||||
]);
|
||||
|
||||
await initModelTypeDropdown('loras');
|
||||
|
||||
const options = document.querySelectorAll('.model-type-option');
|
||||
expect(options[1].textContent).toBe('LoRA (19.9K)');
|
||||
expect(options[1].title).toBe('LoRA — 19,876 models');
|
||||
// Every option carries its own tooltip, so hovering never depends on the
|
||||
// magnitude and never falls back to the group's "Filter by model type".
|
||||
expect(options[2].textContent).toBe('VAE (12)');
|
||||
expect(options[2].title).toBe('VAE — 12 models');
|
||||
// "All Types" is not a counted entry and keeps no tooltip of its own.
|
||||
expect(options[0].title).toBe('');
|
||||
} finally {
|
||||
i18n.currentLocale = originalLocale;
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores types without a known display name', async () => {
|
||||
mockModelTypesResponse([
|
||||
{ type: 'vae', count: 12 },
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// Same dependency mocks as FilterManager.tagLogic.test.js: the manager touches
|
||||
// storage, toasts, the API factory and the preset manager on construction.
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
getCurrentPageState: vi.fn(() => ({
|
||||
filters: {},
|
||||
})),
|
||||
state: {
|
||||
currentPageType: 'loras',
|
||||
loadingManager: {
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
updatePanelPositions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
getModelApiClient: vi.fn(() => ({
|
||||
loadMoreWithVirtualScroll: vi.fn().mockResolvedValue(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||
getStorageItem: vi.fn(),
|
||||
setStorageItem: vi.fn(),
|
||||
removeStorageItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/managers/FilterPresetManager.js', () => ({
|
||||
FilterPresetManager: vi.fn().mockImplementation(() => ({
|
||||
renderPresets: vi.fn(),
|
||||
saveActivePreset: vi.fn(),
|
||||
restoreActivePreset: vi.fn(),
|
||||
updateAddButtonState: vi.fn(),
|
||||
hasEmptyWildcardResult: vi.fn(() => false),
|
||||
})),
|
||||
EMPTY_WILDCARD_MARKER: '__EMPTY_WILDCARD_RESULT__',
|
||||
}));
|
||||
|
||||
import { FilterManager } from '../../../static/js/managers/FilterManager.js';
|
||||
import { i18n } from '../../../static/js/i18n/index.js';
|
||||
|
||||
const BASE_MODELS = [
|
||||
{ name: 'Illustrious', count: 19876 },
|
||||
{ name: 'Pony', count: 5210 },
|
||||
{ name: 'SD 1.5', count: 7 },
|
||||
];
|
||||
|
||||
const MODEL_TYPES = [
|
||||
{ type: 'lora', count: 19876 },
|
||||
{ type: 'locon', count: 12 },
|
||||
];
|
||||
|
||||
function jsonResponse(payload) {
|
||||
return { ok: true, json: async () => payload };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a manager whose filter panel sections are populated from the mocked
|
||||
* endpoints, i.e. through the real render path rather than by hand.
|
||||
*/
|
||||
async function createManagerWithChips() {
|
||||
global.fetch = vi.fn(async (url) => {
|
||||
const target = String(url);
|
||||
if (target.includes('/base-models')) {
|
||||
return jsonResponse({ success: true, base_models: BASE_MODELS });
|
||||
}
|
||||
if (target.includes('/model-types')) {
|
||||
return jsonResponse({ success: true, model_types: MODEL_TYPES });
|
||||
}
|
||||
return jsonResponse({ success: false });
|
||||
});
|
||||
|
||||
const manager = new FilterManager({ page: 'loras' });
|
||||
// createBaseModelTags/createModelTypeTags are fire-and-forget fetches.
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelectorAll('.model-type-tag').length).toBe(MODEL_TYPES.length);
|
||||
});
|
||||
return manager;
|
||||
}
|
||||
|
||||
let originalLocale;
|
||||
|
||||
beforeEach(() => {
|
||||
originalLocale = i18n.currentLocale;
|
||||
i18n.currentLocale = 'en';
|
||||
|
||||
// translate() resolves through window.i18n. Stubbing it lets the tests see
|
||||
// which key a call site picked (the recipes page uses its own sentence);
|
||||
// unknown keys fall back to the caller's English string, as in production.
|
||||
window.i18n = {
|
||||
t: (key, params) => {
|
||||
if (key === 'header.filter.countTitle') {
|
||||
return `${params.name} — ${params.count} models`;
|
||||
}
|
||||
if (key === 'recipes.filter.countTitle') {
|
||||
return `${params.name} — ${params.count} recipes`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
};
|
||||
|
||||
document.body.innerHTML = `
|
||||
<button id="filterButton"></button>
|
||||
<div id="filterPanel" class="hidden">
|
||||
<input id="baseModelSearchInput">
|
||||
<input id="modelTagsSearchInput">
|
||||
<div class="filter-tags" id="baseModelTags"></div>
|
||||
<div id="baseModelEmptyState" hidden></div>
|
||||
<div class="filter-tags" id="modelTagsFilter"></div>
|
||||
<div id="modelTagsEmptyState" hidden></div>
|
||||
<div class="filter-tags" id="modelTypeTags"></div>
|
||||
<div id="tagLogicToggle">
|
||||
<button class="tag-logic-option" data-value="any"></button>
|
||||
<button class="tag-logic-option" data-value="all"></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
i18n.currentLocale = originalLocale;
|
||||
delete window.i18n;
|
||||
delete global.fetch;
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('FilterManager chip counts', () => {
|
||||
it('renders base model and model type chips as name + count slots', async () => {
|
||||
await createManagerWithChips();
|
||||
|
||||
const baseModelChip = document.querySelector('.base-model-tag');
|
||||
expect(baseModelChip.querySelector('.tag-name').textContent).toBe('Illustrious');
|
||||
expect(baseModelChip.querySelector('.tag-count').textContent).toBe('19.9K');
|
||||
expect(baseModelChip.title).toBe('Illustrious — 19,876 models');
|
||||
expect(baseModelChip.getAttribute('aria-label')).toBe('Illustrious — 19,876 models');
|
||||
|
||||
const typeChip = document.querySelector('.model-type-tag');
|
||||
expect(typeChip.querySelector('.tag-name').textContent).toBe('LoRA');
|
||||
expect(typeChip.querySelector('.tag-count').textContent).toBe('19.9K');
|
||||
expect(typeChip.title).toBe('LoRA — 19,876 models');
|
||||
});
|
||||
|
||||
it('keeps the tooltip on every chip, abbreviated or not', async () => {
|
||||
await createManagerWithChips();
|
||||
|
||||
const ponyChip = [...document.querySelectorAll('.base-model-tag')]
|
||||
.find((chip) => chip.dataset.baseModel === 'Pony');
|
||||
// The pill is already exact — the tooltip still exists, so hovering a
|
||||
// count does not depend on the magnitude (or on the library size).
|
||||
expect(ponyChip.querySelector('.tag-count').textContent).toBe('5,210');
|
||||
expect(ponyChip.title).toBe('Pony — 5,210 models');
|
||||
});
|
||||
|
||||
it('renders tag chips with the same structure and count formatting', async () => {
|
||||
const manager = await createManagerWithChips();
|
||||
|
||||
manager.createTagFilterElements([
|
||||
{ tag: 'character', count: 19876 },
|
||||
{ tag: 'style', count: 42 },
|
||||
]);
|
||||
|
||||
const chips = document.querySelectorAll('#modelTagsFilter .tag-filter');
|
||||
expect(chips[0].querySelector('.tag-name').textContent).toBe('character');
|
||||
expect(chips[0].querySelector('.tag-count').textContent).toBe('19.9K');
|
||||
expect(chips[0].title).toBe('character — 19,876 models');
|
||||
|
||||
expect(chips[1].querySelector('.tag-name').textContent).toBe('style');
|
||||
expect(chips[1].querySelector('.tag-count').textContent).toBe('42');
|
||||
expect(chips[1].title).toBe('style — 42 models');
|
||||
// Trailing "No tags" chip carries no count.
|
||||
expect(chips[2].querySelector('.tag-count')).toBeNull();
|
||||
});
|
||||
|
||||
it('counts recipes, not models, on the recipes page', async () => {
|
||||
global.fetch = vi.fn(async (url) => {
|
||||
const target = String(url);
|
||||
if (target.includes('/base-models')) {
|
||||
return jsonResponse({ success: true, base_models: BASE_MODELS });
|
||||
}
|
||||
return jsonResponse({ success: false });
|
||||
});
|
||||
|
||||
new FilterManager({ page: 'recipes' });
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelectorAll('.base-model-tag').length).toBe(BASE_MODELS.length);
|
||||
});
|
||||
|
||||
const chip = document.querySelector('.base-model-tag');
|
||||
expect(chip.querySelector('.tag-count').textContent).toBe('19.9K');
|
||||
expect(chip.title).toBe('Illustrious — 19,876 recipes');
|
||||
});
|
||||
|
||||
it('treats tag names as text, never as markup', async () => {
|
||||
const manager = await createManagerWithChips();
|
||||
|
||||
manager.createTagFilterElements([{ tag: '<img src=x onerror=alert(1)>', count: 5 }]);
|
||||
|
||||
const chip = document.querySelector('#modelTagsFilter .tag-filter');
|
||||
expect(chip.querySelector('.tag-name').textContent).toBe('<img src=x onerror=alert(1)>');
|
||||
expect(chip.querySelector('img')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
formatCount,
|
||||
formatCountExact,
|
||||
formatCountTitle,
|
||||
} from '../../../static/js/utils/formatters.js';
|
||||
import { i18n } from '../../../static/js/i18n/index.js';
|
||||
|
||||
// The display locale is a property of the app, not of the test runner, so it is
|
||||
// pinned here instead of relying on the environment default.
|
||||
let originalLocale;
|
||||
|
||||
beforeEach(() => {
|
||||
originalLocale = i18n.currentLocale;
|
||||
i18n.currentLocale = 'en';
|
||||
vi.stubGlobal('i18n', {
|
||||
t: (key, params) => {
|
||||
if (key === 'header.filter.countTitle') {
|
||||
return `${params.name} — ${params.count} models`;
|
||||
}
|
||||
if (key === 'recipes.filter.countTitle') {
|
||||
return `${params.name} — ${params.count} recipes`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
i18n.currentLocale = originalLocale;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('formatCount', () => {
|
||||
it('shows counts below the compact threshold exactly and grouped', () => {
|
||||
expect(formatCount(0)).toBe('0');
|
||||
expect(formatCount(7)).toBe('7');
|
||||
expect(formatCount(999)).toBe('999');
|
||||
expect(formatCount(5210)).toBe('5,210');
|
||||
expect(formatCount(9999)).toBe('9,999');
|
||||
});
|
||||
|
||||
it('abbreviates to three significant digits from 10000 up', () => {
|
||||
expect(formatCount(10000)).toBe('10K');
|
||||
expect(formatCount(12345)).toBe('12.3K');
|
||||
expect(formatCount(19876)).toBe('19.9K');
|
||||
expect(formatCount(123456)).toBe('123K');
|
||||
expect(formatCount(1234567)).toBe('1.23M');
|
||||
});
|
||||
|
||||
it('treats missing or non numeric values as zero', () => {
|
||||
expect(formatCount(undefined)).toBe('0');
|
||||
expect(formatCount(null)).toBe('0');
|
||||
expect(formatCount('not-a-number')).toBe('0');
|
||||
expect(formatCount('19876')).toBe('19.9K');
|
||||
});
|
||||
|
||||
it('honours a caller supplied threshold', () => {
|
||||
expect(formatCount(1234567, { compactFrom: 1000 })).toBe('1.23M');
|
||||
expect(formatCount(19876, { compactFrom: Infinity })).toBe('19,876');
|
||||
// The abbreviation still has to save width: "5.21K" is exactly as wide
|
||||
// as "5,210", so the exact count wins.
|
||||
expect(formatCount(5210, { compactFrom: 1000 })).toBe('5,210');
|
||||
});
|
||||
|
||||
it('follows the UI locale rather than the browser locale', () => {
|
||||
// Documents that the abbreviation itself is locale specific: Chinese
|
||||
// switches to 万 at the same threshold instead of K.
|
||||
i18n.currentLocale = 'zh-CN';
|
||||
expect(formatCount(9999)).toBe('9,999');
|
||||
expect(formatCount(19876)).toBe('1.99万');
|
||||
});
|
||||
|
||||
it('keeps the exact count where the locale has no compact unit', () => {
|
||||
// German has no compact unit below a million: Intl would render 18,475
|
||||
// as the rounded plain number "18.500", which reads as exact but is not.
|
||||
i18n.currentLocale = 'de';
|
||||
expect(formatCount(18475)).toBe('18.475');
|
||||
// The tooltip is exact either way, it just repeats the same number here.
|
||||
expect(formatCountTitle('Flux.1 D', 18475)).toBe('Flux.1 D — 18.475 models');
|
||||
// From a million up German does have a unit, and it is shorter.
|
||||
// (Compared against Intl's own output: it uses a non-breaking space.)
|
||||
const compact = i18n.formatNumber(12345678, {
|
||||
notation: 'compact',
|
||||
maximumSignificantDigits: 3,
|
||||
});
|
||||
expect(formatCount(12345678)).toBe(compact);
|
||||
expect(compact.length).toBeLessThan(formatCountExact(12345678).length);
|
||||
});
|
||||
|
||||
it('keeps the exact count where the locale abbreviation is wider', () => {
|
||||
// Russian "18,5 тыс." is wider than the exact "18 475", which defeats
|
||||
// the point of abbreviating; "1,23 млн" is genuinely shorter.
|
||||
i18n.currentLocale = 'ru';
|
||||
expect(formatCount(18475)).toBe(formatCountExact(18475));
|
||||
|
||||
const compact = i18n.formatNumber(1234567, {
|
||||
notation: 'compact',
|
||||
maximumSignificantDigits: 3,
|
||||
});
|
||||
expect(formatCount(1234567)).toBe(compact);
|
||||
expect(compact.length).toBeLessThan(formatCountExact(1234567).length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCountExact', () => {
|
||||
it('never abbreviates', () => {
|
||||
expect(formatCountExact(19876)).toBe('19,876');
|
||||
expect(formatCountExact(1234567)).toBe('1,234,567');
|
||||
expect(formatCountExact(undefined)).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCountTitle', () => {
|
||||
it('pairs the full name with the exact count at every magnitude', () => {
|
||||
// Set unconditionally: an affordance that only exists above a threshold
|
||||
// is undiscoverable, and the name is what CSS may have ellipsized.
|
||||
expect(formatCountTitle('character', 0)).toBe('character — 0 models');
|
||||
expect(formatCountTitle('character', 578)).toBe('character — 578 models');
|
||||
expect(formatCountTitle('character', 9999)).toBe('character — 9,999 models');
|
||||
expect(formatCountTitle('character', 10000)).toBe('character — 10,000 models');
|
||||
expect(formatCountTitle('character', 18475)).toBe('character — 18,475 models');
|
||||
// The rendered pill may say "18.5K"; the tooltip never abbreviates.
|
||||
expect(formatCountTitle('character', 18475)).not.toContain('18.5K');
|
||||
});
|
||||
|
||||
it('uses the page specific sentence on the recipes page', () => {
|
||||
expect(formatCountTitle('Illustrious', 19876, 'recipes.filter.countTitle'))
|
||||
.toBe('Illustrious — 19,876 recipes');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user