feat(settings): declutter download root rule rows

- Move match-type selection out of rule rows: two side-by-side add
  buttons (+ Base model rule / + Tag rule) at the group bottom; rows
  show a static BM/Tag chip instead of a select. Changing a rule's
  type is now delete-and-recreate. Removed setDownloadRootRuleMatchType.
- Replace Edit/Collapse text buttons with a chevron-down icon that
  rotates when expanded (aria-expanded preserved, i18n tooltip kept).
- Truncate root dropdown option labels in the middle via
  truncateMiddlePath(path, 24, 16); option.value, option.title and
  select.title keep the full path, and (default) suffix is appended
  after truncation.
- Summary wording: empty drafts show "Select base models…" / "Enter
  tags…"; more than two items collapse to first two + "+N more"; tag
  summaries drop the "tags:" prefix since the chip already conveys it.
This commit is contained in:
Will Miao
2026-10-10 07:47:24 +08:00
parent c372a688de
commit dce7da3d58
13 changed files with 364 additions and 126 deletions
+9 -2
View File
@@ -501,7 +501,8 @@
"description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "[TODO: Translate] Default: {root} — unmatched downloads go here",
"defaultSuffix": "[TODO: Translate] default",
"addRule": "[TODO: Translate] Add rule",
"addBaseModelRule": "[TODO: Translate] Base model rule",
"addTagRule": "[TODO: Translate] Tag rule",
"removeRule": "[TODO: Translate] Remove rule",
"moveUp": "[TODO: Translate] Move up",
"moveDown": "[TODO: Translate] Move down",
@@ -510,9 +511,15 @@
"baseModel": "[TODO: Translate] Base model",
"tag": "[TODO: Translate] Tag"
},
"matchTypeChips": {
"baseModel": "[TODO: Translate] BM",
"tag": "[TODO: Translate] Tag"
},
"tagPlaceholder": "[TODO: Translate] character, style, realistic",
"summary": {
"tags": "[TODO: Translate] tags: {tags}"
"selectBaseModels": "[TODO: Translate] Select base models…",
"enterTags": "[TODO: Translate] Enter tags…",
"more": "[TODO: Translate] +{count} more"
},
"types": {
"loras": "[TODO: Translate] LoRA",
+10 -3
View File
@@ -497,11 +497,12 @@
},
"downloadRootRules": {
"title": "Download Root Rules",
"help": "Route downloads to a different root folder based on the model's base model.",
"help": "Route downloads to a different root folder based on the model's base model or tags.",
"description": "Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "Default: {root} — unmatched downloads go here",
"defaultSuffix": "default",
"addRule": "Add rule",
"addBaseModelRule": "Base model rule",
"addTagRule": "Tag rule",
"removeRule": "Remove rule",
"moveUp": "Move up",
"moveDown": "Move down",
@@ -510,9 +511,15 @@
"baseModel": "Base model",
"tag": "Tag"
},
"matchTypeChips": {
"baseModel": "BM",
"tag": "Tag"
},
"tagPlaceholder": "character, style, realistic",
"summary": {
"tags": "tags: {tags}"
"selectBaseModels": "Select base models…",
"enterTags": "Enter tags…",
"more": "+{count} more"
},
"types": {
"loras": "LoRA",
+9 -2
View File
@@ -501,7 +501,8 @@
"description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "[TODO: Translate] Default: {root} — unmatched downloads go here",
"defaultSuffix": "[TODO: Translate] default",
"addRule": "[TODO: Translate] Add rule",
"addBaseModelRule": "[TODO: Translate] Base model rule",
"addTagRule": "[TODO: Translate] Tag rule",
"removeRule": "[TODO: Translate] Remove rule",
"moveUp": "[TODO: Translate] Move up",
"moveDown": "[TODO: Translate] Move down",
@@ -510,9 +511,15 @@
"baseModel": "[TODO: Translate] Base model",
"tag": "[TODO: Translate] Tag"
},
"matchTypeChips": {
"baseModel": "[TODO: Translate] BM",
"tag": "[TODO: Translate] Tag"
},
"tagPlaceholder": "[TODO: Translate] character, style, realistic",
"summary": {
"tags": "[TODO: Translate] tags: {tags}"
"selectBaseModels": "[TODO: Translate] Select base models…",
"enterTags": "[TODO: Translate] Enter tags…",
"more": "[TODO: Translate] +{count} more"
},
"types": {
"loras": "[TODO: Translate] LoRA",
+9 -2
View File
@@ -501,7 +501,8 @@
"description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "[TODO: Translate] Default: {root} — unmatched downloads go here",
"defaultSuffix": "[TODO: Translate] default",
"addRule": "[TODO: Translate] Add rule",
"addBaseModelRule": "[TODO: Translate] Base model rule",
"addTagRule": "[TODO: Translate] Tag rule",
"removeRule": "[TODO: Translate] Remove rule",
"moveUp": "[TODO: Translate] Move up",
"moveDown": "[TODO: Translate] Move down",
@@ -510,9 +511,15 @@
"baseModel": "[TODO: Translate] Base model",
"tag": "[TODO: Translate] Tag"
},
"matchTypeChips": {
"baseModel": "[TODO: Translate] BM",
"tag": "[TODO: Translate] Tag"
},
"tagPlaceholder": "[TODO: Translate] character, style, realistic",
"summary": {
"tags": "[TODO: Translate] tags: {tags}"
"selectBaseModels": "[TODO: Translate] Select base models…",
"enterTags": "[TODO: Translate] Enter tags…",
"more": "[TODO: Translate] +{count} more"
},
"types": {
"loras": "[TODO: Translate] LoRA",
+9 -2
View File
@@ -501,7 +501,8 @@
"description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "[TODO: Translate] Default: {root} — unmatched downloads go here",
"defaultSuffix": "[TODO: Translate] default",
"addRule": "[TODO: Translate] Add rule",
"addBaseModelRule": "[TODO: Translate] Base model rule",
"addTagRule": "[TODO: Translate] Tag rule",
"removeRule": "[TODO: Translate] Remove rule",
"moveUp": "[TODO: Translate] Move up",
"moveDown": "[TODO: Translate] Move down",
@@ -510,9 +511,15 @@
"baseModel": "[TODO: Translate] Base model",
"tag": "[TODO: Translate] Tag"
},
"matchTypeChips": {
"baseModel": "[TODO: Translate] BM",
"tag": "[TODO: Translate] Tag"
},
"tagPlaceholder": "[TODO: Translate] character, style, realistic",
"summary": {
"tags": "[TODO: Translate] tags: {tags}"
"selectBaseModels": "[TODO: Translate] Select base models…",
"enterTags": "[TODO: Translate] Enter tags…",
"more": "[TODO: Translate] +{count} more"
},
"types": {
"loras": "[TODO: Translate] LoRA",
+9 -2
View File
@@ -501,7 +501,8 @@
"description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "[TODO: Translate] Default: {root} — unmatched downloads go here",
"defaultSuffix": "[TODO: Translate] default",
"addRule": "[TODO: Translate] Add rule",
"addBaseModelRule": "[TODO: Translate] Base model rule",
"addTagRule": "[TODO: Translate] Tag rule",
"removeRule": "[TODO: Translate] Remove rule",
"moveUp": "[TODO: Translate] Move up",
"moveDown": "[TODO: Translate] Move down",
@@ -510,9 +511,15 @@
"baseModel": "[TODO: Translate] Base model",
"tag": "[TODO: Translate] Tag"
},
"matchTypeChips": {
"baseModel": "[TODO: Translate] BM",
"tag": "[TODO: Translate] Tag"
},
"tagPlaceholder": "[TODO: Translate] character, style, realistic",
"summary": {
"tags": "[TODO: Translate] tags: {tags}"
"selectBaseModels": "[TODO: Translate] Select base models…",
"enterTags": "[TODO: Translate] Enter tags…",
"more": "[TODO: Translate] +{count} more"
},
"types": {
"loras": "[TODO: Translate] LoRA",
+9 -2
View File
@@ -501,7 +501,8 @@
"description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "[TODO: Translate] Default: {root} — unmatched downloads go here",
"defaultSuffix": "[TODO: Translate] default",
"addRule": "[TODO: Translate] Add rule",
"addBaseModelRule": "[TODO: Translate] Base model rule",
"addTagRule": "[TODO: Translate] Tag rule",
"removeRule": "[TODO: Translate] Remove rule",
"moveUp": "[TODO: Translate] Move up",
"moveDown": "[TODO: Translate] Move down",
@@ -510,9 +511,15 @@
"baseModel": "[TODO: Translate] Base model",
"tag": "[TODO: Translate] Tag"
},
"matchTypeChips": {
"baseModel": "[TODO: Translate] BM",
"tag": "[TODO: Translate] Tag"
},
"tagPlaceholder": "[TODO: Translate] character, style, realistic",
"summary": {
"tags": "[TODO: Translate] tags: {tags}"
"selectBaseModels": "[TODO: Translate] Select base models…",
"enterTags": "[TODO: Translate] Enter tags…",
"more": "[TODO: Translate] +{count} more"
},
"types": {
"loras": "[TODO: Translate] LoRA",
+9 -2
View File
@@ -501,7 +501,8 @@
"description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "[TODO: Translate] Default: {root} — unmatched downloads go here",
"defaultSuffix": "[TODO: Translate] default",
"addRule": "[TODO: Translate] Add rule",
"addBaseModelRule": "[TODO: Translate] Base model rule",
"addTagRule": "[TODO: Translate] Tag rule",
"removeRule": "[TODO: Translate] Remove rule",
"moveUp": "[TODO: Translate] Move up",
"moveDown": "[TODO: Translate] Move down",
@@ -510,9 +511,15 @@
"baseModel": "[TODO: Translate] Base model",
"tag": "[TODO: Translate] Tag"
},
"matchTypeChips": {
"baseModel": "[TODO: Translate] BM",
"tag": "[TODO: Translate] Tag"
},
"tagPlaceholder": "[TODO: Translate] character, style, realistic",
"summary": {
"tags": "[TODO: Translate] tags: {tags}"
"selectBaseModels": "[TODO: Translate] Select base models…",
"enterTags": "[TODO: Translate] Enter tags…",
"more": "[TODO: Translate] +{count} more"
},
"types": {
"loras": "[TODO: Translate] LoRA",
+9 -2
View File
@@ -501,7 +501,8 @@
"description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "[TODO: Translate] Default: {root} — unmatched downloads go here",
"defaultSuffix": "[TODO: Translate] default",
"addRule": "[TODO: Translate] Add rule",
"addBaseModelRule": "[TODO: Translate] Base model rule",
"addTagRule": "[TODO: Translate] Tag rule",
"removeRule": "[TODO: Translate] Remove rule",
"moveUp": "[TODO: Translate] Move up",
"moveDown": "[TODO: Translate] Move down",
@@ -510,9 +511,15 @@
"baseModel": "[TODO: Translate] Base model",
"tag": "[TODO: Translate] Tag"
},
"matchTypeChips": {
"baseModel": "[TODO: Translate] BM",
"tag": "[TODO: Translate] Tag"
},
"tagPlaceholder": "[TODO: Translate] character, style, realistic",
"summary": {
"tags": "[TODO: Translate] tags: {tags}"
"selectBaseModels": "[TODO: Translate] Select base models…",
"enterTags": "[TODO: Translate] Enter tags…",
"more": "[TODO: Translate] +{count} more"
},
"types": {
"loras": "[TODO: Translate] LoRA",
+9 -2
View File
@@ -501,7 +501,8 @@
"description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "[TODO: Translate] Default: {root} — unmatched downloads go here",
"defaultSuffix": "[TODO: Translate] default",
"addRule": "[TODO: Translate] Add rule",
"addBaseModelRule": "[TODO: Translate] Base model rule",
"addTagRule": "[TODO: Translate] Tag rule",
"removeRule": "[TODO: Translate] Remove rule",
"moveUp": "[TODO: Translate] Move up",
"moveDown": "[TODO: Translate] Move down",
@@ -510,9 +511,15 @@
"baseModel": "[TODO: Translate] Base model",
"tag": "[TODO: Translate] Tag"
},
"matchTypeChips": {
"baseModel": "[TODO: Translate] BM",
"tag": "[TODO: Translate] Tag"
},
"tagPlaceholder": "[TODO: Translate] character, style, realistic",
"summary": {
"tags": "[TODO: Translate] tags: {tags}"
"selectBaseModels": "[TODO: Translate] Select base models…",
"enterTags": "[TODO: Translate] Enter tags…",
"more": "[TODO: Translate] +{count} more"
},
"types": {
"loras": "[TODO: Translate] LoRA",
+31 -8
View File
@@ -695,21 +695,44 @@
margin-top: var(--space-1);
}
.download-root-rule-match-type {
padding: 6px 10px;
.download-root-rule-match-chip {
flex-shrink: 0;
padding: 2px 8px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
font-size: 0.9em;
height: 32px;
flex-shrink: 0;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
opacity: 0.8;
align-self: center;
cursor: default;
}
.download-root-rule-match-type:focus {
.download-root-rule-match-chip.is-tag {
border-color: var(--lora-accent);
outline: none;
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
color: var(--lora-accent);
}
.download-root-rule-chevron {
flex-shrink: 0;
opacity: 0.75;
transition: transform 0.15s ease;
}
.download-root-rule-toggle[aria-expanded="true"] .download-root-rule-chevron {
transform: rotate(180deg);
}
.download-root-rule-add-row {
display: flex;
gap: var(--space-1);
}
.download-root-rule-add-row .add-mapping-btn {
flex: 1;
}
.download-root-rule-tag-input {
+63 -74
View File
@@ -4573,13 +4573,21 @@ export class SettingsManager {
group.appendChild(this.buildDownloadRootRuleRow(typeKey, draft, rules.length, true, roots));
}
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'add-mapping-btn';
addBtn.disabled = !!draft;
addBtn.innerHTML = `<i class="fas fa-plus"></i><span>${translate('settings.downloadRootRules.addRule', {}, 'Add rule')}</span>`;
addBtn.addEventListener('click', () => this.addDownloadRootRule(typeKey));
group.appendChild(addBtn);
const addRow = document.createElement('div');
addRow.className = 'download-root-rule-add-row';
[
['base_model', 'settings.downloadRootRules.addBaseModelRule', 'Base model rule'],
['tag', 'settings.downloadRootRules.addTagRule', 'Tag rule'],
].forEach(([matchType, i18nKey, fallback]) => {
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'add-mapping-btn';
addBtn.disabled = !!draft;
addBtn.innerHTML = `<i class="fas fa-plus"></i><span>${translate(i18nKey, {}, fallback)}</span>`;
addBtn.addEventListener('click', () => this.addDownloadRootRule(typeKey, matchType));
addRow.appendChild(addBtn);
});
group.appendChild(addRow);
return group;
}
@@ -4596,37 +4604,34 @@ export class SettingsManager {
const controls = document.createElement('div');
controls.className = 'mapping-controls download-root-rule-controls';
const matchTypeSelect = document.createElement('select');
matchTypeSelect.className = 'download-root-rule-match-type';
[
['base_model', 'settings.downloadRootRules.matchTypes.baseModel', 'Base model'],
['tag', 'settings.downloadRootRules.matchTypes.tag', 'Tag'],
].forEach(([value, i18nKey, fallback]) => {
const option = document.createElement('option');
option.value = value;
option.textContent = translate(i18nKey, {}, fallback);
matchTypeSelect.appendChild(option);
});
matchTypeSelect.value = rule.match_type === 'tag' ? 'tag' : 'base_model';
matchTypeSelect.addEventListener('change', () => {
this.setDownloadRootRuleMatchType(typeKey, index, matchTypeSelect.value, isDraft);
});
controls.appendChild(matchTypeSelect);
// Static match-type chip: the type is fixed at creation time (the
// two per-group add buttons); changing type means recreating the rule.
const isTagRule = rule.match_type === 'tag';
const chip = document.createElement('span');
chip.className = `download-root-rule-match-chip${isTagRule ? ' is-tag' : ''}`;
chip.textContent = isTagRule
? translate('settings.downloadRootRules.matchTypeChips.tag', {}, 'Tag')
: translate('settings.downloadRootRules.matchTypeChips.baseModel', {}, 'BM');
chip.title = isTagRule
? translate('settings.downloadRootRules.matchTypes.tag', {}, 'Tag')
: translate('settings.downloadRootRules.matchTypes.baseModel', {}, 'Base model');
controls.appendChild(chip);
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'secondary-btn base-model-skip-toggle download-root-rule-toggle';
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
toggle.title = isOpen
? translate('settings.downloadSkipBaseModels.actions.collapse', {}, 'Collapse')
: translate('settings.downloadSkipBaseModels.actions.edit', {}, 'Edit');
const summary = document.createElement('span');
summary.className = 'download-root-rule-summary';
this.renderDownloadRootRuleSummary(summary, rule);
const toggleLabel = document.createElement('span');
toggleLabel.className = 'base-model-skip-toggle-label';
toggleLabel.textContent = isOpen
? translate('settings.downloadSkipBaseModels.actions.collapse', {}, 'Collapse')
: translate('settings.downloadSkipBaseModels.actions.edit', {}, 'Edit');
const chevron = document.createElement('i');
chevron.className = 'fas fa-chevron-down download-root-rule-chevron';
chevron.setAttribute('aria-hidden', 'true');
toggle.appendChild(summary);
toggle.appendChild(toggleLabel);
toggle.appendChild(chevron);
toggle.addEventListener('click', () => this.toggleDownloadRootRulePanel(typeKey, index));
controls.appendChild(toggle);
@@ -4636,15 +4641,21 @@ export class SettingsManager {
roots.forEach((root) => {
const option = document.createElement('option');
option.value = root;
// Middle-truncate long paths for display; the option value keeps
// the full path and the select title shows the selected one.
const displayRoot = this.truncateMiddlePath(root);
option.textContent = this.isDownloadRootRuleDefaultRoot(root, defaultRoot)
? `${root} (${translate('settings.downloadRootRules.defaultSuffix', {}, 'default')})`
: root;
? `${displayRoot} (${translate('settings.downloadRootRules.defaultSuffix', {}, 'default')})`
: displayRoot;
option.title = root;
rootSelect.appendChild(option);
});
if (roots.includes(rule.root)) {
rootSelect.value = rule.root;
}
rootSelect.title = rootSelect.value;
rootSelect.addEventListener('change', () => {
rootSelect.title = rootSelect.value;
this.updateDownloadRootRuleRoot(typeKey, index, rootSelect.value, isDraft);
});
controls.appendChild(rootSelect);
@@ -4747,34 +4758,6 @@ export class SettingsManager {
});
}
setDownloadRootRuleMatchType(typeKey, index, matchType, isDraft) {
if (matchType !== 'base_model' && matchType !== 'tag') {
return;
}
if (isDraft) {
const draft = this.downloadRootRuleDrafts[typeKey];
if (!draft || draft.match_type === matchType) {
return;
}
draft.match_type = matchType;
this.renderDownloadRootRules();
return;
}
const rules = this.getDownloadRootRules();
const rule = rules[typeKey][index];
if (!rule || rule.match_type === matchType) {
return;
}
// Keep the match entries when switching: tags are free text, and
// non-whitelist leftovers in a base_model rule are filtered by the
// backend normalization on save.
rule.match_type = matchType;
this.downloadRootRuleOpenPanels.clear();
this.downloadRootRuleSearches = {};
this.renderDownloadRootRules();
this.persistDownloadRootRules();
}
commitDownloadRootRuleTags(typeKey, index, rawValue, isDraft) {
const tags = this.parseDownloadRootRuleTags(rawValue);
if (isDraft) {
@@ -4825,29 +4808,35 @@ export class SettingsManager {
this.persistDownloadRootRules();
}
// Middle-truncate a long path for compact display, preserving both ends
// (the tail is what actually disambiguates sibling roots).
truncateMiddlePath(path, head = 24, tail = 16) {
const text = String(path || '');
if (text.length <= head + tail + 1) {
return text;
}
return `${text.slice(0, head)}…${text.slice(-tail)}`;
}
renderDownloadRootRuleSummary(summaryEl, rule) {
const match = Array.isArray(rule?.match) ? rule.match : [];
if (rule?.match_type === 'tag' && match.length) {
summaryEl.textContent = translate(
'settings.downloadRootRules.summary.tags',
{ tags: match.join(', ') },
`tags: ${match.join(', ')}`
);
return;
}
if (!match.length) {
summaryEl.textContent = translate('settings.downloadSkipBaseModels.summary.none', {}, 'None selected');
// Only drafts can be empty; persisted rules always have matches.
summaryEl.textContent = rule?.match_type === 'tag'
? translate('settings.downloadRootRules.summary.enterTags', {}, 'Enter tags…')
: translate('settings.downloadRootRules.summary.selectBaseModels', {}, 'Select base models…');
return;
}
if (match.length <= 2) {
summaryEl.textContent = match.join(', ');
return;
}
summaryEl.textContent = translate(
'settings.downloadSkipBaseModels.summary.count',
{ count: match.length },
`${match.length} selected`
const more = translate(
'settings.downloadRootRules.summary.more',
{ count: match.length - 2 },
`+${match.length - 2} more`
);
summaryEl.textContent = `${match.slice(0, 2).join(', ')}, ${more}`;
}
renderDownloadRootRuleBaseModelList(listEl, typeKey, index, isDraft, summaryEl) {
@@ -4899,13 +4888,13 @@ export class SettingsManager {
}
}
addDownloadRootRule(typeKey) {
addDownloadRootRule(typeKey, matchType = 'base_model') {
if (this.downloadRootRuleDrafts[typeKey]) {
return;
}
const roots = this.downloadRootRuleRoots?.[typeKey] || [];
this.downloadRootRuleDrafts[typeKey] = {
match_type: 'base_model',
match_type: matchType === 'tag' ? 'tag' : 'base_model',
match: [],
root: roots[0] || '',
};
@@ -457,7 +457,7 @@ describe('SettingsManager download root rules tag matching', () => {
]);
});
it('renders a tag rule with the match-type select, summary and tag input', () => {
it('renders a tag rule with a static type chip, summary and tag input', () => {
appendDownloadRootRulesUi();
state.global.settings.download_root_rules = {
loras: [{ match_type: 'tag', match: ['character', 'style'], root: '/models/lora-archive' }],
@@ -470,16 +470,20 @@ describe('SettingsManager download root rules tag matching', () => {
manager.renderDownloadRootRules();
const row = groupRows('loras')[0];
expect(row.querySelector('.download-root-rule-match-type').value).toBe('tag');
// The match type is fixed at creation: a chip, not a dropdown.
const chip = row.querySelector('.download-root-rule-match-chip');
expect(chip.textContent).toBe('Tag');
expect(chip.title).toBe('Tag');
expect(row.querySelector('.download-root-rule-match-type')).toBeNull();
expect(row.querySelector('.download-root-rule-summary').textContent)
.toBe('tags: character, style');
.toBe('character, style');
expect(row.querySelector('.download-root-rule-tag-input').value)
.toBe('character, style');
// No base model checkbox list for tag rules
expect(row.querySelector('.base-model-skip-list')).toBeNull();
});
it('switching a persisted rule to tag persists immediately and keeps the match list', async () => {
it('renders a base model rule chip with the full name as tooltip', () => {
appendDownloadRootRulesUi();
state.global.settings.download_root_rules = {
loras: [{ match_type: 'base_model', match: ['Flux.1 D'], root: '/models/lora-archive' }],
@@ -488,33 +492,20 @@ describe('SettingsManager download root rules tag matching', () => {
embeddings: [],
};
const manager = createManager();
manager.renderDownloadRootRules();
const select = groupRows('loras')[0].querySelector('.download-root-rule-match-type');
select.value = 'tag';
select.dispatchEvent(new Event('change'));
await vi.waitFor(() => {
expect(manager.saveSetting).toHaveBeenCalled();
});
expect(manager.saveSetting).toHaveBeenCalledWith('download_root_rules', {
loras: [{ match_type: 'tag', match: ['Flux.1 D'], root: '/models/lora-archive' }],
checkpoints: [],
unet: [],
embeddings: [],
});
expect(groupRows('loras')[0].querySelector('.download-root-rule-tag-input')).not.toBeNull();
const chip = groupRows('loras')[0].querySelector('.download-root-rule-match-chip');
expect(chip.textContent).toBe('BM');
expect(chip.title).toBe('Base model');
});
it('a draft switched to tag promotes on the first committed tag input', async () => {
it('a tag draft promotes on the first committed tag input', async () => {
appendDownloadRootRulesUi();
const manager = createManager();
manager.renderDownloadRootRules();
manager.addDownloadRootRule('loras');
const select = groupRows('loras')[0].querySelector('.download-root-rule-match-type');
select.value = 'tag';
select.dispatchEvent(new Event('change'));
manager.addDownloadRootRule('loras', 'tag');
// Still a draft: nothing persisted yet
expect(manager.saveSetting).not.toHaveBeenCalled();
@@ -588,3 +579,168 @@ describe('SettingsManager download root rules tag matching', () => {
});
});
});
describe('SettingsManager download root rules row layout', () => {
beforeEach(() => {
document.body.innerHTML = '';
vi.clearAllMocks();
state.global.settings = {
download_root_rules: { loras: [], checkpoints: [], unet: [], embeddings: [] },
};
});
it('offers separate add buttons for base model and tag rules', () => {
appendDownloadRootRulesUi();
const manager = createManager();
manager.renderDownloadRootRules();
const buttons = document.querySelectorAll('.download-root-rule-group')[0]
.querySelectorAll('.download-root-rule-add-row .add-mapping-btn');
expect(buttons).toHaveLength(2);
expect(buttons[0].textContent).toContain('Base model rule');
expect(buttons[1].textContent).toContain('Tag rule');
buttons[1].click();
expect(manager.downloadRootRuleDrafts.loras.match_type).toBe('tag');
// While a draft exists both buttons are disabled
const disabledButtons = document.querySelectorAll('.download-root-rule-group')[0]
.querySelectorAll('.download-root-rule-add-row .add-mapping-btn');
expect([...disabledButtons].every((btn) => btn.disabled)).toBe(true);
});
it('base model add button creates a base_model draft', () => {
appendDownloadRootRulesUi();
const manager = createManager();
manager.renderDownloadRootRules();
document.querySelectorAll('.download-root-rule-group')[0]
.querySelectorAll('.download-root-rule-add-row .add-mapping-btn')[0]
.click();
expect(manager.downloadRootRuleDrafts.loras.match_type).toBe('base_model');
expect(groupRows('loras')[0].querySelector('.download-root-rule-match-chip').textContent)
.toBe('BM');
});
it('the summary toggle uses a chevron icon with accessible title and aria state', () => {
appendDownloadRootRulesUi();
state.global.settings.download_root_rules = {
loras: [{ match_type: 'base_model', match: ['Flux.1 D'], root: '/models/loras-main' }],
checkpoints: [],
unet: [],
embeddings: [],
};
const manager = createManager();
manager.renderDownloadRootRules();
const toggle = groupRows('loras')[0].querySelector('.download-root-rule-toggle');
expect(toggle.querySelector('.download-root-rule-chevron')).not.toBeNull();
expect(toggle.querySelector('.base-model-skip-toggle-label')).toBeNull();
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(toggle.title).toBe('Edit');
toggle.click();
const reopened = groupRows('loras')[0].querySelector('.download-root-rule-toggle');
expect(reopened.getAttribute('aria-expanded')).toBe('true');
expect(reopened.title).toBe('Collapse');
});
it('truncateMiddlePath keeps short paths intact and ellipsizes long ones', () => {
const manager = createManager();
expect(manager.truncateMiddlePath('/models/loras')).toBe('/models/loras');
const long = '/models/library/very/deeply/nested/folder/structure/loras';
const truncated = manager.truncateMiddlePath(long);
expect(truncated.startsWith(long.slice(0, 24))).toBe(true);
expect(truncated.endsWith(long.slice(-16))).toBe(true);
expect(truncated).toContain('…');
expect(truncated.length).toBe(24 + 1 + 16);
});
it('truncates long root paths in the dropdown but keeps full values and titles', () => {
appendDownloadRootRulesUi();
const longRoot = '/models/library/very/deeply/nested/folder/structure/loras';
const manager = createManager();
manager.downloadRootRuleRoots = {
loras: [longRoot],
checkpoints: [],
unet: [],
embeddings: [],
};
state.global.settings.download_root_rules = {
loras: [{ match_type: 'base_model', match: ['Flux.1 D'], root: longRoot }],
checkpoints: [],
unet: [],
embeddings: [],
};
manager.renderDownloadRootRules();
const rootSelect = groupRows('loras')[0].querySelector('select.download-root-rule-root');
expect(rootSelect.options[0].value).toBe(longRoot);
expect(rootSelect.options[0].title).toBe(longRoot);
expect(rootSelect.options[0].textContent).toBe(manager.truncateMiddlePath(longRoot));
expect(rootSelect.title).toBe(longRoot);
});
it('keeps the (default) suffix after the truncated path', () => {
appendDownloadRootRulesUi();
const longRoot = '/models/library/very/deeply/nested/folder/structure/loras';
const manager = createManager();
manager.downloadRootRuleRoots = {
loras: [longRoot],
checkpoints: [],
unet: [],
embeddings: [],
};
state.global.settings.default_lora_root = longRoot;
state.global.settings.download_root_rules = {
loras: [{ match_type: 'base_model', match: ['Flux.1 D'], root: longRoot }],
checkpoints: [],
unet: [],
embeddings: [],
};
manager.renderDownloadRootRules();
const option = groupRows('loras')[0]
.querySelector('select.download-root-rule-root').options[0];
expect(option.textContent).toBe(`${manager.truncateMiddlePath(longRoot)} (default)`);
});
it('shows actionable placeholder summaries for empty drafts', () => {
appendDownloadRootRulesUi();
const manager = createManager();
manager.renderDownloadRootRules();
manager.addDownloadRootRule('loras', 'base_model');
expect(groupRows('loras')[0].querySelector('.download-root-rule-summary').textContent)
.toBe('Select base models…');
manager.removeDownloadRootRule('loras', 0, true);
manager.addDownloadRootRule('loras', 'tag');
expect(groupRows('loras')[0].querySelector('.download-root-rule-summary').textContent)
.toBe('Enter tags…');
});
it('summarizes more than two matches as first two plus a count', () => {
appendDownloadRootRulesUi();
state.global.settings.download_root_rules = {
loras: [
{ match_type: 'base_model', match: ['Flux.1 D', 'Wan Video', 'Pony', 'SDXL 1.0'], root: '/models/loras-main' },
],
checkpoints: [],
unet: [],
embeddings: [],
};
const manager = createManager();
manager.renderDownloadRootRules();
expect(groupRows('loras')[0].querySelector('.download-root-rule-summary').textContent)
.toBe('Flux.1 D, Wan Video, +2 more');
});
});