diff --git a/static/css/components/toast.css b/static/css/components/toast.css
index 229d77c4..fbf0732f 100644
--- a/static/css/components/toast.css
+++ b/static/css/components/toast.css
@@ -80,6 +80,33 @@
margin-top: 10px;
}
+/* Action toast: ghost action button + countdown (e.g. Undo delete) */
+.toast-action-btn {
+ margin-left: auto;
+ flex-shrink: 0;
+ padding: 4px 12px;
+ background: transparent;
+ color: var(--lora-accent);
+ border: 1px solid var(--lora-accent);
+ border-radius: var(--border-radius-sm);
+ font-size: 0.85em;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s ease, color 0.2s ease;
+}
+
+.toast-action-btn:hover {
+ background: var(--lora-accent);
+ color: #fff;
+}
+
+.toast-countdown {
+ flex-shrink: 0;
+ font-size: 0.75em;
+ opacity: 0.65;
+ white-space: nowrap;
+}
+
/* Responsive adjustments */
@media (max-width: 768px) {
.toast {
diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js
index b119b950..e9f561b2 100644
--- a/static/js/api/baseModelApi.js
+++ b/static/js/api/baseModelApi.js
@@ -201,8 +201,13 @@ export class BaseModelApiClient {
if (state.virtualScroller) {
state.virtualScroller.removeItemByFilePath(filePath);
}
- showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success');
- return true;
+ const batchId = data.batch_id || null;
+ if (!batchId) {
+ // Not staged (undo disabled or staging failed): keep the legacy toast.
+ // When staged, the caller shows the undo action toast instead.
+ showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success');
+ }
+ return { success: true, batch_id: batchId };
} else {
throw new Error(data.error || `Failed to delete ${this.apiConfig.config.singularName}`);
}
@@ -1622,9 +1627,14 @@ export class BaseModelApiClient {
if (result.success) {
return {
success: true,
- deleted_count: result.deleted_count,
+ deleted_count: result.deleted_count ?? result.total_deleted,
failed_count: result.failed_count || 0,
- errors: result.errors || []
+ errors: result.errors || [],
+ // Undo batch fields — batch_id on merge success, batch_ids
+ // array on merge failure (same success dict for the
+ // status='cancelled' staged-subset path)
+ batch_id: result.batch_id || null,
+ batch_ids: result.batch_ids || null
};
} else {
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js
index 70a2951e..215838c9 100644
--- a/static/js/api/recipeApi.js
+++ b/static/js/api/recipeApi.js
@@ -657,6 +657,10 @@ export class RecipeSidebarApiClient {
deleted_count: result.total_deleted,
failed_count: result.total_failed || 0,
errors: result.failed || [],
+ // Undo batch fields — batch_id on merge success, batch_ids
+ // array on merge failure
+ batch_id: result.batch_id || null,
+ batch_ids: result.batch_ids || null,
};
} finally {
state.loadingManager?.hide();
diff --git a/static/js/components/DuplicatesManager.js b/static/js/components/DuplicatesManager.js
index b17e124f..32acc2fb 100644
--- a/static/js/components/DuplicatesManager.js
+++ b/static/js/components/DuplicatesManager.js
@@ -1,5 +1,7 @@
// Duplicates Manager Component
-import { showToast } from '../utils/uiHelpers.js';
+import { showToast, showActionToast } from '../utils/uiHelpers.js';
+import { handleUndoDelete } from '../utils/undoHelpers.js';
+import { armDeleteButton } from '../utils/modalUtils.js';
import { translate } from '../utils/i18nHelpers.js';
import { RecipeCard } from './RecipeCard.js';
import { state, getCurrentPageState } from '../state/index.js';
@@ -447,6 +449,7 @@ export class DuplicatesManager {
// Use the modal manager to show the confirmation modal
modalManager.showModal('duplicateDeleteModal');
+ armDeleteButton(document.getElementById('duplicateDeleteModal'));
} catch (error) {
console.error('Error preparing delete:', error);
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
@@ -479,8 +482,34 @@ export class DuplicatesManager {
if (!data.success) {
throw new Error(data.error || 'Unknown error deleting recipes');
}
-
- showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: 'recipes' }, 'success');
+
+ const batchIds = !data.batch_id && Array.isArray(data.batch_ids) && data.batch_ids.length
+ ? data.batch_ids
+ : null;
+
+ if (data.batch_id || batchIds) {
+ // One undo action restores the whole selected group
+ const refreshFn = () => window.recipeManager.loadRecipes(true);
+ const onAction = data.batch_id
+ ? () => handleUndoDelete(data.batch_id, refreshFn)
+ : async () => {
+ for (const id of batchIds) {
+ const succeeded = await handleUndoDelete(id, null, { showToast: false, refresh: false });
+ if (!succeeded) {
+ showToast('toast.undo.failed', { error: '' }, 'error');
+ return;
+ }
+ }
+ refreshFn();
+ showToast('toast.undo.restored', {}, 'success');
+ };
+ showActionToast('toast.undo.deletedBulk', { count: data.total_deleted }, 'success', {
+ actionText: translate('toast.undo.action'),
+ onAction,
+ });
+ } else {
+ showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: 'recipes' }, 'success');
+ }
// Exit duplicate mode if deletions were successful
if (data.total_deleted > 0) {
diff --git a/static/js/components/ModelDuplicatesManager.js b/static/js/components/ModelDuplicatesManager.js
index 7c0f7ee2..ebe21a7e 100644
--- a/static/js/components/ModelDuplicatesManager.js
+++ b/static/js/components/ModelDuplicatesManager.js
@@ -1,5 +1,8 @@
// Model Duplicates Manager Component for LoRAs and Checkpoints
-import { showToast } from '../utils/uiHelpers.js';
+import { showToast, showActionToast } from '../utils/uiHelpers.js';
+import { handleUndoDelete } from '../utils/undoHelpers.js';
+import { armDeleteButton } from '../utils/modalUtils.js';
+import { translate } from '../utils/i18nHelpers.js';
import { state, getCurrentPageState } from '../state/index.js';
import { formatDate } from '../utils/formatters.js';
import { resetAndReload} from '../api/modelApiFactory.js';
@@ -700,6 +703,7 @@ export class ModelDuplicatesManager {
// Use the modal manager to show the confirmation modal
modalManager.showModal('modelDuplicateDeleteModal');
+ armDeleteButton(document.getElementById('modelDuplicateDeleteModal'));
} catch (error) {
console.error('Error preparing delete:', error);
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
@@ -732,8 +736,34 @@ export class ModelDuplicatesManager {
if (!data.success) {
throw new Error(data.error || 'Unknown error deleting models');
}
-
- showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: this.modelType }, 'success');
+
+ const batchIds = !data.batch_id && Array.isArray(data.batch_ids) && data.batch_ids.length
+ ? data.batch_ids
+ : null;
+
+ if (data.batch_id || batchIds) {
+ // One undo action restores the whole selected group
+ const refreshFn = () => resetAndReload(true);
+ const onAction = data.batch_id
+ ? () => handleUndoDelete(data.batch_id, refreshFn)
+ : async () => {
+ for (const id of batchIds) {
+ const succeeded = await handleUndoDelete(id, null, { showToast: false, refresh: false });
+ if (!succeeded) {
+ showToast('toast.undo.failed', { error: '' }, 'error');
+ return;
+ }
+ }
+ refreshFn();
+ showToast('toast.undo.restored', {}, 'success');
+ };
+ showActionToast('toast.undo.deletedBulk', { count: data.total_deleted }, 'success', {
+ actionText: translate('toast.undo.action'),
+ onAction,
+ });
+ } else {
+ showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: this.modelType }, 'success');
+ }
// If models were successfully deleted
if (data.total_deleted > 0) {
diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js
index cd1617bf..780c861d 100644
--- a/static/js/components/RecipeCard.js
+++ b/static/js/components/RecipeCard.js
@@ -1,5 +1,5 @@
// Recipe Card Component
-import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
+import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { updateRecipeMetadata } from '../api/recipeApi.js';
import { configureModelCardVideo } from './shared/ModelCard.js';
import { modalManager } from '../managers/ModalManager.js';
@@ -7,6 +7,9 @@ import { getCurrentPageState } from '../state/index.js';
import { state } from '../state/index.js';
import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js';
+import { translate } from '../utils/i18nHelpers.js';
+import { handleUndoDelete } from '../utils/undoHelpers.js';
+import { armDeleteButton } from '../utils/modalUtils.js';
class RecipeCard {
constructor(recipe, clickHandler) {
@@ -375,8 +378,13 @@ class RecipeCard {
`;
// Show the modal with custom content and setup callbacks
+ let deleteArmTimer = null;
modalManager.showModal('deleteModal', deleteModalContent, () => {
// This is the onClose callback
+ if (deleteArmTimer) {
+ clearTimeout(deleteArmTimer);
+ deleteArmTimer = null;
+ }
const deleteModal = document.getElementById('deleteModal');
const deleteBtn = deleteModal.querySelector('.delete-btn');
deleteBtn.textContent = 'Delete';
@@ -396,6 +404,8 @@ class RecipeCard {
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
deleteBtn.onclick = () => this.confirmDeleteRecipe();
+ deleteArmTimer = armDeleteButton(deleteModal);
+
} catch (error) {
console.error('Error showing delete confirmation:', error);
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
@@ -432,7 +442,16 @@ class RecipeCard {
return response.json();
})
.then(data => {
- showToast('toast.recipes.deletedSuccessfully', {}, 'success');
+ if (data.batch_id) {
+ // Staged delete: offer undo instead of the plain success toast
+ const batchId = data.batch_id;
+ showActionToast('toast.undo.deleted', { name: this.recipe.title }, 'success', {
+ actionText: translate('toast.undo.action'),
+ onAction: () => handleUndoDelete(batchId, () => window.recipeManager.loadRecipes(true)),
+ });
+ } else {
+ showToast('toast.recipes.deletedSuccessfully', {}, 'success');
+ }
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js
index 12e2dc8f..ca9c94fe 100644
--- a/static/js/managers/BulkManager.js
+++ b/static/js/managers/BulkManager.js
@@ -1,5 +1,7 @@
import { state, getCurrentPageState } from '../state/index.js';
-import { showToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js';
+import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js';
+import { handleUndoDelete } from '../utils/undoHelpers.js';
+import { armDeleteButton } from '../utils/modalUtils.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
@@ -628,6 +630,7 @@ export class BulkManager {
}
modalManager.showModal('bulkDeleteModal');
+ armDeleteButton(document.getElementById('bulkDeleteModal'));
}
async confirmBulkDelete() {
@@ -649,10 +652,38 @@ export class BulkManager {
showToast('toast.api.operationCancelled', {}, 'info');
} else if (result.success) {
const currentConfig = this.getCurrentDisplayConfig();
- showToast('toast.models.deletedSuccessfully', {
- count: result.deleted_count,
- type: currentConfig.displayName.toLowerCase()
- }, 'success');
+ const isRecipes = state.currentPageType === 'recipes';
+ const refreshFn = isRecipes
+ ? () => window.recipeManager.loadRecipes(true)
+ : () => resetAndReload(true);
+
+ if (result.batch_id || (result.batch_ids && result.batch_ids.length)) {
+ // One undo action for the whole bulk action — the backend
+ // merges staged per-file batches into a single batch, with
+ // a batch_ids fallback array when the merge failed
+ const onAction = result.batch_id
+ ? () => handleUndoDelete(result.batch_id, refreshFn)
+ : async () => {
+ for (const id of result.batch_ids) {
+ const succeeded = await handleUndoDelete(id, null, { showToast: false, refresh: false });
+ if (!succeeded) {
+ showToast('toast.undo.failed', { error: '' }, 'error');
+ return;
+ }
+ }
+ refreshFn();
+ showToast('toast.undo.restored', {}, 'success');
+ };
+ showActionToast('toast.undo.deletedBulk', { count: result.deleted_count }, 'success', {
+ actionText: translate('toast.undo.action'),
+ onAction,
+ });
+ } else {
+ showToast('toast.models.deletedSuccessfully', {
+ count: result.deleted_count,
+ type: currentConfig.displayName.toLowerCase()
+ }, 'success');
+ }
filePaths.forEach(path => {
state.virtualScroller.removeItemByFilePath(path);
diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js
index fe336664..f3b2203c 100644
--- a/static/js/managers/ModalManager.js
+++ b/static/js/managers/ModalManager.js
@@ -434,6 +434,22 @@ export class ModalManager {
this.currentOpenModal = id; // Update currently open modal
document.body.style.top = `-${this.scrollPosition}px`;
document.body.classList.add('modal-open');
+
+ modal.restoreFocusTo = null;
+ if (this._isDeleteConfirmModal(modal.element)) {
+ const activeElement = document.activeElement;
+ modal.restoreFocusTo = activeElement && activeElement !== document.body
+ ? activeElement
+ : null;
+ modal.element.querySelector('.cancel-btn')?.focus();
+ }
+ }
+
+ // Several non-delete modals share the delete-modal styling class, so an
+ // actual .delete-btn is required before focus is moved to Cancel.
+ _isDeleteConfirmModal(element) {
+ return element.classList.contains('delete-modal') &&
+ Boolean(element.querySelector('.delete-btn'));
}
closeModal(id) {
@@ -463,6 +479,13 @@ export class ModalManager {
modal.cleanupCallback();
modal.cleanupCallback = null;
}
+
+ if (modal.restoreFocusTo) {
+ if (modal.restoreFocusTo.isConnected) {
+ modal.restoreFocusTo.focus();
+ }
+ modal.restoreFocusTo = null;
+ }
}
handleEscape(e) {
diff --git a/static/js/utils/modalUtils.js b/static/js/utils/modalUtils.js
index e7e49e2e..0b7377ae 100644
--- a/static/js/utils/modalUtils.js
+++ b/static/js/utils/modalUtils.js
@@ -1,37 +1,84 @@
import { modalManager } from '../managers/ModalManager.js';
-import { getModelApiClient } from '../api/modelApiFactory.js';
+import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
+import { showActionToast } from './uiHelpers.js';
+import { translate } from './i18nHelpers.js';
+import { handleUndoDelete } from './undoHelpers.js';
+import { state } from '../state/index.js';
+import { formatFileSize } from '../components/shared/utils.js';
+
+const DELETE_BUTTON_ARM_DELAY_MS = 1500;
let pendingDeletePath = null;
+let pendingDeleteName = null;
let pendingExcludePath = null;
+let pendingDeleteArmTimer = null;
+
+// Delay-activates every delete button inside a delete-confirmation modal so a
+// misclick in the first moments after opening cannot confirm the deletion.
+// Returns the pending timeout id so callers can cancel it when the modal closes early.
+export function armDeleteButton(modalElement, delayMs = DELETE_BUTTON_ARM_DELAY_MS) {
+ if (!modalElement) return null;
+
+ const deleteButtons = modalElement.querySelectorAll('.delete-btn');
+ if (!deleteButtons.length) return null;
+
+ deleteButtons.forEach((button) => { button.disabled = true; });
+
+ return setTimeout(() => {
+ deleteButtons.forEach((button) => { button.disabled = false; });
+ }, delayMs);
+}
export function showDeleteModal(filePath) {
pendingDeletePath = filePath;
-
+
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
? window.CSS.escape(filePath)
: filePath.replace(/["\\]/g, '\\$&');
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
const modelName = card ? card.dataset.name : filePath.split('/').pop();
+ pendingDeleteName = modelName;
const modal = modalManager.getModal('deleteModal').element;
const modelInfo = modal.querySelector('.delete-model-info');
-
+
+ const undoEnabled = state.global?.settings?.delete_undo_enabled;
+ const warningKey = undoEnabled
+ ? 'modals.deleteModel.recoverableWarning'
+ : 'modals.deleteModel.permanentWarning';
+ const fileSize = card?.dataset.file_size;
+ const sizeLine = fileSize
+ ? `
${translate('modals.deleteModel.freesSpace', { size: formatFileSize(parseInt(fileSize, 10)) })}`
+ : '';
+
modelInfo.innerHTML = `
Model: ${modelName}
File: ${filePath}
+
+ ${translate(warningKey)}${sizeLine}
`;
-
+
modalManager.showModal('deleteModal');
+ pendingDeleteArmTimer = armDeleteButton(modal);
}
export async function confirmDelete() {
if (!pendingDeletePath) return;
-
+
try {
- await getModelApiClient().deleteModel(pendingDeletePath);
-
+ const modelName = pendingDeleteName;
+ const result = await getModelApiClient().deleteModel(pendingDeletePath);
+
closeDeleteModal();
+ if (result?.batch_id) {
+ const batchId = result.batch_id;
+ showActionToast('toast.undo.deleted', { name: modelName }, 'success', {
+ actionText: translate('toast.undo.action'),
+ onAction: () => handleUndoDelete(batchId, () => resetAndReload(true)),
+ });
+ }
+
if (window.modelDuplicatesManager) {
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
}
@@ -43,7 +90,12 @@ export async function confirmDelete() {
export function closeDeleteModal() {
modalManager.closeModal('deleteModal');
+ if (pendingDeleteArmTimer) {
+ clearTimeout(pendingDeleteArmTimer);
+ pendingDeleteArmTimer = null;
+ }
pendingDeletePath = null;
+ pendingDeleteName = null;
}
// Functions for the exclude modal
diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js
index 1cacd9ab..e944af4e 100644
--- a/static/js/utils/uiHelpers.js
+++ b/static/js/utils/uiHelpers.js
@@ -133,15 +133,28 @@ export async function copyToClipboard(text, successMessage = null) {
}
}
-export function showToast(key, params = {}, type = 'info', fallback = null) {
- // Plain messages (contain spaces) are not i18n dot-notation keys — use verbatim
- // to avoid spurious "Translation key not found" warnings from i18next
- const isPlainMessage = typeof key === 'string' && /\s/.test(key);
- const message = isPlainMessage ? key : translate(key, params, fallback);
+/**
+ * Build a toast element (internal — not exported).
+ * @param {string} message - Already-resolved message text
+ * @param {string} type - Toast type (info/success/warning/error)
+ * @returns {HTMLElement} The toast element (not yet attached to the DOM)
+ */
+function createToastElement(message, type) {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
+ return toast;
+}
+/**
+ * Attach a toast to the shared container, position it, and schedule its
+ * dismissal (internal — not exported).
+ * @param {HTMLElement} toast - The toast element to display
+ * @param {number} durationMs - How long the toast stays visible
+ * @param {Function} [onDismiss] - Optional callback fired once when dismissal begins
+ * @returns {Function} Manual dismiss function (idempotent)
+ */
+function appendToast(toast, durationMs, onDismiss = null) {
// Get or create toast container
let toastContainer = document.querySelector('.toast-container');
if (!toastContainer) {
@@ -161,35 +174,127 @@ export function showToast(key, params = {}, type = 'info', fallback = null) {
// Set position based on existing toasts
toast.style.top = `${topOffset + (toastIndex * (toast.offsetHeight || 60 + spacing))}px`;
- requestAnimationFrame(() => {
- toast.classList.add('show');
+ let dismissed = false;
+ const dismiss = () => {
+ if (dismissed) return;
+ dismissed = true;
- // Set timeout based on type
- let timeout = 2000; // Default (info)
- if (type === 'warning' || type === 'error') {
- timeout = 5000;
+ if (typeof onDismiss === 'function') {
+ onDismiss();
}
- setTimeout(() => {
- toast.classList.remove('show');
- toast.addEventListener('transitionend', () => {
- toast.remove();
+ toast.classList.remove('show');
+ toast.addEventListener('transitionend', () => {
+ toast.remove();
- // Reposition remaining toasts
- if (toastContainer) {
- const remainingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
- remainingToasts.forEach((t, index) => {
- t.style.top = `${topOffset + (index * (t.offsetHeight || 60 + spacing))}px`;
- });
+ // Reposition remaining toasts
+ if (toastContainer) {
+ const remainingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
+ remainingToasts.forEach((t, index) => {
+ t.style.top = `${topOffset + (index * (t.offsetHeight || 60 + spacing))}px`;
+ });
- // Remove container if empty
- if (remainingToasts.length === 0) {
- toastContainer.remove();
- }
+ // Remove container if empty
+ if (remainingToasts.length === 0) {
+ toastContainer.remove();
}
- });
- }, timeout);
+ }
+ });
+ };
+
+ requestAnimationFrame(() => {
+ toast.classList.add('show');
+ setTimeout(dismiss, durationMs);
});
+
+ return dismiss;
+}
+
+export function showToast(key, params = {}, type = 'info', fallback = null) {
+ // Plain messages (contain spaces) are not i18n dot-notation keys — use verbatim
+ // to avoid spurious "Translation key not found" warnings from i18next
+ const isPlainMessage = typeof key === 'string' && /\s/.test(key);
+ const message = isPlainMessage ? key : translate(key, params, fallback);
+ const toast = createToastElement(message, type);
+
+ // Set timeout based on type
+ let duration = 2000; // Default (info)
+ if (type === 'warning' || type === 'error') {
+ duration = 5000;
+ }
+
+ appendToast(toast, duration);
+}
+
+/**
+ * Show a toast with an action button (e.g. Undo) and an optional countdown.
+ * The message accepts the same key/plain-string contract as showToast, so
+ * callers may pass either an i18n key or an already-translated string.
+ * @param {string} key - i18n key or plain message
+ * @param {Object} [params] - i18n interpolation params
+ * @param {string} [type] - Toast type (info/success/warning/error)
+ * @param {Object} [options]
+ * @param {string} [options.actionText] - Label for the action button (button omitted when empty)
+ * @param {Function} [options.onAction] - Callback invoked at most once on button click
+ * @param {number} [options.durationMs=30000] - How long the toast stays visible
+ * @param {boolean} [options.countdown=true] - Show a ticking `(N)s` countdown
+ */
+export function showActionToast(key, params = {}, type = 'info', options = {}) {
+ const { actionText, onAction, durationMs = 30000, countdown = true } = options;
+
+ const isPlainMessage = typeof key === 'string' && /\s/.test(key);
+ const message = isPlainMessage ? key : translate(key, params);
+ const toast = createToastElement(message, type);
+
+ let countdownInterval = null;
+ const clearCountdown = () => {
+ if (countdownInterval !== null) {
+ clearInterval(countdownInterval);
+ countdownInterval = null;
+ }
+ };
+
+ // The interval must be cleared on EVERY dismiss path (timeout, countdown end,
+ // manual button click) — the onDismiss hook covers the appendToast timeout path.
+ const dismiss = appendToast(toast, durationMs, clearCountdown);
+
+ let actionFired = false;
+ if (actionText) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'toast-action-btn';
+ button.textContent = actionText;
+ button.addEventListener('click', (event) => {
+ event.preventDefault();
+ // Guard against double-click firing the action twice
+ if (actionFired) return;
+ actionFired = true;
+
+ clearCountdown();
+ if (typeof onAction === 'function') {
+ onAction();
+ }
+ dismiss();
+ });
+ toast.append(button);
+ }
+
+ if (countdown) {
+ const countdownEl = document.createElement('span');
+ countdownEl.className = 'toast-countdown';
+ let remainingSeconds = Math.max(0, Math.ceil(durationMs / 1000));
+ countdownEl.textContent = `(${remainingSeconds}s)`;
+ toast.append(countdownEl);
+
+ countdownInterval = setInterval(() => {
+ remainingSeconds -= 1;
+ countdownEl.textContent = `(${Math.max(remainingSeconds, 0)}s)`;
+ if (remainingSeconds <= 0) {
+ clearCountdown();
+ dismiss();
+ }
+ }, 1000);
+ }
}
export function restoreFolderFilter() {
diff --git a/static/js/utils/undoHelpers.js b/static/js/utils/undoHelpers.js
new file mode 100644
index 00000000..70a2b23e
--- /dev/null
+++ b/static/js/utils/undoHelpers.js
@@ -0,0 +1,55 @@
+import { showToast } from './uiHelpers.js';
+
+/**
+ * Undo a staged delete batch via the pending-delete endpoint.
+ * @param {string} batchId - The batch id returned by a staged delete response
+ * @param {Function|null} refreshFn - Called once after a successful restore (unless options.refresh is false)
+ * @param {Object} [options]
+ * @param {boolean} [options.showToast=true] - Suppress toasts (used by sequential multi-batch undo loops)
+ * @param {boolean} [options.refresh=true] - Suppress the refresh call (used by sequential multi-batch undo loops)
+ * @returns {Promise} Whether the undo succeeded
+ */
+export async function handleUndoDelete(batchId, refreshFn, options = {}) {
+ const { showToast: showToastEnabled = true, refresh: refreshEnabled = true } = options;
+
+ try {
+ const response = await fetch('/api/lm/undo-delete', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ batch_id: batchId }),
+ });
+
+ if (response.ok) {
+ if (refreshEnabled && typeof refreshFn === 'function') {
+ refreshFn();
+ }
+ if (showToastEnabled) {
+ showToast('toast.undo.restored', {}, 'success');
+ }
+ return true;
+ }
+
+ // Read the error body to distinguish an expired batch from other failures
+ let errorMessage = '';
+ try {
+ const body = await response.json();
+ errorMessage = body?.error || '';
+ } catch {
+ errorMessage = '';
+ }
+
+ if (showToastEnabled) {
+ if (response.status === 404 && errorMessage.toLowerCase().includes('expired')) {
+ showToast('toast.undo.expired', {}, 'error');
+ } else {
+ showToast('toast.undo.failed', { error: errorMessage || response.statusText }, 'error');
+ }
+ }
+ return false;
+ } catch (error) {
+ if (showToastEnabled) {
+ showToast('toast.undo.failed', { error: error.message }, 'error');
+ }
+ return false;
+ }
+}
diff --git a/tests/frontend/api/baseModelApi.bulkDelete.test.js b/tests/frontend/api/baseModelApi.bulkDelete.test.js
new file mode 100644
index 00000000..ff267215
--- /dev/null
+++ b/tests/frontend/api/baseModelApi.bulkDelete.test.js
@@ -0,0 +1,201 @@
+import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
+
+const {
+ BASE_MODEL_API_MODULE,
+ STATE_MODULE,
+ UI_HELPERS_MODULE,
+ I18N_MODULE,
+ STORAGE_MODULE,
+ API_CONFIG_MODULE,
+ API_FACTORY_MODULE,
+ SIDEBAR_MANAGER_MODULE,
+} = vi.hoisted(() => ({
+ BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname,
+ STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
+ UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
+ I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
+ STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
+ API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
+ API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
+ SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
+}));
+
+const showToastMock = vi.fn();
+const showSimpleLoadingMock = vi.fn();
+const showCancelButtonMock = vi.fn();
+const hideLoadingMock = vi.fn();
+
+vi.mock(STATE_MODULE, () => ({
+ state: {
+ loadingManager: {
+ showSimpleLoading: showSimpleLoadingMock,
+ showCancelButton: showCancelButtonMock,
+ hide: hideLoadingMock,
+ },
+ virtualScroller: {
+ removeItemByFilePath: vi.fn(),
+ },
+ },
+ getCurrentPageState: vi.fn(() => ({})),
+}));
+
+vi.mock(UI_HELPERS_MODULE, () => ({
+ showToast: showToastMock,
+}));
+
+vi.mock(I18N_MODULE, () => ({
+ translate: vi.fn((key) => key),
+}));
+
+vi.mock(STORAGE_MODULE, () => ({
+ getStorageItem: vi.fn(),
+ getSessionItem: vi.fn(),
+ removeSessionItem: vi.fn(),
+ saveMapToStorage: vi.fn(),
+}));
+
+vi.mock(API_CONFIG_MODULE, () => ({
+ getCompleteApiConfig: vi.fn(() => ({
+ endpoints: { bulkDelete: '/api/lm/loras/bulk-delete' },
+ config: { displayName: 'LoRA', singularName: 'LoRA' },
+ })),
+ getCurrentModelType: vi.fn(() => 'loras'),
+ isValidModelType: vi.fn(() => true),
+ DOWNLOAD_ENDPOINTS: {},
+ HF_ENDPOINTS: {},
+ WS_ENDPOINTS: {},
+}));
+
+vi.mock(API_FACTORY_MODULE, () => ({
+ resetAndReload: vi.fn(),
+}));
+
+vi.mock(SIDEBAR_MANAGER_MODULE, () => ({
+ sidebarManager: { refresh: vi.fn() },
+}));
+
+describe('BaseModelApiClient.bulkDeleteModels undo contract', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ delete global.fetch;
+ });
+
+ async function createClient() {
+ const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE);
+ class TestClient extends BaseModelApiClient {}
+ return new TestClient('loras');
+ }
+
+ function mockBulkDeleteResponse(payload) {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => payload,
+ });
+ }
+
+ it('posts the file paths and defaults both batch fields to null', async () => {
+ mockBulkDeleteResponse({
+ success: true,
+ status: 'success',
+ total_deleted: 3,
+ total_attempted: 3,
+ cache_updated: true,
+ results: [],
+ });
+
+ const client = await createClient();
+ const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']);
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ '/api/lm/loras/bulk-delete',
+ expect.objectContaining({ method: 'POST' })
+ );
+ expect(result).toEqual({
+ success: true,
+ deleted_count: 3,
+ failed_count: 0,
+ errors: [],
+ batch_id: null,
+ batch_ids: null,
+ });
+ expect(hideLoadingMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('passes through the merged batch_id when the backend staged the bulk delete', async () => {
+ mockBulkDeleteResponse({
+ success: true,
+ status: 'success',
+ total_deleted: 2,
+ total_attempted: 2,
+ cache_updated: true,
+ results: [],
+ batch_id: 'merged-batch-1',
+ });
+
+ const client = await createClient();
+ const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']);
+
+ expect(result.batch_id).toBe('merged-batch-1');
+ expect(result.batch_ids).toBeNull();
+ });
+
+ it('passes through the batch_ids fallback array when the merge failed', async () => {
+ mockBulkDeleteResponse({
+ success: true,
+ status: 'success',
+ total_deleted: 2,
+ total_attempted: 2,
+ cache_updated: true,
+ results: [],
+ batch_ids: ['batch-1', 'batch-2'],
+ });
+
+ const client = await createClient();
+ const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']);
+
+ expect(result.batch_id).toBeNull();
+ expect(result.batch_ids).toEqual(['batch-1', 'batch-2']);
+ });
+
+ it('keeps the batch field on the cancelled-status path (staged subset is undoable)', async () => {
+ mockBulkDeleteResponse({
+ success: true,
+ status: 'cancelled',
+ total_deleted: 1,
+ total_attempted: 2,
+ cache_updated: true,
+ results: [],
+ batch_id: 'partial-batch',
+ });
+
+ const client = await createClient();
+ const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']);
+
+ expect(result.success).toBe(true);
+ expect(result.deleted_count).toBe(1);
+ expect(result.batch_id).toBe('partial-batch');
+ expect(result.batch_ids).toBeNull();
+ });
+
+ it('returns the cancelled marker when the user aborts the fetch', async () => {
+ const abortError = new Error('The user aborted a request.');
+ abortError.name = 'AbortError';
+ global.fetch = vi.fn().mockRejectedValue(abortError);
+
+ const client = await createClient();
+ const result = await client.bulkDeleteModels(['/models/a.safetensors']);
+
+ expect(result).toEqual({ success: false, cancelled: true });
+ expect(hideLoadingMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('throws the backend error message when the bulk delete fails', async () => {
+ mockBulkDeleteResponse({ success: false, error: 'disk full' });
+
+ const client = await createClient();
+ await expect(client.bulkDeleteModels(['/models/a.safetensors'])).rejects.toThrow('disk full');
+ });
+});
diff --git a/tests/frontend/api/baseModelApi.delete.test.js b/tests/frontend/api/baseModelApi.delete.test.js
new file mode 100644
index 00000000..a8faff8f
--- /dev/null
+++ b/tests/frontend/api/baseModelApi.delete.test.js
@@ -0,0 +1,161 @@
+import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
+
+const {
+ BASE_MODEL_API_MODULE,
+ STATE_MODULE,
+ UI_HELPERS_MODULE,
+ I18N_MODULE,
+ STORAGE_MODULE,
+ API_CONFIG_MODULE,
+ API_FACTORY_MODULE,
+ SIDEBAR_MANAGER_MODULE,
+} = vi.hoisted(() => ({
+ BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname,
+ STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
+ UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
+ I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
+ STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
+ API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
+ API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
+ SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
+}));
+
+const showToastMock = vi.fn();
+const removeItemByFilePathMock = vi.fn();
+const showSimpleLoadingMock = vi.fn();
+const hideLoadingMock = vi.fn();
+
+vi.mock(STATE_MODULE, () => ({
+ state: {
+ loadingManager: {
+ showSimpleLoading: showSimpleLoadingMock,
+ hide: hideLoadingMock,
+ },
+ virtualScroller: {
+ removeItemByFilePath: removeItemByFilePathMock,
+ },
+ },
+ getCurrentPageState: vi.fn(() => ({})),
+}));
+
+vi.mock(UI_HELPERS_MODULE, () => ({
+ showToast: showToastMock,
+}));
+
+vi.mock(I18N_MODULE, () => ({
+ translate: vi.fn((key) => key),
+}));
+
+vi.mock(STORAGE_MODULE, () => ({
+ getStorageItem: vi.fn(),
+ getSessionItem: vi.fn(),
+ removeSessionItem: vi.fn(),
+ saveMapToStorage: vi.fn(),
+}));
+
+vi.mock(API_CONFIG_MODULE, () => ({
+ getCompleteApiConfig: vi.fn(() => ({
+ endpoints: { delete: '/api/lm/loras/delete' },
+ config: { displayName: 'LoRA', singularName: 'LoRA' },
+ })),
+ getCurrentModelType: vi.fn(() => 'loras'),
+ isValidModelType: vi.fn(() => true),
+ DOWNLOAD_ENDPOINTS: {},
+ HF_ENDPOINTS: {},
+ WS_ENDPOINTS: {},
+}));
+
+vi.mock(API_FACTORY_MODULE, () => ({
+ resetAndReload: vi.fn(),
+}));
+
+vi.mock(SIDEBAR_MANAGER_MODULE, () => ({
+ sidebarManager: { refresh: vi.fn() },
+}));
+
+describe('BaseModelApiClient.deleteModel undo contract', () => {
+ beforeEach(() => {
+ showToastMock.mockReset();
+ removeItemByFilePathMock.mockReset();
+ showSimpleLoadingMock.mockReset();
+ hideLoadingMock.mockReset();
+ });
+
+ afterEach(() => {
+ delete global.fetch;
+ });
+
+ async function createClient() {
+ const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE);
+ class TestClient extends BaseModelApiClient {}
+ return new TestClient('loras');
+ }
+
+ it('returns the batch id and suppresses the legacy success toast when staged', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true, deleted_files: [], batch_id: 'batch-42' }),
+ });
+
+ const client = await createClient();
+ const result = await client.deleteModel('/models/foo.safetensors');
+
+ expect(result).toEqual({ success: true, batch_id: 'batch-42' });
+ // The card is still removed from the scroller — the file is gone either way
+ expect(removeItemByFilePathMock).toHaveBeenCalledWith('/models/foo.safetensors');
+ // No legacy toast: the caller shows the undo action toast instead
+ expect(showToastMock).not.toHaveBeenCalledWith(
+ 'toast.api.deleteSuccess',
+ expect.anything(),
+ expect.anything()
+ );
+ expect(hideLoadingMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps the legacy success toast when the delete was not staged', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true, deleted_files: ['/models/foo.safetensors'] }),
+ });
+
+ const client = await createClient();
+ const result = await client.deleteModel('/models/foo.safetensors');
+
+ expect(result).toEqual({ success: true, batch_id: null });
+ expect(removeItemByFilePathMock).toHaveBeenCalledWith('/models/foo.safetensors');
+ expect(showToastMock).toHaveBeenCalledWith('toast.api.deleteSuccess', { type: 'LoRA' }, 'success');
+ });
+
+ it('returns a truthy result so undo-blind callers keep working (ModelVersionsTab)', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true, deleted_files: [], batch_id: 'batch-7' }),
+ });
+
+ const client = await createClient();
+ const result = await client.deleteModel('/models/v2.safetensors');
+
+ // ModelVersionsTab.js:1136-1144 awaits deleteModel and treats any truthy
+ // result as success — the new object must satisfy that check shape.
+ expect(result).toBeTruthy();
+ expect(Boolean(result && result.success)).toBe(true);
+ });
+
+ it('returns false and shows the failure toast when the server reports failure', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: false, error: 'disk error' }),
+ });
+
+ const client = await createClient();
+ const result = await client.deleteModel('/models/foo.safetensors');
+
+ expect(result).toBe(false);
+ expect(removeItemByFilePathMock).not.toHaveBeenCalled();
+ expect(showToastMock).toHaveBeenCalledWith(
+ 'toast.api.deleteFailed',
+ expect.objectContaining({ type: 'LoRA' }),
+ 'error'
+ );
+ });
+});
diff --git a/tests/frontend/api/recipeApi.bulk.test.js b/tests/frontend/api/recipeApi.bulk.test.js
index 55a58487..63d552b1 100644
--- a/tests/frontend/api/recipeApi.bulk.test.js
+++ b/tests/frontend/api/recipeApi.bulk.test.js
@@ -142,10 +142,50 @@ describe('RecipeSidebarApiClient bulk operations', () => {
success: true,
deleted_count: 2,
failed_count: 0,
+ batch_id: null,
+ batch_ids: null,
});
expect(loadingManagerMock.hide).toHaveBeenCalled();
});
+ it('passes through the merged batch_id from a staged bulk delete', async () => {
+ const api = new RecipeSidebarApiClient();
+ global.fetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ success: true,
+ total_deleted: 2,
+ total_failed: 0,
+ failed: [],
+ batch_id: 'merged-recipe-batch',
+ }),
+ });
+
+ const result = await api.bulkDeleteModels(['/recipes/a.webp', '/recipes/b.webp']);
+
+ expect(result.batch_id).toBe('merged-recipe-batch');
+ expect(result.batch_ids).toBeNull();
+ });
+
+ it('passes through the batch_ids fallback array when the merge failed', async () => {
+ const api = new RecipeSidebarApiClient();
+ global.fetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ success: true,
+ total_deleted: 2,
+ total_failed: 0,
+ failed: [],
+ batch_ids: ['recipe-batch-1', 'recipe-batch-2'],
+ }),
+ });
+
+ const result = await api.bulkDeleteModels(['/recipes/a.webp', '/recipes/b.webp']);
+
+ expect(result.batch_id).toBeNull();
+ expect(result.batch_ids).toEqual(['recipe-batch-1', 'recipe-batch-2']);
+ });
+
it('encodes recipe IDs when fetching recipe details', async () => {
global.fetch.mockResolvedValue({
ok: true,
diff --git a/tests/frontend/components/contextMenu.interactions.test.js b/tests/frontend/components/contextMenu.interactions.test.js
index a817f816..94aff178 100644
--- a/tests/frontend/components/contextMenu.interactions.test.js
+++ b/tests/frontend/components/contextMenu.interactions.test.js
@@ -123,6 +123,7 @@ vi.mock('../../../static/js/state/index.js', () => ({
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
showExcludeModal: vi.fn(),
showDeleteModal: vi.fn(),
+ armDeleteButton: vi.fn(),
}));
vi.mock('../../../static/js/managers/MoveManager.js', () => ({
diff --git a/tests/frontend/components/duplicatesManager.test.js b/tests/frontend/components/duplicatesManager.test.js
index 6ff6251f..5e95e425 100644
--- a/tests/frontend/components/duplicatesManager.test.js
+++ b/tests/frontend/components/duplicatesManager.test.js
@@ -1,11 +1,18 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
+const showActionToastMock = vi.fn();
+const handleUndoDeleteMock = vi.fn();
const recreateVirtualScrollMock = vi.fn();
const translateMock = vi.fn((key) => key);
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
+ showActionToast: showActionToastMock,
+}));
+
+vi.mock('../../../static/js/utils/undoHelpers.js', () => ({
+ handleUndoDelete: handleUndoDeleteMock,
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
@@ -20,6 +27,17 @@ vi.mock('../../../static/js/components/RecipeCard.js', () => ({
},
}));
+vi.mock('../../../static/js/utils/modalUtils.js', () => ({
+ armDeleteButton: (modalElement) => {
+ if (!modalElement) return null;
+ const buttons = modalElement.querySelectorAll('.delete-btn');
+ buttons.forEach((button) => { button.disabled = true; });
+ return setTimeout(() => {
+ buttons.forEach((button) => { button.disabled = false; });
+ }, 1500);
+ },
+}));
+
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
recreateVirtualScroll: recreateVirtualScrollMock,
}));
@@ -211,3 +229,152 @@ describe('DuplicatesManager prompt matching toggle', () => {
expect(document.getElementById('duplicatesBasis').textContent).toBe('recipes.duplicates.basis.loraCombo');
});
});
+
+describe('DuplicatesManager confirmDeleteDuplicates undo flows', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setCurrentPageType('recipes');
+ setupDom();
+ state.pendingLayoutRecreate = false;
+ state.virtualScroller = { enable: vi.fn(), disable: vi.fn() };
+ handleUndoDeleteMock.mockResolvedValue(true);
+ globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
+ globalThis.recipeManager = { loadRecipes: vi.fn() };
+ });
+
+ afterEach(() => {
+ state.pendingLayoutRecreate = false;
+ state.virtualScroller = null;
+ delete globalThis.modalManager;
+ delete globalThis.recipeManager;
+ delete globalThis.fetch;
+ });
+
+ function mockBulkDelete(payload) {
+ globalThis.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => payload,
+ });
+ }
+
+ function lastActionToastOptions() {
+ const call = showActionToastMock.mock.calls[showActionToastMock.mock.calls.length - 1];
+ return call[3];
+ }
+
+ it('shows the undo action toast with the batch id and reloads recipes on undo', async () => {
+ mockBulkDelete({ success: true, total_deleted: 2, batch_id: 'recipe-batch-1' });
+
+ const manager = new DuplicatesManager({});
+ manager.inDuplicateMode = true;
+ manager.selectedForDeletion.add('r1');
+ manager.selectedForDeletion.add('r2');
+
+ await manager.confirmDeleteDuplicates();
+
+ expect(showActionToastMock).toHaveBeenCalledTimes(1);
+ expect(showActionToastMock).toHaveBeenCalledWith(
+ 'toast.undo.deletedBulk',
+ { count: 2 },
+ 'success',
+ expect.objectContaining({
+ actionText: 'toast.undo.action',
+ onAction: expect.any(Function),
+ })
+ );
+ // The legacy duplicates success toast is replaced, not duplicated
+ expect(showToastMock).not.toHaveBeenCalledWith(
+ 'toast.duplicates.deleteSuccess',
+ expect.anything(),
+ expect.anything()
+ );
+ // exitDuplicateMode still runs for successful deletions
+ expect(manager.inDuplicateMode).toBe(false);
+
+ lastActionToastOptions().onAction();
+
+ expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
+ expect(handleUndoDeleteMock).toHaveBeenCalledWith('recipe-batch-1', expect.any(Function));
+
+ const refreshFn = handleUndoDeleteMock.mock.calls[0][1];
+ refreshFn();
+ expect(globalThis.recipeManager.loadRecipes).toHaveBeenCalledWith(true);
+ });
+
+ it('undoes the batch_ids fallback sequentially with one final refresh and restored toast', async () => {
+ mockBulkDelete({ success: true, total_deleted: 2, batch_ids: ['rb-1', 'rb-2'] });
+
+ const manager = new DuplicatesManager({});
+ manager.inDuplicateMode = true;
+ manager.selectedForDeletion.add('r1');
+ manager.selectedForDeletion.add('r2');
+
+ await manager.confirmDeleteDuplicates();
+
+ expect(showActionToastMock).toHaveBeenCalledTimes(1);
+ await lastActionToastOptions().onAction();
+
+ expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2);
+ expect(handleUndoDeleteMock.mock.calls[0]).toEqual(['rb-1', null, { showToast: false, refresh: false }]);
+ expect(handleUndoDeleteMock.mock.calls[1]).toEqual(['rb-2', null, { showToast: false, refresh: false }]);
+ expect(globalThis.recipeManager.loadRecipes).toHaveBeenCalledTimes(1);
+ expect(globalThis.recipeManager.loadRecipes).toHaveBeenCalledWith(true);
+ expect(showToastMock).toHaveBeenCalledTimes(1);
+ expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
+ });
+
+ it('keeps the legacy success toast when the response carries no batch field', async () => {
+ mockBulkDelete({ success: true, total_deleted: 1 });
+
+ const manager = new DuplicatesManager({});
+ manager.inDuplicateMode = true;
+ manager.selectedForDeletion.add('r1');
+
+ await manager.confirmDeleteDuplicates();
+
+ expect(showActionToastMock).not.toHaveBeenCalled();
+ expect(showToastMock).toHaveBeenCalledWith(
+ 'toast.duplicates.deleteSuccess',
+ { count: 1, type: 'recipes' },
+ 'success'
+ );
+ });
+});
+
+describe('DuplicatesManager deleteSelectedDuplicates delay-activate', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ setCurrentPageType('recipes');
+ setupDom();
+ document.body.insertAdjacentHTML('beforeend', `
+
+ `);
+ globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ delete globalThis.modalManager;
+ });
+
+ it('opens with the delete button disabled and enables it after 1500ms', async () => {
+ const manager = new DuplicatesManager({});
+ manager.selectedForDeletion.add('r1');
+
+ await manager.deleteSelectedDuplicates();
+
+ expect(globalThis.modalManager.showModal).toHaveBeenCalledWith('duplicateDeleteModal');
+ const deleteBtn = document.querySelector('#duplicateDeleteModal .delete-btn');
+ expect(deleteBtn.disabled).toBe(true);
+
+ deleteBtn.click();
+ expect(deleteBtn.disabled).toBe(true);
+
+ vi.advanceTimersByTime(1500);
+ expect(deleteBtn.disabled).toBe(false);
+ });
+});
diff --git a/tests/frontend/components/modelDuplicatesManager.test.js b/tests/frontend/components/modelDuplicatesManager.test.js
index 4bc47244..f72e6533 100644
--- a/tests/frontend/components/modelDuplicatesManager.test.js
+++ b/tests/frontend/components/modelDuplicatesManager.test.js
@@ -1,16 +1,34 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
+const showActionToastMock = vi.fn();
+const handleUndoDeleteMock = vi.fn();
const resetAndReloadMock = vi.fn();
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
+ showActionToast: showActionToastMock,
+}));
+
+vi.mock('../../../static/js/utils/undoHelpers.js', () => ({
+ handleUndoDelete: handleUndoDeleteMock,
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
resetAndReload: resetAndReloadMock,
}));
+vi.mock('../../../static/js/utils/modalUtils.js', () => ({
+ armDeleteButton: (modalElement) => {
+ if (!modalElement) return null;
+ const buttons = modalElement.querySelectorAll('.delete-btn');
+ buttons.forEach((button) => { button.disabled = true; });
+ return setTimeout(() => {
+ buttons.forEach((button) => { button.disabled = false; });
+ }, 1500);
+ },
+}));
+
const { ModelDuplicatesManager } = await import('../../../static/js/components/ModelDuplicatesManager.js');
const { state } = await import('../../../static/js/state/index.js');
@@ -230,3 +248,153 @@ describe('ModelDuplicatesManager verification state', () => {
expect(manager.verifiedGroups.has('visible-hash')).toBe(true);
});
});
+
+describe('ModelDuplicatesManager confirmDeleteDuplicates undo flows', () => {
+ function mockDeleteAndRecheck(deletePayload) {
+ global.fetch = vi.fn((url) => {
+ if (String(url).includes('bulk-delete')) {
+ return Promise.resolve({
+ ok: true,
+ statusText: 'OK',
+ json: async () => deletePayload,
+ });
+ }
+ return Promise.resolve({
+ ok: true,
+ statusText: 'OK',
+ json: async () => ({ success: true, duplicates: [] }),
+ });
+ });
+ }
+
+ function lastActionToastOptions() {
+ const call = showActionToastMock.mock.calls[showActionToastMock.mock.calls.length - 1];
+ return call[3];
+ }
+
+ beforeEach(() => {
+ handleUndoDeleteMock.mockResolvedValue(true);
+ state.virtualScroller = { enable: vi.fn(), disable: vi.fn() };
+ globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
+ });
+
+ afterEach(() => {
+ state.virtualScroller = null;
+ delete globalThis.modalManager;
+ });
+
+ it('shows the undo action toast with the batch id and refreshes models on undo', async () => {
+ const manager = await createManager();
+ mockDeleteAndRecheck({ success: true, total_deleted: 1, batch_id: 'model-batch-1' });
+
+ manager.inDuplicateMode = true;
+ manager.selectedForDeletion.add(carPath);
+
+ await manager.confirmDeleteDuplicates();
+
+ expect(showActionToastMock).toHaveBeenCalledTimes(1);
+ expect(showActionToastMock).toHaveBeenCalledWith(
+ 'toast.undo.deletedBulk',
+ { count: 1 },
+ 'success',
+ expect.objectContaining({
+ actionText: 'toast.undo.action',
+ onAction: expect.any(Function),
+ })
+ );
+ expect(showToastMock).not.toHaveBeenCalledWith(
+ 'toast.duplicates.deleteSuccess',
+ expect.anything(),
+ expect.anything()
+ );
+
+ // The existing reset + find-duplicates re-check path still runs
+ expect(resetAndReloadMock).toHaveBeenCalledWith(true);
+ // No remaining duplicates -> duplicate mode exited
+ expect(manager.inDuplicateMode).toBe(false);
+
+ lastActionToastOptions().onAction();
+
+ expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
+ expect(handleUndoDeleteMock).toHaveBeenCalledWith('model-batch-1', expect.any(Function));
+
+ const refreshFn = handleUndoDeleteMock.mock.calls[0][1];
+ resetAndReloadMock.mockClear();
+ refreshFn();
+ expect(resetAndReloadMock).toHaveBeenCalledTimes(1);
+ expect(resetAndReloadMock).toHaveBeenCalledWith(true);
+ });
+
+ it('undoes the batch_ids fallback sequentially with one final refresh and restored toast', async () => {
+ const manager = await createManager();
+ mockDeleteAndRecheck({ success: true, total_deleted: 2, batch_ids: ['mb-1', 'mb-2'] });
+
+ manager.inDuplicateMode = true;
+ manager.selectedForDeletion.add(carPath);
+ manager.selectedForDeletion.add(copyPath);
+
+ await manager.confirmDeleteDuplicates();
+
+ expect(showActionToastMock).toHaveBeenCalledTimes(1);
+ resetAndReloadMock.mockClear();
+ await lastActionToastOptions().onAction();
+
+ expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2);
+ expect(handleUndoDeleteMock.mock.calls[0]).toEqual(['mb-1', null, { showToast: false, refresh: false }]);
+ expect(handleUndoDeleteMock.mock.calls[1]).toEqual(['mb-2', null, { showToast: false, refresh: false }]);
+ expect(resetAndReloadMock).toHaveBeenCalledTimes(1);
+ expect(resetAndReloadMock).toHaveBeenCalledWith(true);
+ expect(showToastMock).toHaveBeenCalledTimes(1);
+ expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
+ });
+
+ it('keeps the legacy success toast when the response carries no batch field', async () => {
+ const manager = await createManager();
+ mockDeleteAndRecheck({ success: true, total_deleted: 1 });
+
+ manager.inDuplicateMode = true;
+ manager.selectedForDeletion.add(carPath);
+
+ await manager.confirmDeleteDuplicates();
+
+ expect(showActionToastMock).not.toHaveBeenCalled();
+ expect(showToastMock).toHaveBeenCalledWith(
+ 'toast.duplicates.deleteSuccess',
+ { count: 1, type: 'loras' },
+ 'success'
+ );
+ });
+});
+
+describe('ModelDuplicatesManager deleteSelectedDuplicates delay-activate', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ delete globalThis.modalManager;
+ });
+
+ it('opens with the delete button disabled and enables it after 1500ms', async () => {
+ const manager = await createManager();
+ document.body.insertAdjacentHTML('beforeend', `
+
+ `);
+ manager.selectedForDeletion.add(carPath);
+
+ await manager.deleteSelectedDuplicates();
+
+ expect(globalThis.modalManager.showModal).toHaveBeenCalledWith('modelDuplicateDeleteModal');
+ const deleteBtn = document.querySelector('#modelDuplicateDeleteModal .delete-btn');
+ expect(deleteBtn.disabled).toBe(true);
+
+ vi.advanceTimersByTime(1500);
+ expect(deleteBtn.disabled).toBe(false);
+ });
+});
diff --git a/tests/frontend/components/recipeCard.delete.test.js b/tests/frontend/components/recipeCard.delete.test.js
new file mode 100644
index 00000000..6f1ebe94
--- /dev/null
+++ b/tests/frontend/components/recipeCard.delete.test.js
@@ -0,0 +1,191 @@
+import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
+
+const {
+ RECIPE_CARD_MODULE,
+ UI_HELPERS_MODULE,
+ RECIPE_API_MODULE,
+ MODEL_CARD_MODULE,
+ MODAL_MANAGER_MODULE,
+ STATE_MODULE,
+ BULK_MANAGER_MODULE,
+ CONSTANTS_MODULE,
+ I18N_MODULE,
+ UNDO_HELPERS_MODULE,
+} = vi.hoisted(() => ({
+ RECIPE_CARD_MODULE: new URL('../../../static/js/components/RecipeCard.js', import.meta.url).pathname,
+ UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
+ RECIPE_API_MODULE: new URL('../../../static/js/api/recipeApi.js', import.meta.url).pathname,
+ MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname,
+ MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
+ STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
+ BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname,
+ CONSTANTS_MODULE: new URL('../../../static/js/utils/constants.js', import.meta.url).pathname,
+ I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
+ UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
+}));
+
+const showToastMock = vi.fn();
+const showActionToastMock = vi.fn();
+const handleUndoDeleteMock = vi.fn();
+const translateMock = vi.fn((key) => key);
+const closeModalMock = vi.fn();
+const removeItemByFilePathMock = vi.fn();
+
+vi.mock(UI_HELPERS_MODULE, () => ({
+ showToast: showToastMock,
+ showActionToast: showActionToastMock,
+ copyToClipboard: vi.fn(),
+ sendLoraToWorkflow: vi.fn(),
+}));
+
+vi.mock(RECIPE_API_MODULE, () => ({
+ updateRecipeMetadata: vi.fn(),
+}));
+
+vi.mock(MODEL_CARD_MODULE, () => ({
+ configureModelCardVideo: vi.fn(),
+}));
+
+vi.mock(MODAL_MANAGER_MODULE, () => ({
+ modalManager: {
+ showModal: vi.fn(),
+ closeModal: closeModalMock,
+ },
+}));
+
+vi.mock(STATE_MODULE, () => ({
+ state: {
+ virtualScroller: {
+ removeItemByFilePath: removeItemByFilePathMock,
+ },
+ },
+ getCurrentPageState: vi.fn(() => ({})),
+}));
+
+vi.mock(BULK_MANAGER_MODULE, () => ({
+ bulkManager: {},
+}));
+
+vi.mock(CONSTANTS_MODULE, () => ({
+ NSFW_LEVELS: {},
+ getBaseModelAbbreviation: vi.fn(),
+ getMatureBlurThreshold: vi.fn(),
+}));
+
+vi.mock(I18N_MODULE, () => ({
+ translate: translateMock,
+}));
+
+vi.mock(UNDO_HELPERS_MODULE, () => ({
+ handleUndoDelete: handleUndoDeleteMock,
+}));
+
+function setupDeleteModal() {
+ document.body.innerHTML = `
+
+
+
+ `;
+ const deleteModal = document.getElementById('deleteModal');
+ // jsdom maps data-file-path to dataset.filePath
+ deleteModal.dataset.recipeId = 'recipe-1';
+ deleteModal.dataset.filePath = '/recipes/r1.json';
+ return deleteModal;
+}
+
+async function flushPromises() {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+describe('RecipeCard confirmDeleteRecipe undo flow', () => {
+ beforeEach(() => {
+ showToastMock.mockReset();
+ showActionToastMock.mockReset();
+ handleUndoDeleteMock.mockReset();
+ translateMock.mockClear();
+ closeModalMock.mockReset();
+ removeItemByFilePathMock.mockReset();
+ setupDeleteModal();
+ window.recipeManager = { loadRecipes: vi.fn() };
+ });
+
+ afterEach(() => {
+ delete global.fetch;
+ delete window.recipeManager;
+ document.body.innerHTML = '';
+ });
+
+ async function createCard() {
+ const { RecipeCard } = await import(RECIPE_CARD_MODULE);
+ const card = Object.create(RecipeCard.prototype);
+ card.recipe = { id: 'recipe-1', title: 'My Recipe', file_path: '/recipes/r1.json' };
+ return card;
+ }
+
+ it('shows the undo action toast and wires undo to handleUndoDelete + loadRecipes(true)', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true, message: 'deleted', batch_id: 'recipe-batch-1' }),
+ });
+
+ const card = await createCard();
+ card.confirmDeleteRecipe();
+ await flushPromises();
+
+ expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1', expect.objectContaining({
+ method: 'DELETE',
+ }));
+ // No legacy success toast when the delete was staged
+ expect(showToastMock).not.toHaveBeenCalledWith('toast.recipes.deletedSuccessfully', {}, 'success');
+ expect(showActionToastMock).toHaveBeenCalledTimes(1);
+
+ const [key, params, type, options] = showActionToastMock.mock.calls[0];
+ expect(key).toBe('toast.undo.deleted');
+ expect(params).toEqual({ name: 'My Recipe' });
+ expect(type).toBe('success');
+ expect(options.actionText).toBe('toast.undo.action');
+
+ options.onAction();
+ expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
+ const [batchId, refreshFn] = handleUndoDeleteMock.mock.calls[0];
+ expect(batchId).toBe('recipe-batch-1');
+
+ refreshFn();
+ expect(window.recipeManager.loadRecipes).toHaveBeenCalledWith(true);
+
+ expect(removeItemByFilePathMock).toHaveBeenCalledWith('/recipes/r1.json');
+ expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
+ });
+
+ it('keeps the legacy success toast when the delete was not staged', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true, message: 'deleted' }),
+ });
+
+ const card = await createCard();
+ card.confirmDeleteRecipe();
+ await flushPromises();
+
+ expect(showToastMock).toHaveBeenCalledWith('toast.recipes.deletedSuccessfully', {}, 'success');
+ expect(showActionToastMock).not.toHaveBeenCalled();
+ expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
+ });
+
+ it('shows the failure toast when the server rejects the delete', async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false });
+
+ const card = await createCard();
+ const deleteBtn = document.querySelector('.delete-btn');
+ card.confirmDeleteRecipe();
+ await flushPromises();
+
+ expect(showToastMock).toHaveBeenCalledWith(
+ 'toast.recipes.deleteFailed',
+ expect.objectContaining({ message: expect.any(String) }),
+ 'error'
+ );
+ expect(deleteBtn.disabled).toBe(false);
+ expect(deleteBtn.textContent).toBe('Delete');
+ });
+});
diff --git a/tests/frontend/components/recipeCard.deleteFriction.test.js b/tests/frontend/components/recipeCard.deleteFriction.test.js
new file mode 100644
index 00000000..5104fb86
--- /dev/null
+++ b/tests/frontend/components/recipeCard.deleteFriction.test.js
@@ -0,0 +1,160 @@
+import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
+
+const {
+ RECIPE_CARD_MODULE,
+ UI_HELPERS_MODULE,
+ RECIPE_API_MODULE,
+ MODEL_CARD_MODULE,
+ MODAL_MANAGER_MODULE,
+ BULK_MANAGER_MODULE,
+ I18N_MODULE,
+ UNDO_HELPERS_MODULE,
+ API_FACTORY_MODULE,
+ STATE_MODULE,
+} = vi.hoisted(() => ({
+ RECIPE_CARD_MODULE: new URL('../../../static/js/components/RecipeCard.js', import.meta.url).pathname,
+ UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
+ RECIPE_API_MODULE: new URL('../../../static/js/api/recipeApi.js', import.meta.url).pathname,
+ MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname,
+ MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
+ BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname,
+ I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
+ UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
+ API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
+ STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
+}));
+
+const showModalMock = vi.fn();
+const closeModalMock = vi.fn();
+
+vi.mock(UI_HELPERS_MODULE, () => ({
+ showToast: vi.fn(),
+ showActionToast: vi.fn(),
+ copyToClipboard: vi.fn(),
+ sendLoraToWorkflow: vi.fn(),
+}));
+
+vi.mock(RECIPE_API_MODULE, () => ({
+ updateRecipeMetadata: vi.fn(),
+}));
+
+vi.mock(MODEL_CARD_MODULE, () => ({
+ configureModelCardVideo: vi.fn(),
+}));
+
+vi.mock(MODAL_MANAGER_MODULE, () => ({
+ modalManager: {
+ showModal: showModalMock,
+ closeModal: closeModalMock,
+ },
+}));
+
+vi.mock(BULK_MANAGER_MODULE, () => ({
+ bulkManager: {},
+}));
+
+vi.mock(I18N_MODULE, () => ({
+ translate: vi.fn((key) => key),
+}));
+
+vi.mock(UNDO_HELPERS_MODULE, () => ({
+ handleUndoDelete: vi.fn(),
+}));
+
+// modalUtils.js is intentionally NOT mocked — its real armDeleteButton drives
+// the delay-activate behavior under test. Its own imports are mocked below.
+vi.mock(API_FACTORY_MODULE, () => ({
+ getModelApiClient: vi.fn(),
+ resetAndReload: vi.fn(),
+}));
+
+describe('RecipeCard delete confirmation delay-activate', () => {
+ let capturedOnClose;
+
+ beforeEach(async () => {
+ vi.useFakeTimers();
+ showModalMock.mockReset();
+ closeModalMock.mockReset();
+ capturedOnClose = null;
+ document.body.innerHTML = '';
+ showModalMock.mockImplementation((id, content, onClose) => {
+ if (content) {
+ document.getElementById(id).innerHTML = content;
+ }
+ capturedOnClose = onClose;
+ });
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true }),
+ });
+ window.recipeManager = { loadRecipes: vi.fn() };
+ const { state } = await import(STATE_MODULE);
+ state.virtualScroller = { removeItemByFilePath: vi.fn() };
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ delete global.fetch;
+ delete window.recipeManager;
+ document.body.innerHTML = '';
+ });
+
+ async function createCard() {
+ const { RecipeCard } = await import(RECIPE_CARD_MODULE);
+ const card = Object.create(RecipeCard.prototype);
+ card.recipe = { id: 'recipe-1', title: 'My Recipe', file_path: '/recipes/r1.json', file_url: '/preview.png' };
+ return card;
+ }
+
+ it('opens with a disabled delete button that ignores clicks until 1500ms elapse', async () => {
+ const card = await createCard();
+ card.showDeleteConfirmation();
+
+ const deleteBtn = document.querySelector('#deleteModal .delete-btn');
+ expect(deleteBtn.disabled).toBe(true);
+
+ deleteBtn.click();
+ expect(global.fetch).not.toHaveBeenCalled();
+
+ vi.advanceTimersByTime(1500);
+ expect(deleteBtn.disabled).toBe(false);
+
+ deleteBtn.click();
+ expect(global.fetch).toHaveBeenCalledWith(
+ '/api/lm/recipe/recipe-1',
+ expect.objectContaining({ method: 'DELETE' })
+ );
+ });
+
+ it('clears the pending arm timer when the modal closes during the countdown', async () => {
+ const card = await createCard();
+ card.showDeleteConfirmation();
+
+ const deleteBtn = document.querySelector('#deleteModal .delete-btn');
+ expect(deleteBtn.disabled).toBe(true);
+
+ vi.advanceTimersByTime(700);
+ capturedOnClose();
+
+ expect(deleteBtn.disabled).toBe(false);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('re-arms a full 1500ms countdown when the modal is reopened', async () => {
+ const card = await createCard();
+ card.showDeleteConfirmation();
+
+ vi.advanceTimersByTime(1400);
+ capturedOnClose();
+
+ card.showDeleteConfirmation();
+ const deleteBtn = document.querySelector('#deleteModal .delete-btn');
+ expect(deleteBtn.disabled).toBe(true);
+
+ vi.advanceTimersByTime(1499);
+ expect(deleteBtn.disabled).toBe(true);
+
+ vi.advanceTimersByTime(1);
+ expect(deleteBtn.disabled).toBe(false);
+ });
+});
diff --git a/tests/frontend/managers/BulkManager.bulkDelete.test.js b/tests/frontend/managers/BulkManager.bulkDelete.test.js
new file mode 100644
index 00000000..fc939d16
--- /dev/null
+++ b/tests/frontend/managers/BulkManager.bulkDelete.test.js
@@ -0,0 +1,372 @@
+import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
+
+const {
+ UNDO_HELPERS_MODULE,
+} = vi.hoisted(() => ({
+ UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
+}));
+
+const showToastMock = vi.fn();
+const showActionToastMock = vi.fn();
+const handleUndoDeleteMock = vi.fn();
+const resetAndReloadMock = vi.fn();
+const bulkDeleteModelsMock = vi.fn();
+const recipeBulkDeleteModelsMock = vi.fn();
+
+const loadingManagerStub = {
+ showSimpleLoading: vi.fn(),
+ hide: vi.fn(),
+ restoreProgressBar: vi.fn(),
+};
+
+const stateStub = {
+ currentPageType: 'loras',
+ bulkMode: false,
+ selectedModels: new Set(),
+ loadingManager: loadingManagerStub,
+ virtualScroller: { removeItemByFilePath: vi.fn() },
+ global: { settings: {} },
+};
+
+vi.mock('../../../static/js/state/index.js', () => ({
+ state: stateStub,
+ getCurrentPageState: vi.fn(),
+}));
+
+vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
+ showToast: showToastMock,
+ showActionToast: showActionToastMock,
+ copyToClipboard: vi.fn(),
+ sendLoraToWorkflow: vi.fn(),
+ sendEmbeddingToWorkflow: vi.fn(),
+ buildLoraSyntax: vi.fn(),
+ getNSFWLevelName: vi.fn(),
+}));
+
+vi.mock(UNDO_HELPERS_MODULE, () => ({
+ handleUndoDelete: handleUndoDeleteMock,
+}));
+
+vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
+ getModelApiClient: vi.fn(() => ({ bulkDeleteModels: bulkDeleteModelsMock })),
+ resetAndReload: resetAndReloadMock,
+}));
+
+vi.mock('../../../static/js/api/recipeApi.js', () => ({
+ RecipeSidebarApiClient: class {
+ constructor() {
+ this.bulkDeleteModels = recipeBulkDeleteModelsMock;
+ }
+ },
+ updateRecipeMetadata: vi.fn(),
+ extractRecipeId: vi.fn(),
+}));
+
+vi.mock('../../../static/js/api/apiConfig.js', () => ({
+ MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' },
+ MODEL_CONFIG: {},
+}));
+
+vi.mock('../../../static/js/managers/ModalManager.js', () => ({
+ modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
+}));
+
+vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
+ updateCardsForBulkMode: vi.fn(),
+}));
+
+vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
+ translate: vi.fn((key) => key),
+}));
+
+vi.mock('../../../static/js/utils/priorityTagHelpers.js', () => ({
+ getPriorityTagSuggestions: vi.fn(),
+}));
+
+vi.mock('../../../static/js/components/shared/NsfwLevelSelector.js', () => ({
+ getNsfwLevelSelector: vi.fn(),
+}));
+
+describe('BulkManager.confirmBulkDelete undo flows', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ stateStub.currentPageType = 'loras';
+ stateStub.bulkMode = false;
+ stateStub.selectedModels.clear();
+ stateStub.selectedModels.add('/models/a.safetensors');
+ stateStub.selectedModels.add('/models/b.safetensors');
+ handleUndoDeleteMock.mockResolvedValue(true);
+ });
+
+ afterEach(() => {
+ delete window.recipeManager;
+ delete window.modelDuplicatesManager;
+ });
+
+ async function createBulkManager() {
+ const { BulkManager } = await import('../../../static/js/managers/BulkManager.js');
+ return new BulkManager();
+ }
+
+ function lastActionToastOptions() {
+ const call = showActionToastMock.mock.calls[showActionToastMock.mock.calls.length - 1];
+ return call[3];
+ }
+
+ it('shows one action toast for the merged batch id and undoes it with a model refresh', async () => {
+ bulkDeleteModelsMock.mockResolvedValue({
+ success: true,
+ deleted_count: 2,
+ failed_count: 0,
+ errors: [],
+ batch_id: 'merged-1',
+ batch_ids: null,
+ });
+
+ const bulk = await createBulkManager();
+ await bulk.confirmBulkDelete();
+
+ expect(showActionToastMock).toHaveBeenCalledTimes(1);
+ expect(showActionToastMock).toHaveBeenCalledWith(
+ 'toast.undo.deletedBulk',
+ { count: 2 },
+ 'success',
+ expect.objectContaining({
+ actionText: 'toast.undo.action',
+ onAction: expect.any(Function),
+ })
+ );
+ // The legacy success and cancelled toasts must NOT fire
+ expect(showToastMock).not.toHaveBeenCalledWith(
+ 'toast.models.deletedSuccessfully',
+ expect.anything(),
+ expect.anything()
+ );
+ expect(showToastMock).not.toHaveBeenCalledWith(
+ 'toast.api.operationCancelled',
+ expect.anything(),
+ expect.anything()
+ );
+
+ lastActionToastOptions().onAction();
+
+ expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
+ expect(handleUndoDeleteMock).toHaveBeenCalledWith('merged-1', expect.any(Function));
+
+ // The undo refresh targets the model library
+ const refreshFn = handleUndoDeleteMock.mock.calls[0][1];
+ refreshFn();
+ expect(resetAndReloadMock).toHaveBeenCalledWith(true);
+ });
+
+ it('keeps the legacy success toast when both batch fields are null', async () => {
+ bulkDeleteModelsMock.mockResolvedValue({
+ success: true,
+ deleted_count: 2,
+ failed_count: 0,
+ errors: [],
+ batch_id: null,
+ batch_ids: null,
+ });
+
+ const bulk = await createBulkManager();
+ await bulk.confirmBulkDelete();
+
+ expect(showActionToastMock).not.toHaveBeenCalled();
+ expect(showToastMock).toHaveBeenCalledWith(
+ 'toast.models.deletedSuccessfully',
+ { count: 2, type: 'model' },
+ 'success'
+ );
+ });
+
+ it('undoes the batch_ids fallback sequentially with exactly one final refresh and restored toast', async () => {
+ bulkDeleteModelsMock.mockResolvedValue({
+ success: true,
+ deleted_count: 2,
+ failed_count: 0,
+ errors: [],
+ batch_id: null,
+ batch_ids: ['id-1', 'id-2'],
+ });
+
+ const bulk = await createBulkManager();
+ await bulk.confirmBulkDelete();
+
+ expect(showActionToastMock).toHaveBeenCalledTimes(1);
+ expect(showActionToastMock).toHaveBeenCalledWith(
+ 'toast.undo.deletedBulk',
+ { count: 2 },
+ 'success',
+ expect.objectContaining({ onAction: expect.any(Function) })
+ );
+
+ await lastActionToastOptions().onAction();
+
+ // Sequential suppressed undos in order
+ expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2);
+ expect(handleUndoDeleteMock.mock.calls[0]).toEqual(['id-1', null, { showToast: false, refresh: false }]);
+ expect(handleUndoDeleteMock.mock.calls[1]).toEqual(['id-2', null, { showToast: false, refresh: false }]);
+
+ // Exactly ONE final refresh and ONE restored toast
+ expect(resetAndReloadMock).toHaveBeenCalledTimes(1);
+ expect(resetAndReloadMock).toHaveBeenCalledWith(true);
+ expect(showToastMock).toHaveBeenCalledTimes(1);
+ expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
+ });
+
+ it('stops the fallback loop on the first failure and skips the final refresh', async () => {
+ bulkDeleteModelsMock.mockResolvedValue({
+ success: true,
+ deleted_count: 2,
+ failed_count: 0,
+ errors: [],
+ batch_id: null,
+ batch_ids: ['id-1', 'id-2', 'id-3'],
+ });
+ handleUndoDeleteMock
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(false);
+
+ const bulk = await createBulkManager();
+ await bulk.confirmBulkDelete();
+ await lastActionToastOptions().onAction();
+
+ // The loop stops at the failing second id — the third is never attempted
+ expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2);
+ expect(handleUndoDeleteMock.mock.calls[1][0]).toBe('id-2');
+
+ // The suppressed undo shows no error toast itself — the loop re-shows it
+ expect(showToastMock).toHaveBeenCalledTimes(1);
+ expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: '' }, 'error');
+
+ // No final refresh, no restored toast
+ expect(resetAndReloadMock).not.toHaveBeenCalled();
+ expect(showToastMock).not.toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
+ });
+
+ it('shows the action toast for a cancelled bulk that staged a subset (batch_id)', async () => {
+ bulkDeleteModelsMock.mockResolvedValue({
+ success: true,
+ deleted_count: 1,
+ failed_count: 0,
+ errors: [],
+ batch_id: 'partial-1',
+ batch_ids: null,
+ });
+
+ const bulk = await createBulkManager();
+ await bulk.confirmBulkDelete();
+
+ expect(showActionToastMock).toHaveBeenCalledTimes(1);
+ expect(showActionToastMock).toHaveBeenCalledWith(
+ 'toast.undo.deletedBulk',
+ { count: 1 },
+ 'success',
+ expect.objectContaining({ onAction: expect.any(Function) })
+ );
+ expect(showToastMock).not.toHaveBeenCalledWith(
+ 'toast.api.operationCancelled',
+ expect.anything(),
+ expect.anything()
+ );
+ });
+
+ it('shows the action toast for a cancelled bulk with the batch_ids fallback', async () => {
+ bulkDeleteModelsMock.mockResolvedValue({
+ success: true,
+ deleted_count: 1,
+ failed_count: 0,
+ errors: [],
+ batch_id: null,
+ batch_ids: ['partial-1'],
+ });
+
+ const bulk = await createBulkManager();
+ await bulk.confirmBulkDelete();
+
+ expect(showActionToastMock).toHaveBeenCalledTimes(1);
+ expect(showToastMock).not.toHaveBeenCalledWith(
+ 'toast.api.operationCancelled',
+ expect.anything(),
+ expect.anything()
+ );
+ });
+
+ it('keeps the cancelled toast when the user aborted and nothing was staged', async () => {
+ bulkDeleteModelsMock.mockResolvedValue({ success: false, cancelled: true });
+
+ const bulk = await createBulkManager();
+ await bulk.confirmBulkDelete();
+
+ expect(showToastMock).toHaveBeenCalledWith('toast.api.operationCancelled', {}, 'info');
+ expect(showActionToastMock).not.toHaveBeenCalled();
+ });
+
+ it('refreshes recipes through window.recipeManager when undoing a recipe bulk delete', async () => {
+ stateStub.currentPageType = 'recipes';
+ stateStub.selectedModels.clear();
+ stateStub.selectedModels.add('/recipes/a.webp');
+ const loadRecipesMock = vi.fn();
+ window.recipeManager = { loadRecipes: loadRecipesMock };
+
+ recipeBulkDeleteModelsMock.mockResolvedValue({
+ success: true,
+ deleted_count: 1,
+ failed_count: 0,
+ errors: [],
+ batch_id: 'recipe-batch-1',
+ batch_ids: null,
+ });
+
+ const bulk = await createBulkManager();
+ await bulk.confirmBulkDelete();
+
+ expect(showActionToastMock).toHaveBeenCalledTimes(1);
+ lastActionToastOptions().onAction();
+
+ expect(handleUndoDeleteMock).toHaveBeenCalledWith('recipe-batch-1', expect.any(Function));
+ const refreshFn = handleUndoDeleteMock.mock.calls[0][1];
+ refreshFn();
+ expect(loadRecipesMock).toHaveBeenCalledWith(true);
+ expect(resetAndReloadMock).not.toHaveBeenCalled();
+ });
+});
+
+describe('BulkManager.showBulkDeleteModal delay-activate', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ stateStub.currentPageType = 'loras';
+ stateStub.selectedModels.clear();
+ stateStub.selectedModels.add('/models/a.safetensors');
+ document.body.innerHTML = `
+
+
+
+
+
+
+
+ `;
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ document.body.innerHTML = '';
+ });
+
+ it('opens with the delete button disabled and enables it after 1500ms', async () => {
+ const { BulkManager } = await import('../../../static/js/managers/BulkManager.js');
+ const bulk = new BulkManager();
+ bulk.showBulkDeleteModal();
+
+ const deleteBtn = document.querySelector('#bulkDeleteModal .delete-btn');
+ expect(deleteBtn.disabled).toBe(true);
+
+ deleteBtn.click();
+ expect(bulkDeleteModelsMock).not.toHaveBeenCalled();
+
+ vi.advanceTimersByTime(1500);
+ expect(deleteBtn.disabled).toBe(false);
+ });
+});
diff --git a/tests/frontend/managers/ModalManager.focus.test.js b/tests/frontend/managers/ModalManager.focus.test.js
new file mode 100644
index 00000000..d7ae202e
--- /dev/null
+++ b/tests/frontend/managers/ModalManager.focus.test.js
@@ -0,0 +1,103 @@
+import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
+
+const MODAL_MANAGER_MODULE = new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname;
+
+function setupDom() {
+ document.body.innerHTML = `
+
+
+
+
+ `;
+}
+
+describe('ModalManager delete-modal focus handling', () => {
+ let ModalManager;
+ let manager;
+
+ beforeEach(async () => {
+ setupDom();
+ window.scrollTo = vi.fn();
+ ({ ModalManager } = await import(MODAL_MANAGER_MODULE));
+ manager = new ModalManager();
+ for (const id of ['deleteModal', 'excludeModal', 'plainModal']) {
+ manager.registerModal(id, {
+ element: document.getElementById(id),
+ onClose: () => {},
+ });
+ }
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('focuses the cancel button when a delete-type modal opens', () => {
+ const trigger = document.getElementById('triggerBtn');
+ trigger.focus();
+
+ manager.showModal('deleteModal');
+
+ expect(document.activeElement).toBe(
+ document.querySelector('#deleteModal .cancel-btn')
+ );
+ });
+
+ it('restores focus to the previously focused element on close', () => {
+ const trigger = document.getElementById('triggerBtn');
+ trigger.focus();
+
+ manager.showModal('deleteModal');
+ manager.closeModal('deleteModal');
+
+ expect(document.activeElement).toBe(trigger);
+ });
+
+ it('does not touch focus for a non-delete modal', () => {
+ const trigger = document.getElementById('triggerBtn');
+ trigger.focus();
+
+ manager.showModal('plainModal');
+
+ expect(document.activeElement).toBe(trigger);
+
+ manager.closeModal('plainModal');
+ expect(document.activeElement).toBe(trigger);
+ });
+
+ it('does not treat delete-modal-styled modals without a delete button as delete modals', () => {
+ const trigger = document.getElementById('triggerBtn');
+ trigger.focus();
+
+ manager.showModal('excludeModal');
+
+ expect(document.activeElement).toBe(trigger);
+
+ manager.closeModal('excludeModal');
+ expect(document.activeElement).toBe(trigger);
+ });
+
+ it('skips the focus restore when the previously focused element is gone', () => {
+ const trigger = document.getElementById('triggerBtn');
+ trigger.focus();
+
+ manager.showModal('deleteModal');
+ trigger.remove();
+
+ expect(() => manager.closeModal('deleteModal')).not.toThrow();
+ });
+});
diff --git a/tests/frontend/pages/checkpointsPage.test.js b/tests/frontend/pages/checkpointsPage.test.js
index ea982e57..d5b0462f 100644
--- a/tests/frontend/pages/checkpointsPage.test.js
+++ b/tests/frontend/pages/checkpointsPage.test.js
@@ -34,6 +34,7 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
closeDeleteModal: closeDeleteModalMock,
confirmExclude: confirmExcludeMock,
closeExcludeModal: closeExcludeModalMock,
+ armDeleteButton: vi.fn(),
}));
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
diff --git a/tests/frontend/pages/embeddingsPage.test.js b/tests/frontend/pages/embeddingsPage.test.js
index 4d0f754f..02bc12dd 100644
--- a/tests/frontend/pages/embeddingsPage.test.js
+++ b/tests/frontend/pages/embeddingsPage.test.js
@@ -26,6 +26,7 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
closeDeleteModal: closeDeleteModalMock,
confirmExclude: confirmExcludeMock,
closeExcludeModal: closeExcludeModalMock,
+ armDeleteButton: vi.fn(),
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
diff --git a/tests/frontend/pages/lorasPage.test.js b/tests/frontend/pages/lorasPage.test.js
index 1768fa35..02a6637e 100644
--- a/tests/frontend/pages/lorasPage.test.js
+++ b/tests/frontend/pages/lorasPage.test.js
@@ -36,6 +36,7 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
closeDeleteModal: closeDeleteModalMock,
confirmExclude: confirmExcludeMock,
closeExcludeModal: closeExcludeModalMock,
+ armDeleteButton: vi.fn(),
}));
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
diff --git a/tests/frontend/utils/modalUtils.test.js b/tests/frontend/utils/modalUtils.test.js
new file mode 100644
index 00000000..b1cab3da
--- /dev/null
+++ b/tests/frontend/utils/modalUtils.test.js
@@ -0,0 +1,283 @@
+import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
+
+const {
+ MODAL_UTILS_MODULE,
+ MODAL_MANAGER_MODULE,
+ API_FACTORY_MODULE,
+ UI_HELPERS_MODULE,
+ I18N_MODULE,
+ UNDO_HELPERS_MODULE,
+ STATE_MODULE,
+} = vi.hoisted(() => ({
+ MODAL_UTILS_MODULE: new URL('../../../static/js/utils/modalUtils.js', import.meta.url).pathname,
+ MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
+ API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
+ UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
+ I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
+ UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
+ STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
+}));
+
+const deleteModelMock = vi.fn();
+const resetAndReloadMock = vi.fn();
+const showActionToastMock = vi.fn();
+const handleUndoDeleteMock = vi.fn();
+const translateMock = vi.fn((key) => key);
+const closeModalMock = vi.fn();
+const showModalMock = vi.fn();
+
+vi.mock(MODAL_MANAGER_MODULE, () => ({
+ modalManager: {
+ getModal: vi.fn((id) => ({ element: document.getElementById(id) })),
+ showModal: showModalMock,
+ closeModal: closeModalMock,
+ },
+}));
+
+vi.mock(API_FACTORY_MODULE, () => ({
+ getModelApiClient: vi.fn(() => ({ deleteModel: deleteModelMock })),
+ resetAndReload: resetAndReloadMock,
+}));
+
+vi.mock(UI_HELPERS_MODULE, () => ({
+ showActionToast: showActionToastMock,
+}));
+
+vi.mock(I18N_MODULE, () => ({
+ translate: translateMock,
+}));
+
+vi.mock(UNDO_HELPERS_MODULE, () => ({
+ handleUndoDelete: handleUndoDeleteMock,
+}));
+
+describe('modalUtils confirmDelete undo flow', () => {
+ beforeEach(() => {
+ deleteModelMock.mockReset();
+ resetAndReloadMock.mockReset();
+ showActionToastMock.mockReset();
+ handleUndoDeleteMock.mockReset();
+ translateMock.mockClear();
+ closeModalMock.mockReset();
+ showModalMock.mockReset();
+ document.body.innerHTML = `
+
+
+ `;
+ window.modelDuplicatesManager = undefined;
+ });
+
+ it('shows the undo action toast and wires undo to handleUndoDelete + resetAndReload', async () => {
+ deleteModelMock.mockResolvedValue({ success: true, batch_id: 'batch-9' });
+
+ const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
+
+ showDeleteModal('/models/foo.safetensors');
+ await confirmDelete();
+
+ expect(deleteModelMock).toHaveBeenCalledWith('/models/foo.safetensors');
+ expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
+ expect(showActionToastMock).toHaveBeenCalledTimes(1);
+
+ const [key, params, type, options] = showActionToastMock.mock.calls[0];
+ expect(key).toBe('toast.undo.deleted');
+ expect(params).toEqual({ name: 'Foo Model' });
+ expect(type).toBe('success');
+ expect(options.actionText).toBe('toast.undo.action');
+ expect(translateMock).toHaveBeenCalledWith('toast.undo.action');
+
+ // Clicking Undo posts the batch and refreshes the model list
+ options.onAction();
+ expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
+ const [batchId, refreshFn] = handleUndoDeleteMock.mock.calls[0];
+ expect(batchId).toBe('batch-9');
+ expect(typeof refreshFn).toBe('function');
+
+ refreshFn();
+ expect(resetAndReloadMock).toHaveBeenCalledWith(true);
+ });
+
+ it('does not show the action toast when the delete was not staged', async () => {
+ deleteModelMock.mockResolvedValue({ success: true, batch_id: null });
+
+ const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
+
+ showDeleteModal('/models/foo.safetensors');
+ await confirmDelete();
+
+ expect(showActionToastMock).not.toHaveBeenCalled();
+ expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
+ });
+
+ it('falls back to the file name when no card is present', async () => {
+ deleteModelMock.mockResolvedValue({ success: true, batch_id: 'batch-10' });
+ document.querySelector('.model-card').remove();
+
+ const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
+
+ showDeleteModal('/models/bar.safetensors');
+ await confirmDelete();
+
+ expect(showActionToastMock).toHaveBeenCalledTimes(1);
+ expect(showActionToastMock.mock.calls[0][1]).toEqual({ name: 'bar.safetensors' });
+ });
+
+ it('refreshes the duplicates badge when the manager is available', async () => {
+ deleteModelMock.mockResolvedValue({ success: true, batch_id: 'batch-11' });
+ const updateBadge = vi.fn();
+ window.modelDuplicatesManager = { updateDuplicatesBadgeAfterRefresh: updateBadge };
+
+ const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
+
+ showDeleteModal('/models/foo.safetensors');
+ await confirmDelete();
+
+ expect(updateBadge).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('modalUtils armDeleteButton delay-activate', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ deleteModelMock.mockReset();
+ showModalMock.mockReset();
+ closeModalMock.mockReset();
+ document.body.innerHTML = `
+
+
+ `;
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('opens with the delete button disabled and enables it after exactly 1500ms', async () => {
+ const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
+
+ showDeleteModal('/models/foo.safetensors');
+
+ const deleteBtn = document.querySelector('#deleteModal .delete-btn');
+ expect(deleteBtn.disabled).toBe(true);
+
+ vi.advanceTimersByTime(1499);
+ expect(deleteBtn.disabled).toBe(true);
+
+ vi.advanceTimersByTime(1);
+ expect(deleteBtn.disabled).toBe(false);
+ });
+
+ it('clicking the disabled delete button fires nothing', async () => {
+ const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
+
+ showDeleteModal('/models/foo.safetensors');
+
+ const deleteBtn = document.querySelector('#deleteModal .delete-btn');
+ deleteBtn.click();
+
+ expect(deleteBtn.disabled).toBe(true);
+ expect(deleteModelMock).not.toHaveBeenCalled();
+ });
+
+ it('closing during the countdown clears the timer and reopening re-arms a full 1500ms', async () => {
+ const { showDeleteModal, closeDeleteModal } = await import(MODAL_UTILS_MODULE);
+
+ showDeleteModal('/models/foo.safetensors');
+ const deleteBtn = document.querySelector('#deleteModal .delete-btn');
+
+ vi.advanceTimersByTime(1400);
+ closeDeleteModal();
+ expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
+
+ // Reopen — the stale timer must not enable the button early
+ showDeleteModal('/models/foo.safetensors');
+ expect(deleteBtn.disabled).toBe(true);
+
+ vi.advanceTimersByTime(1499);
+ expect(deleteBtn.disabled).toBe(true);
+
+ vi.advanceTimersByTime(1);
+ expect(deleteBtn.disabled).toBe(false);
+ });
+});
+
+describe('modalUtils showDeleteModal warning copy and size line', () => {
+ beforeEach(() => {
+ showModalMock.mockReset();
+ closeModalMock.mockReset();
+ translateMock.mockClear();
+ translateMock.mockImplementation((key) => key);
+ document.body.innerHTML = `
+
+
+ `;
+ });
+
+ afterEach(async () => {
+ const { state } = await import(STATE_MODULE);
+ state.global.settings.delete_undo_enabled = true;
+ });
+
+ function modelInfoHtml() {
+ return document.querySelector('#deleteModal .delete-model-info').innerHTML;
+ }
+
+ it('shows the recoverable warning when delete_undo_enabled is truthy', async () => {
+ const { state } = await import(STATE_MODULE);
+ state.global.settings.delete_undo_enabled = true;
+
+ const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
+ showDeleteModal('/models/foo.safetensors');
+
+ expect(modelInfoHtml()).toContain('modals.deleteModel.recoverableWarning');
+ expect(modelInfoHtml()).not.toContain('modals.deleteModel.permanentWarning');
+ });
+
+ it('shows the permanent warning when delete_undo_enabled is falsy', async () => {
+ const { state } = await import(STATE_MODULE);
+ state.global.settings.delete_undo_enabled = false;
+
+ const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
+ showDeleteModal('/models/foo.safetensors');
+
+ expect(modelInfoHtml()).toContain('modals.deleteModel.permanentWarning');
+ expect(modelInfoHtml()).not.toContain('modals.deleteModel.recoverableWarning');
+ });
+
+ it('falls back to the neutral permanent warning when the setting is unavailable', async () => {
+ const { state } = await import(STATE_MODULE);
+ delete state.global.settings.delete_undo_enabled;
+
+ const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
+ showDeleteModal('/models/foo.safetensors');
+
+ expect(modelInfoHtml()).toContain('modals.deleteModel.permanentWarning');
+ });
+
+ it('appends a formatted "Frees {size}" line when the card carries a file size', async () => {
+ translateMock.mockImplementation((key, params) =>
+ params && params.size ? `${key} ${params.size}` : key
+ );
+
+ const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
+ showDeleteModal('/models/foo.safetensors');
+
+ expect(modelInfoHtml()).toContain('modals.deleteModel.freesSpace 2.0 GB');
+ });
+
+ it('omits the size line when the card has no file size dataset', async () => {
+ document.querySelector('.model-card').removeAttribute('data-file_size');
+
+ const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
+ showDeleteModal('/models/foo.safetensors');
+
+ expect(modelInfoHtml()).not.toContain('modals.deleteModel.freesSpace');
+ });
+});
diff --git a/tests/frontend/utils/uiHelpers.dom.test.js b/tests/frontend/utils/uiHelpers.dom.test.js
index 23b40ebe..60c5737a 100644
--- a/tests/frontend/utils/uiHelpers.dom.test.js
+++ b/tests/frontend/utils/uiHelpers.dom.test.js
@@ -110,6 +110,142 @@ describe('UI helper DOM utilities', () => {
expect(toast.classList.contains('show')).toBe(false);
});
+ it('renders an action button and countdown span for action toasts', async () => {
+ vi.useFakeTimers();
+ translateMock.mockReturnValue('Deleted Demo Model');
+
+ const { showActionToast } = await import(UI_HELPERS_MODULE);
+
+ const onAction = vi.fn();
+ showActionToast('toast.undo.deleted', { name: 'Demo Model' }, 'success', {
+ actionText: 'Undo',
+ onAction,
+ });
+
+ const toast = document.querySelector('.toast-container .toast');
+ expect(toast).not.toBeNull();
+ expect(toast.classList.contains('toast-success')).toBe(true);
+ expect(translateMock).toHaveBeenCalledWith('toast.undo.deleted', { name: 'Demo Model' });
+
+ const button = toast.querySelector('.toast-action-btn');
+ expect(button).not.toBeNull();
+ expect(button.textContent).toBe('Undo');
+
+ const countdown = toast.querySelector('.toast-countdown');
+ expect(countdown).not.toBeNull();
+ expect(countdown.textContent).toBe('(30s)');
+
+ // Ticking one second updates the countdown text
+ vi.advanceTimersByTime(1000);
+ expect(countdown.textContent).toBe('(29s)');
+
+ // Drain remaining timers so no state leaks into other tests
+ vi.advanceTimersByTime(30000);
+ });
+
+ it('invokes onAction once and dismisses immediately when the button is clicked', async () => {
+ vi.useFakeTimers();
+ translateMock.mockReturnValue('Deleted Demo Model');
+
+ const { showActionToast } = await import(UI_HELPERS_MODULE);
+
+ const onAction = vi.fn();
+ showActionToast('toast.undo.deleted', {}, 'success', {
+ actionText: 'Undo',
+ onAction,
+ });
+
+ const toast = document.querySelector('.toast-container .toast');
+ toast.querySelector('.toast-action-btn').click();
+
+ expect(onAction).toHaveBeenCalledTimes(1);
+ expect(toast.classList.contains('show')).toBe(false);
+
+ // Dismissal removes the element after the transition ends
+ toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
+ expect(document.querySelector('.toast-container .toast')).toBeNull();
+ expect(document.querySelector('.toast-container')).toBeNull();
+ });
+
+ it('calls onAction exactly once when the button is double-clicked', async () => {
+ vi.useFakeTimers();
+ translateMock.mockReturnValue('Deleted Demo Model');
+
+ const { showActionToast } = await import(UI_HELPERS_MODULE);
+
+ const onAction = vi.fn();
+ showActionToast('toast.undo.deleted', {}, 'success', {
+ actionText: 'Undo',
+ onAction,
+ });
+
+ const button = document.querySelector('.toast-action-btn');
+ button.click();
+ button.click();
+
+ expect(onAction).toHaveBeenCalledTimes(1);
+ });
+
+ it('dismisses the toast when the countdown reaches zero', async () => {
+ vi.useFakeTimers();
+ translateMock.mockReturnValue('Deleted Demo Model');
+ // Async RAF mirrors production ordering: the countdown interval is
+ // registered before the dismiss timeout, so the final tick displays (0s)
+ globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0);
+
+ const { showActionToast } = await import(UI_HELPERS_MODULE);
+
+ showActionToast('toast.undo.deleted', {}, 'success', {
+ actionText: 'Undo',
+ onAction: vi.fn(),
+ durationMs: 3000,
+ });
+
+ vi.advanceTimersByTime(0); // Flush the RAF callback
+ const toast = document.querySelector('.toast-container .toast');
+ const countdown = toast.querySelector('.toast-countdown');
+ expect(countdown.textContent).toBe('(3s)');
+
+ vi.advanceTimersByTime(2000);
+ expect(countdown.textContent).toBe('(1s)');
+ expect(toast.classList.contains('show')).toBe(true);
+
+ vi.advanceTimersByTime(1000);
+ expect(countdown.textContent).toBe('(0s)');
+ expect(toast.classList.contains('show')).toBe(false);
+
+ toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
+ expect(document.querySelector('.toast-container .toast')).toBeNull();
+ });
+
+ it('clears the countdown interval when dismissed via the action button', async () => {
+ vi.useFakeTimers();
+ translateMock.mockReturnValue('Deleted Demo Model');
+
+ const { showActionToast } = await import(UI_HELPERS_MODULE);
+
+ const onAction = vi.fn();
+ showActionToast('toast.undo.deleted', {}, 'success', {
+ actionText: 'Undo',
+ onAction,
+ durationMs: 30000,
+ });
+
+ const toast = document.querySelector('.toast-container .toast');
+ const countdown = toast.querySelector('.toast-countdown');
+ toast.querySelector('.toast-action-btn').click();
+
+ // Advancing past the full duration must not tick the countdown further,
+ // throw, or re-dismiss the already-dismissed toast
+ vi.advanceTimersByTime(60000);
+ expect(countdown.textContent).toBe('(30s)');
+ expect(onAction).toHaveBeenCalledTimes(1);
+ expect(toast.classList.contains('show')).toBe(false);
+
+ toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
+ expect(document.querySelector('.toast-container')).toBeNull();
+ });
+
it('toggles the persisted theme and updates DOM attributes', async () => {
getStorageItemMock.mockReturnValue('light');
document.body.innerHTML = '';
diff --git a/tests/frontend/utils/undoHelpers.test.js b/tests/frontend/utils/undoHelpers.test.js
new file mode 100644
index 00000000..88d368f6
--- /dev/null
+++ b/tests/frontend/utils/undoHelpers.test.js
@@ -0,0 +1,133 @@
+import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
+
+const {
+ UI_HELPERS_MODULE,
+ UNDO_HELPERS_MODULE,
+} = vi.hoisted(() => ({
+ UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
+ UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
+}));
+
+const showToastMock = vi.fn();
+
+vi.mock(UI_HELPERS_MODULE, () => ({
+ showToast: showToastMock,
+}));
+
+describe('handleUndoDelete', () => {
+ beforeEach(() => {
+ showToastMock.mockReset();
+ });
+
+ afterEach(() => {
+ delete global.fetch;
+ });
+
+ it('posts the batch id, refreshes once, and shows the restored toast on 200', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ success: true, restored: ['/models/foo.safetensors'] }),
+ });
+
+ const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
+
+ const refreshFn = vi.fn();
+ const result = await handleUndoDelete('batch-1', refreshFn);
+
+ expect(result).toBe(true);
+ expect(global.fetch).toHaveBeenCalledWith('/api/lm/undo-delete', expect.objectContaining({
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ batch_id: 'batch-1' }),
+ }));
+ expect(refreshFn).toHaveBeenCalledTimes(1);
+ expect(showToastMock).toHaveBeenCalledTimes(1);
+ expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
+ });
+
+ it('shows the expired toast for a 404 whose error body mentions expired', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ statusText: 'Not Found',
+ json: async () => ({ success: false, error: 'Undo batch expired and was purged' }),
+ });
+
+ const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
+
+ const refreshFn = vi.fn();
+ const result = await handleUndoDelete('batch-gone', refreshFn);
+
+ expect(result).toBe(false);
+ expect(refreshFn).not.toHaveBeenCalled();
+ expect(showToastMock).toHaveBeenCalledTimes(1);
+ expect(showToastMock).toHaveBeenCalledWith('toast.undo.expired', {}, 'error');
+ });
+
+ it('shows the failed toast with the server message for a 404 occupied path', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ statusText: 'Not Found',
+ json: async () => ({ success: false, error: 'Target path occupied' }),
+ });
+
+ const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
+
+ const result = await handleUndoDelete('batch-occupied', vi.fn());
+
+ expect(result).toBe(false);
+ expect(showToastMock).toHaveBeenCalledTimes(1);
+ expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: 'Target path occupied' }, 'error');
+ });
+
+ it('shows the failed toast when the error body is not parseable', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ statusText: 'Not Found',
+ json: async () => { throw new Error('invalid json'); },
+ });
+
+ const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
+
+ const result = await handleUndoDelete('batch-malformed', vi.fn());
+
+ expect(result).toBe(false);
+ expect(showToastMock).toHaveBeenCalledTimes(1);
+ expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: 'Not Found' }, 'error');
+ });
+
+ it('shows the failed toast on network errors', async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error('connection reset'));
+
+ const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
+
+ const refreshFn = vi.fn();
+ const result = await handleUndoDelete('batch-net', refreshFn);
+
+ expect(result).toBe(false);
+ expect(refreshFn).not.toHaveBeenCalled();
+ expect(showToastMock).toHaveBeenCalledTimes(1);
+ expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: 'connection reset' }, 'error');
+ });
+
+ it('suppresses the toast and refresh when the options disable them', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ success: true }),
+ });
+
+ const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
+
+ const refreshFn = vi.fn();
+ const result = await handleUndoDelete('batch-quiet', refreshFn, { showToast: false, refresh: false });
+
+ expect(result).toBe(true);
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ expect(refreshFn).not.toHaveBeenCalled();
+ expect(showToastMock).not.toHaveBeenCalled();
+ });
+});