mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-06 14:10:13 -03:00
Compare commits
5 Commits
99e65cccd8
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2228627ff4 | ||
|
|
4c647ad9c8 | ||
|
|
8ca3e6c33f | ||
|
|
dd6bdbf297 | ||
|
|
b47dde87e4 |
File diff suppressed because it is too large
Load Diff
@@ -1562,6 +1562,11 @@ class SettingsHandler:
|
||||
{"success": False, "error": validation_error}
|
||||
)
|
||||
|
||||
if key == "update_channel" and value not in ("release", "nightly"):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "update_channel must be 'release' or 'nightly'"}
|
||||
)
|
||||
|
||||
if value == "__DELETE__" and key in (
|
||||
"proxy_username",
|
||||
"proxy_password",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.1.9"
|
||||
version = "1.2.0"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -27,6 +27,8 @@ export class BulkManager {
|
||||
|
||||
// Drag detection properties
|
||||
this.dragThreshold = 5; // Pixels to move before considering it a drag
|
||||
this.dragDelayMs = 100; // Minimum hold time before a drag is treated as a marquee
|
||||
this.minMarqueeSize = 10; // Minimum drag box (px) before a marquee counts as a selection
|
||||
this.mouseDownTime = 0;
|
||||
this.mouseDownPosition = { x: 0, y: 0 };
|
||||
|
||||
@@ -173,6 +175,19 @@ export class BulkManager {
|
||||
});
|
||||
|
||||
eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
|
||||
// Only track marquee/drag while the left button is physically held.
|
||||
// mouseup can be missed (release outside the window, focus loss, driver quirks),
|
||||
// so mousemove must verify the button state itself instead of relying on it.
|
||||
if (!(e.buttons & 1)) {
|
||||
if (this.isMarqueeActive) {
|
||||
this.endMarqueeSelection(e);
|
||||
} else {
|
||||
this.mouseDownTime = 0;
|
||||
this.isDragging = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isMarqueeActive) {
|
||||
this.lastClientX = e.clientX;
|
||||
this.lastClientY = e.clientY;
|
||||
@@ -184,7 +199,10 @@ export class BulkManager {
|
||||
const dy = e.clientY - this.mouseDownPosition.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance >= this.dragThreshold) {
|
||||
// Require both enough movement AND enough hold time so quick
|
||||
// click jitter from micro-movement input devices is not a marquee.
|
||||
const heldTime = Date.now() - this.mouseDownTime;
|
||||
if (heldTime >= this.dragDelayMs && distance >= this.dragThreshold) {
|
||||
this.isDragging = true;
|
||||
this.startMarqueeSelection(e, true);
|
||||
}
|
||||
@@ -1958,9 +1976,31 @@ export class BulkManager {
|
||||
// Remove visual feedback class
|
||||
document.body.classList.remove('marquee-selecting');
|
||||
|
||||
// Compute the actual drag box size in document coordinates, matching how
|
||||
// updateMarqueeSelectionFromPosition tracks the rectangle. Client-space
|
||||
// size would wrongly flag auto-scroll marquees (tiny pointer movement,
|
||||
// large document-space box) as accidental clicks.
|
||||
const container = document.querySelector('.page-content');
|
||||
const scrollX = container?.scrollLeft || 0;
|
||||
const scrollY = container?.scrollTop || 0;
|
||||
const dragWidth = Math.abs((e.clientX + scrollX) - this.marqueeStartDoc.x);
|
||||
const dragHeight = Math.abs((e.clientY + scrollY) - this.marqueeStartDoc.y);
|
||||
const isTinyMarquee = dragWidth < this.minMarqueeSize && dragHeight < this.minMarqueeSize;
|
||||
|
||||
// Get selection count
|
||||
const selectionCount = state.selectedModels.size;
|
||||
|
||||
// A tiny box (e.g. click jitter that happened to graze a card) is treated
|
||||
// as an accidental click: undo any selection and leave bulk mode.
|
||||
if (isTinyMarquee) {
|
||||
this.clearSelection();
|
||||
if (state.bulkMode) {
|
||||
this.toggleBulkMode();
|
||||
}
|
||||
this.initialSelectedModels.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// If no models were selected, exit bulk mode
|
||||
if (selectionCount === 0) {
|
||||
if (state.bulkMode) {
|
||||
|
||||
@@ -1517,11 +1517,20 @@ export class SettingsManager {
|
||||
return data;
|
||||
}
|
||||
|
||||
async loadLoraRoots() {
|
||||
try {
|
||||
const defaultLoraRootSelect = document.getElementById('defaultLoraRoot');
|
||||
if (!defaultLoraRootSelect) return;
|
||||
showNoRootsPlaceholder(select) {
|
||||
select.innerHTML = '';
|
||||
const option = document.createElement('option');
|
||||
option.value = '';
|
||||
option.textContent = translate('settings.folderSettings.noDefault', {}, 'No Default');
|
||||
select.appendChild(option);
|
||||
select.disabled = true;
|
||||
}
|
||||
|
||||
async loadLoraRoots() {
|
||||
const defaultLoraRootSelect = document.getElementById('defaultLoraRoot');
|
||||
if (!defaultLoraRootSelect) return;
|
||||
|
||||
try {
|
||||
// Fetch lora roots
|
||||
const response = await fetch('/api/lm/loras/roots');
|
||||
if (!response.ok) {
|
||||
@@ -1530,10 +1539,12 @@ export class SettingsManager {
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.roots || data.roots.length === 0) {
|
||||
throw new Error('No LoRA roots found');
|
||||
this.showNoRootsPlaceholder(defaultLoraRootSelect);
|
||||
return;
|
||||
}
|
||||
|
||||
defaultLoraRootSelect.innerHTML = '';
|
||||
defaultLoraRootSelect.disabled = false;
|
||||
|
||||
// Add options for each root
|
||||
data.roots.forEach(root => {
|
||||
@@ -1548,15 +1559,16 @@ export class SettingsManager {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading LoRA roots:', error);
|
||||
this.showNoRootsPlaceholder(defaultLoraRootSelect);
|
||||
showToast('toast.settings.loraRootsFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async loadCheckpointRoots() {
|
||||
try {
|
||||
const defaultCheckpointRootSelect = document.getElementById('defaultCheckpointRoot');
|
||||
if (!defaultCheckpointRootSelect) return;
|
||||
const defaultCheckpointRootSelect = document.getElementById('defaultCheckpointRoot');
|
||||
if (!defaultCheckpointRootSelect) return;
|
||||
|
||||
try {
|
||||
// Fetch checkpoint roots (checkpoint paths only, not unet)
|
||||
const response = await fetch('/api/lm/checkpoints/checkpoints_roots');
|
||||
if (!response.ok) {
|
||||
@@ -1565,10 +1577,12 @@ export class SettingsManager {
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.roots || data.roots.length === 0) {
|
||||
throw new Error('No checkpoint roots found');
|
||||
this.showNoRootsPlaceholder(defaultCheckpointRootSelect);
|
||||
return;
|
||||
}
|
||||
|
||||
defaultCheckpointRootSelect.innerHTML = '';
|
||||
defaultCheckpointRootSelect.disabled = false;
|
||||
|
||||
// Add options for each root
|
||||
data.roots.forEach(root => {
|
||||
@@ -1583,15 +1597,16 @@ export class SettingsManager {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading checkpoint roots:', error);
|
||||
this.showNoRootsPlaceholder(defaultCheckpointRootSelect);
|
||||
showToast('toast.settings.checkpointRootsFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async loadUnetRoots() {
|
||||
try {
|
||||
const defaultUnetRootSelect = document.getElementById('defaultUnetRoot');
|
||||
if (!defaultUnetRootSelect) return;
|
||||
const defaultUnetRootSelect = document.getElementById('defaultUnetRoot');
|
||||
if (!defaultUnetRootSelect) return;
|
||||
|
||||
try {
|
||||
// Fetch unet roots (diffusion model paths only)
|
||||
const response = await fetch('/api/lm/checkpoints/unet_roots');
|
||||
if (!response.ok) {
|
||||
@@ -1600,10 +1615,12 @@ export class SettingsManager {
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.roots || data.roots.length === 0) {
|
||||
throw new Error('No diffusion model roots found');
|
||||
this.showNoRootsPlaceholder(defaultUnetRootSelect);
|
||||
return;
|
||||
}
|
||||
|
||||
defaultUnetRootSelect.innerHTML = '';
|
||||
defaultUnetRootSelect.disabled = false;
|
||||
|
||||
// Add options for each root
|
||||
data.roots.forEach(root => {
|
||||
@@ -1618,15 +1635,16 @@ export class SettingsManager {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading diffusion model roots:', error);
|
||||
this.showNoRootsPlaceholder(defaultUnetRootSelect);
|
||||
showToast('toast.settings.unetRootsFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async loadEmbeddingRoots() {
|
||||
try {
|
||||
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
|
||||
if (!defaultEmbeddingRootSelect) return;
|
||||
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
|
||||
if (!defaultEmbeddingRootSelect) return;
|
||||
|
||||
try {
|
||||
// Fetch embedding roots
|
||||
const response = await fetch('/api/lm/embeddings/roots');
|
||||
if (!response.ok) {
|
||||
@@ -1635,10 +1653,12 @@ export class SettingsManager {
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.roots || data.roots.length === 0) {
|
||||
throw new Error('No embedding roots found');
|
||||
this.showNoRootsPlaceholder(defaultEmbeddingRootSelect);
|
||||
return;
|
||||
}
|
||||
|
||||
defaultEmbeddingRootSelect.innerHTML = '';
|
||||
defaultEmbeddingRootSelect.disabled = false;
|
||||
|
||||
// Add options for each root
|
||||
data.roots.forEach(root => {
|
||||
@@ -1653,6 +1673,7 @@ export class SettingsManager {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading embedding roots:', error);
|
||||
this.showNoRootsPlaceholder(defaultEmbeddingRootSelect);
|
||||
showToast('toast.settings.embeddingRootsFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import {
|
||||
getStorageItem,
|
||||
setStorageItem,
|
||||
getStoredVersionInfo,
|
||||
import {
|
||||
getStorageItem,
|
||||
setStorageItem,
|
||||
getStoredVersionInfo,
|
||||
setStoredVersionInfo,
|
||||
isVersionMatch
|
||||
} from '../utils/storageHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { bannerService } from './BannerService.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
|
||||
@@ -26,6 +27,8 @@ export class UpdateService {
|
||||
this.isUpdating = false;
|
||||
this.channelMode = null;
|
||||
this.hasGit = false;
|
||||
this.nightlyNotifyDate = getStorageItem('nightly_notify_date', '');
|
||||
this.nightlyBadgeShown = false;
|
||||
this.progressKeepVisible = false;
|
||||
this.currentVersionInfo = null;
|
||||
this.versionMismatch = false;
|
||||
@@ -59,9 +62,6 @@ export class UpdateService {
|
||||
|
||||
// Perform update check if needed
|
||||
this.checkVersionInfo().then(() => {
|
||||
if (this.channelMode === null) {
|
||||
this.channelMode = this.hasGit ? 'nightly' : 'release';
|
||||
}
|
||||
this.checkForUpdates().then(() => {
|
||||
this.updateBadgeVisibility();
|
||||
});
|
||||
@@ -118,6 +118,14 @@ export class UpdateService {
|
||||
|
||||
if (data.success) {
|
||||
this.channelMode = channel;
|
||||
// Persist channel preference to settings.json
|
||||
fetch('/api/lm/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ update_channel: channel })
|
||||
}).then(r => {
|
||||
if (!r.ok) console.warn('Failed to persist update channel:', r.status);
|
||||
}).catch(e => console.warn('Failed to persist update channel:', e));
|
||||
await this.checkForUpdates({ force: true });
|
||||
this.updateModalContent();
|
||||
this.updateChannelUI();
|
||||
@@ -154,6 +162,20 @@ export class UpdateService {
|
||||
}
|
||||
}
|
||||
|
||||
_resolveChannelFromSettings() {
|
||||
const stored = state?.global?.settings?.update_channel;
|
||||
if (stored === 'nightly' || stored === 'release') {
|
||||
return stored;
|
||||
}
|
||||
if (!this.hasGit) {
|
||||
return 'release';
|
||||
}
|
||||
if (this.gitInfo?.branch === 'detached') {
|
||||
return 'release';
|
||||
}
|
||||
return 'nightly';
|
||||
}
|
||||
|
||||
async _confirmChannelSwitch(titleKey, messageKey) {
|
||||
return new Promise((resolve) => {
|
||||
const title = translate(titleKey);
|
||||
@@ -475,6 +497,18 @@ export class UpdateService {
|
||||
}
|
||||
|
||||
async checkForUpdates({ force = false } = {}) {
|
||||
let needsMigration = false;
|
||||
if (this.channelMode === null) {
|
||||
const stored = state?.global?.settings?.update_channel;
|
||||
if (stored === 'nightly' || stored === 'release') {
|
||||
this.channelMode = stored;
|
||||
} else if (!this.hasGit) {
|
||||
this.channelMode = 'release';
|
||||
needsMigration = true;
|
||||
}
|
||||
// hasGit=true with no stored value: wait for gitInfo.branch
|
||||
}
|
||||
|
||||
if (!force && !this.updateNotificationsEnabled) {
|
||||
return;
|
||||
}
|
||||
@@ -493,7 +527,7 @@ export class UpdateService {
|
||||
|
||||
try {
|
||||
// Call backend API to check for updates with nightly flag
|
||||
const nightly = this.channelMode === 'nightly';
|
||||
const nightly = (this.channelMode ?? (this.hasGit ? 'nightly' : 'release')) === 'nightly';
|
||||
const response = await fetch(`/api/lm/check-updates?nightly=${nightly}`);
|
||||
const data = await response.json();
|
||||
|
||||
@@ -503,12 +537,28 @@ export class UpdateService {
|
||||
this.updateInfo = data;
|
||||
this.gitInfo = data.git_info || this.gitInfo;
|
||||
this.hasGit = data.has_git || false;
|
||||
if (this.channelMode === null) {
|
||||
this.channelMode = this.hasGit ? 'nightly' : 'release';
|
||||
|
||||
if (needsMigration || this.channelMode === null) {
|
||||
this.channelMode = this._resolveChannelFromSettings();
|
||||
if (state?.global?.settings) {
|
||||
state.global.settings.update_channel = this.channelMode;
|
||||
}
|
||||
fetch('/api/lm/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ update_channel: this.channelMode })
|
||||
}).then(r => {
|
||||
if (!r.ok) console.warn('Failed to persist update channel:', r.status);
|
||||
}).catch(e => console.warn('Failed to persist update channel:', e));
|
||||
}
|
||||
|
||||
this.updateAvailable = data.update_available;
|
||||
|
||||
// Nightly channel: surface the update badge at most once per calendar day.
|
||||
if (this.updateAvailable && this.channelMode === 'nightly' && this.nightlyNotifyDate !== this._getTodayKey()) {
|
||||
this._markNightlyNotified();
|
||||
}
|
||||
|
||||
this.lastCheckTime = now;
|
||||
setStorageItem('last_update_check', now.toString());
|
||||
|
||||
@@ -558,6 +608,28 @@ export class UpdateService {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
_getTodayKey() {
|
||||
const now = new Date();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
return `${now.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
|
||||
_isNightlyBadgeAllowed() {
|
||||
if (this.channelMode !== 'nightly') {
|
||||
return true;
|
||||
}
|
||||
// Keep the badge visible for the rest of the session once shown, but do
|
||||
// not show it again on later sessions within the same calendar day.
|
||||
return this.nightlyNotifyDate !== this._getTodayKey() || this.nightlyBadgeShown;
|
||||
}
|
||||
|
||||
_markNightlyNotified() {
|
||||
this.nightlyNotifyDate = this._getTodayKey();
|
||||
this.nightlyBadgeShown = true;
|
||||
setStorageItem('nightly_notify_date', this.nightlyNotifyDate);
|
||||
}
|
||||
|
||||
updateBadgeVisibility() {
|
||||
const updateToggle = document.querySelector('.update-toggle');
|
||||
@@ -566,9 +638,12 @@ export class UpdateService {
|
||||
? bannerService.getUnreadBannerCount()
|
||||
: 0;
|
||||
|
||||
// Force updating badges visibility based on current state
|
||||
const shouldShowUpdate = this.updateNotificationsEnabled && this.updateAvailable && this._isNightlyBadgeAllowed();
|
||||
|
||||
if (updateToggle) {
|
||||
let tooltipKey = 'header.actions.notifications';
|
||||
if (this.updateNotificationsEnabled && this.updateAvailable) {
|
||||
if (shouldShowUpdate) {
|
||||
tooltipKey = 'update.updateAvailable';
|
||||
} else if (unreadBanners > 0) {
|
||||
tooltipKey = 'update.tabs.messages';
|
||||
@@ -576,8 +651,6 @@ export class UpdateService {
|
||||
updateToggle.title = translate(tooltipKey);
|
||||
}
|
||||
|
||||
// Force updating badges visibility based on current state
|
||||
const shouldShowUpdate = this.updateNotificationsEnabled && this.updateAvailable;
|
||||
const shouldShow = shouldShowUpdate || unreadBanners > 0;
|
||||
|
||||
if (updateBadge) {
|
||||
|
||||
186
tests/frontend/managers/BulkManager.marquee.test.js
Normal file
186
tests/frontend/managers/BulkManager.marquee.test.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
import { state } from '../../../static/js/state/index.js';
|
||||
import { MODEL_TYPES } from '../../../static/js/api/apiConfig.js';
|
||||
import { eventManager } from '../../../static/js/utils/EventManager.js';
|
||||
import { BulkManager } from '../../../static/js/managers/BulkManager.js';
|
||||
|
||||
function fire(type, init = {}) {
|
||||
return new MouseEvent(type, { bubbles: true, cancelable: true, ...init });
|
||||
}
|
||||
|
||||
describe('BulkManager marquee guards', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// jsdom may not provide requestAnimationFrame; stub it so the auto-scroll loop is a no-op.
|
||||
window.requestAnimationFrame = vi.fn();
|
||||
window.cancelAnimationFrame = vi.fn();
|
||||
|
||||
eventManager.cleanup();
|
||||
state.currentPageType = MODEL_TYPES.LORA;
|
||||
state.bulkMode = false;
|
||||
state.selectedModels.clear();
|
||||
|
||||
document.body.innerHTML = '<div class="page-content"></div>';
|
||||
const pageContent = document.querySelector('.page-content');
|
||||
pageContent.getBoundingClientRect = () => ({
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 1000,
|
||||
bottom: 1000,
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
pageContent.scrollBy = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
eventManager.cleanup();
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
function createBulkManager() {
|
||||
const bulk = new BulkManager();
|
||||
bulk.initialize();
|
||||
return bulk;
|
||||
}
|
||||
|
||||
it('never starts a marquee when the left button is not held', () => {
|
||||
const bulk = createBulkManager();
|
||||
const pageContent = document.querySelector('.page-content');
|
||||
|
||||
pageContent.dispatchEvent(fire('mousedown', { button: 0, clientX: 10, clientY: 10 }));
|
||||
document.dispatchEvent(fire('mousemove', { buttons: 0, clientX: 50, clientY: 50 }));
|
||||
|
||||
expect(bulk.mouseDownTime).toBe(0);
|
||||
expect(bulk.isMarqueeActive).toBe(false);
|
||||
expect(state.bulkMode).toBe(false);
|
||||
expect(document.querySelector('.marquee-selection')).toBeNull();
|
||||
});
|
||||
|
||||
it('requires holding the left button for the drag delay before starting a marquee', () => {
|
||||
const bulk = createBulkManager();
|
||||
const pageContent = document.querySelector('.page-content');
|
||||
|
||||
pageContent.dispatchEvent(fire('mousedown', { button: 0, clientX: 10, clientY: 10 }));
|
||||
|
||||
// Fast movement: far enough, but too soon after mousedown.
|
||||
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 30, clientY: 10 }));
|
||||
expect(state.bulkMode).toBe(false);
|
||||
expect(bulk.isMarqueeActive).toBe(false);
|
||||
|
||||
// Once the hold time has elapsed, the same drag qualifies.
|
||||
vi.advanceTimersByTime(100);
|
||||
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 35, clientY: 12 }));
|
||||
expect(state.bulkMode).toBe(true);
|
||||
expect(bulk.isMarqueeActive).toBe(true);
|
||||
expect(document.querySelector('.marquee-selection')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('ends an active marquee if the left button is released without a mouseup event', () => {
|
||||
const bulk = createBulkManager();
|
||||
bulk.mouseDownPosition = { x: 10, y: 10 };
|
||||
bulk.startMarqueeSelection({}, true);
|
||||
expect(state.bulkMode).toBe(true);
|
||||
expect(document.querySelector('.marquee-selection')).not.toBeNull();
|
||||
|
||||
// No mouseup was dispatched; a plain move with the button released finalizes it.
|
||||
document.dispatchEvent(fire('mousemove', { buttons: 0, clientX: 50, clientY: 50 }));
|
||||
|
||||
expect(bulk.isMarqueeActive).toBe(false);
|
||||
expect(document.querySelector('.marquee-selection')).toBeNull();
|
||||
expect(state.bulkMode).toBe(false); // zero selected -> auto-exit
|
||||
});
|
||||
|
||||
it('treats a tiny marquee as an accidental click: clears selection and exits bulk mode', () => {
|
||||
const bulk = createBulkManager();
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card selected';
|
||||
card.dataset.filepath = '/models/test.safetensors';
|
||||
document.body.appendChild(card);
|
||||
state.selectedModels.add('/models/test.safetensors');
|
||||
|
||||
bulk.mouseDownPosition = { x: 100, y: 100 };
|
||||
bulk.startMarqueeSelection({}, true);
|
||||
expect(state.bulkMode).toBe(true);
|
||||
|
||||
bulk.endMarqueeSelection({ clientX: 103, clientY: 104 });
|
||||
|
||||
expect(state.bulkMode).toBe(false);
|
||||
expect(state.selectedModels.size).toBe(0);
|
||||
expect(card.classList.contains('selected')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps selection and bulk mode when the marquee is large enough', () => {
|
||||
const bulk = createBulkManager();
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card selected';
|
||||
card.dataset.filepath = '/models/test.safetensors';
|
||||
document.body.appendChild(card);
|
||||
state.selectedModels.add('/models/test.safetensors');
|
||||
|
||||
bulk.mouseDownPosition = { x: 100, y: 100 };
|
||||
bulk.startMarqueeSelection({}, true);
|
||||
|
||||
bulk.endMarqueeSelection({ clientX: 130, clientY: 140 });
|
||||
|
||||
expect(state.bulkMode).toBe(true);
|
||||
expect(state.selectedModels.has('/models/test.safetensors')).toBe(true);
|
||||
expect(card.classList.contains('selected')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps auto-scroll marquee selections when the pointer only moved a few pixels', () => {
|
||||
const bulk = createBulkManager();
|
||||
const pageContent = document.querySelector('.page-content');
|
||||
|
||||
// Card just below the press point in document coordinates.
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card';
|
||||
card.dataset.filepath = '/models/off-screen.safetensors';
|
||||
card.getBoundingClientRect = () => ({
|
||||
top: 950,
|
||||
left: 400,
|
||||
right: 600,
|
||||
bottom: 1050,
|
||||
width: 200,
|
||||
height: 100,
|
||||
x: 400,
|
||||
y: 950,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
document.body.appendChild(card);
|
||||
|
||||
pageContent.dispatchEvent(fire('mousedown', { button: 0, clientX: 500, clientY: 900 }));
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
// Small pointer move: enough to start the marquee, but under minMarqueeSize.
|
||||
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 506, clientY: 906 }));
|
||||
expect(bulk.isMarqueeActive).toBe(true);
|
||||
|
||||
// Auto-scroll grows the document-space box while the pointer stays nearly still.
|
||||
pageContent.scrollTop = 200;
|
||||
card.getBoundingClientRect = () => ({
|
||||
top: 750,
|
||||
left: 400,
|
||||
right: 600,
|
||||
bottom: 850,
|
||||
width: 200,
|
||||
height: 100,
|
||||
x: 400,
|
||||
y: 750,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 506, clientY: 906 }));
|
||||
|
||||
expect(state.selectedModels.has('/models/off-screen.safetensors')).toBe(true);
|
||||
|
||||
// Release: the client-space box is tiny, but the document-space box is not.
|
||||
document.dispatchEvent(fire('mouseup', { button: 0, clientX: 506, clientY: 906 }));
|
||||
|
||||
expect(state.selectedModels.has('/models/off-screen.safetensors')).toBe(true);
|
||||
expect(state.bulkMode).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -106,6 +106,118 @@ afterEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsManager root selects', () => {
|
||||
const rootCases = [
|
||||
{
|
||||
method: 'loadLoraRoots',
|
||||
selectId: 'defaultLoraRoot',
|
||||
endpoint: '/api/lm/loras/roots',
|
||||
errorKey: 'toast.settings.loraRootsFailed',
|
||||
},
|
||||
{
|
||||
method: 'loadCheckpointRoots',
|
||||
selectId: 'defaultCheckpointRoot',
|
||||
endpoint: '/api/lm/checkpoints/checkpoints_roots',
|
||||
errorKey: 'toast.settings.checkpointRootsFailed',
|
||||
},
|
||||
{
|
||||
method: 'loadUnetRoots',
|
||||
selectId: 'defaultUnetRoot',
|
||||
endpoint: '/api/lm/checkpoints/unet_roots',
|
||||
errorKey: 'toast.settings.unetRootsFailed',
|
||||
},
|
||||
{
|
||||
method: 'loadEmbeddingRoots',
|
||||
selectId: 'defaultEmbeddingRoot',
|
||||
endpoint: '/api/lm/embeddings/roots',
|
||||
errorKey: 'toast.settings.embeddingRootsFailed',
|
||||
},
|
||||
];
|
||||
|
||||
const appendRootSelect = (id) => {
|
||||
const select = document.createElement('select');
|
||||
select.id = id;
|
||||
document.body.appendChild(select);
|
||||
return select;
|
||||
};
|
||||
|
||||
it.each(rootCases)(
|
||||
'populates the $method select with roots and keeps it enabled',
|
||||
async ({ method, selectId, endpoint }) => {
|
||||
const manager = createManager();
|
||||
const select = appendRootSelect(selectId);
|
||||
select.disabled = true;
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
roots: ['/models/root-a', '/models/root-b'],
|
||||
}),
|
||||
});
|
||||
|
||||
await manager[method]();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(endpoint);
|
||||
expect(Array.from(select.options).map(option => option.value)).toEqual([
|
||||
'/models/root-a',
|
||||
'/models/root-b',
|
||||
]);
|
||||
expect(select.disabled).toBe(false);
|
||||
expect(showToast).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(rootCases)(
|
||||
'shows a placeholder and no error toast when $method has empty roots',
|
||||
async ({ method, selectId, endpoint }) => {
|
||||
const manager = createManager();
|
||||
const select = appendRootSelect(selectId);
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
roots: [],
|
||||
}),
|
||||
});
|
||||
|
||||
await manager[method]();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(endpoint);
|
||||
expect(select.options).toHaveLength(1);
|
||||
expect(select.options[0].value).toBe('');
|
||||
expect(select.options[0].textContent).toBe('No Default');
|
||||
expect(select.disabled).toBe(true);
|
||||
expect(showToast).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(rootCases)(
|
||||
'shows an error toast when the $method roots request fails',
|
||||
async ({ method, selectId, errorKey }) => {
|
||||
const manager = createManager();
|
||||
const select = appendRootSelect(selectId);
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
});
|
||||
|
||||
await manager[method]();
|
||||
|
||||
expect(select.options).toHaveLength(1);
|
||||
expect(select.options[0].value).toBe('');
|
||||
expect(select.disabled).toBe(true);
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
errorKey,
|
||||
expect.objectContaining({ message: expect.any(String) }),
|
||||
'error',
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('SettingsManager library controls', () => {
|
||||
it('loads libraries and populates the select', async () => {
|
||||
const manager = createManager();
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import { describe, beforeEach, afterEach, expect, it, vi } from 'vitest';
|
||||
import { UpdateService } from '../../../static/js/managers/UpdateService.js';
|
||||
import { state } from '../../../static/js/state/index.js';
|
||||
|
||||
function createFetchResponse(payload) {
|
||||
return {
|
||||
json: vi.fn().mockResolvedValue(payload)
|
||||
json: vi.fn().mockResolvedValue(payload),
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
|
||||
function stubSettingsUpdateChannel(channel) {
|
||||
state.global = state.global || {};
|
||||
state.global.settings = state.global.settings || {};
|
||||
state.global.settings.update_channel = channel;
|
||||
}
|
||||
|
||||
function clearSettingsUpdateChannel() {
|
||||
if (state.global?.settings) {
|
||||
delete state.global.settings.update_channel;
|
||||
}
|
||||
}
|
||||
|
||||
describe('UpdateService passive checks', () => {
|
||||
let service;
|
||||
let fetchMock;
|
||||
@@ -16,10 +30,13 @@ describe('UpdateService passive checks', () => {
|
||||
success: true,
|
||||
current_version: 'v1.0.0',
|
||||
latest_version: 'v1.0.0',
|
||||
git_info: { short_hash: 'abc123' }
|
||||
git_info: { short_hash: 'abc123' },
|
||||
has_git: true,
|
||||
}));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
stubSettingsUpdateChannel('release');
|
||||
|
||||
service = new UpdateService();
|
||||
service.updateNotificationsEnabled = false;
|
||||
service.lastCheckTime = 0;
|
||||
@@ -28,6 +45,7 @@ describe('UpdateService passive checks', () => {
|
||||
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
clearSettingsUpdateChannel();
|
||||
});
|
||||
|
||||
it('skips passive update checks when notifications are disabled', async () => {
|
||||
@@ -43,3 +61,106 @@ describe('UpdateService passive checks', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/lm/check-updates?nightly=false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateService nightly notification throttling', () => {
|
||||
let fetchMock;
|
||||
let updateToggle;
|
||||
let updateBadge;
|
||||
|
||||
function stubUpdateBadgeDom() {
|
||||
updateToggle = document.createElement('div');
|
||||
updateToggle.className = 'update-toggle';
|
||||
updateBadge = document.createElement('span');
|
||||
updateBadge.className = 'update-badge';
|
||||
updateToggle.appendChild(updateBadge);
|
||||
document.body.appendChild(updateToggle);
|
||||
|
||||
vi.spyOn(document, 'querySelector').mockImplementation((selector) => {
|
||||
if (selector === '.update-toggle') return updateToggle;
|
||||
if (selector === '.update-toggle .update-badge') return updateBadge;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function makeUpdateResponse(channel) {
|
||||
return {
|
||||
success: true,
|
||||
current_version: 'v1.0.0',
|
||||
latest_version: channel === 'nightly' ? 'main-abc1234' : 'v1.1.0',
|
||||
update_available: true,
|
||||
git_info: { short_hash: 'abc123' },
|
||||
has_git: true,
|
||||
nightly: channel === 'nightly',
|
||||
changelog: ['test: change'],
|
||||
releases: [],
|
||||
behind_by: 3,
|
||||
commit_date: '2026-07-31',
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn().mockResolvedValue(createFetchResponse(makeUpdateResponse('release')));
|
||||
global.fetch = fetchMock;
|
||||
stubUpdateBadgeDom();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
it('shows the nightly badge once and keeps it visible for the session', async () => {
|
||||
stubSettingsUpdateChannel('nightly');
|
||||
fetchMock.mockResolvedValue(createFetchResponse(makeUpdateResponse('nightly')));
|
||||
|
||||
const service = new UpdateService();
|
||||
service.updateNotificationsEnabled = true;
|
||||
|
||||
await service.checkForUpdates({ force: true });
|
||||
|
||||
expect(service.updateAvailable).toBe(true);
|
||||
expect(service.nightlyBadgeShown).toBe(true);
|
||||
expect(service.nightlyNotifyDate).toBe(service._getTodayKey());
|
||||
expect(updateBadge.classList.contains('visible')).toBe(true);
|
||||
|
||||
// A repeated check within the same session keeps the badge visible.
|
||||
await service.checkForUpdates({ force: true });
|
||||
expect(updateBadge.classList.contains('visible')).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses the nightly badge on a later session in the same day', async () => {
|
||||
stubSettingsUpdateChannel('nightly');
|
||||
fetchMock.mockResolvedValue(createFetchResponse(makeUpdateResponse('nightly')));
|
||||
|
||||
const firstService = new UpdateService();
|
||||
firstService.updateNotificationsEnabled = true;
|
||||
await firstService.checkForUpdates({ force: true });
|
||||
expect(updateBadge.classList.contains('visible')).toBe(true);
|
||||
|
||||
// Simulate a fresh page session on the same calendar day.
|
||||
const secondService = new UpdateService();
|
||||
secondService.updateNotificationsEnabled = true;
|
||||
await secondService.checkForUpdates({ force: true });
|
||||
|
||||
expect(secondService.updateAvailable).toBe(true);
|
||||
expect(secondService.nightlyBadgeShown).toBe(false);
|
||||
expect(updateBadge.classList.contains('visible')).toBe(false);
|
||||
});
|
||||
|
||||
it('is not affected by the daily limit on the release channel', async () => {
|
||||
stubSettingsUpdateChannel('release');
|
||||
fetchMock.mockResolvedValue(createFetchResponse(makeUpdateResponse('release')));
|
||||
|
||||
const firstService = new UpdateService();
|
||||
firstService.updateNotificationsEnabled = true;
|
||||
await firstService.checkForUpdates({ force: true });
|
||||
expect(updateBadge.classList.contains('visible')).toBe(true);
|
||||
|
||||
const secondService = new UpdateService();
|
||||
secondService.updateNotificationsEnabled = true;
|
||||
await secondService.checkForUpdates({ force: true });
|
||||
|
||||
expect(secondService.updateAvailable).toBe(true);
|
||||
expect(updateBadge.classList.contains('visible')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user