feat(delete): shorten undo window to 20s and make undo toast dismissible

This commit is contained in:
Will Miao
2026-08-12 19:15:03 +08:00
parent 680f0a57f5
commit 1ca99294c9
15 changed files with 150 additions and 90 deletions
+9 -9
View File
@@ -1277,13 +1277,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}", "freesSpace": "Gibt {size} frei",
"title": "Modell löschen", "title": "Modell löschen",
"message": "Sind Sie sicher, dass Sie dieses Modell und alle zugehörigen Dateien löschen möchten?", "message": "Sind Sie sicher, dass Sie dieses Modell und alle zugehörigen Dateien löschen möchten?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo." "recoverableWarning": "Die Datei wird nach 20 Sekunden endgültig gelöscht, sofern Sie nicht rückgängig machen."
}, },
"deleteRecipe": { "deleteRecipe": {
"recoverableWarning": "Diese Aktion kann 30 Sekunden lang rückgängig gemacht werden." "recoverableWarning": "Diese Aktion kann 20 Sekunden lang rückgängig gemacht werden."
}, },
"excludeModel": { "excludeModel": {
"title": "Modell ausschließen", "title": "Modell ausschließen",
@@ -2120,12 +2120,12 @@
"copyFailed": "Kopieren fehlgeschlagen" "copyFailed": "Kopieren fehlgeschlagen"
}, },
"undo": { "undo": {
"action": "[TODO: Translate] Undo", "action": "Rückgängig",
"deleted": "[TODO: Translate] Deleted {name}", "deleted": "Gelöscht: {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)", "deletedBulk": "{count} Element(e) gelöscht",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.", "expired": "Undo-Fenster abgelaufen. Das Element wurde endgültig gelöscht.",
"failed": "[TODO: Translate] Undo failed: {error}", "failed": "Rückgängig machen fehlgeschlagen: {error}",
"restored": "[TODO: Translate] Item restored" "restored": "Element wiederhergestellt"
}, },
"virtual": { "virtual": {
"loadFailed": "Fehler beim Laden der Elemente", "loadFailed": "Fehler beim Laden der Elemente",
+2 -2
View File
@@ -1280,10 +1280,10 @@
"freesSpace": "Frees {size}", "freesSpace": "Frees {size}",
"title": "Delete Model", "title": "Delete Model",
"message": "Are you sure you want to delete this model and all associated files?", "message": "Are you sure you want to delete this model and all associated files?",
"recoverableWarning": "This will permanently delete the file after 30 seconds unless you undo." "recoverableWarning": "This will permanently delete the file after 20 seconds unless you undo."
}, },
"deleteRecipe": { "deleteRecipe": {
"recoverableWarning": "This action can be undone for 30 seconds." "recoverableWarning": "This action can be undone for 20 seconds."
}, },
"excludeModel": { "excludeModel": {
"title": "Exclude Model", "title": "Exclude Model",
+9 -9
View File
@@ -1277,13 +1277,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}", "freesSpace": "Libera {size}",
"title": "Eliminar modelo", "title": "Eliminar modelo",
"message": "¿Estás seguro de que quieres eliminar este modelo y todos los archivos asociados?", "message": "¿Estás seguro de que quieres eliminar este modelo y todos los archivos asociados?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo." "recoverableWarning": "El archivo se eliminará permanentemente después de 20 segundos a menos que deshaga la acción."
}, },
"deleteRecipe": { "deleteRecipe": {
"recoverableWarning": "Esta acción se puede deshacer durante 30 segundos." "recoverableWarning": "Esta acción se puede deshacer durante 20 segundos."
}, },
"excludeModel": { "excludeModel": {
"title": "Excluir modelo", "title": "Excluir modelo",
@@ -2120,12 +2120,12 @@
"copyFailed": "Error al copiar" "copyFailed": "Error al copiar"
}, },
"undo": { "undo": {
"action": "[TODO: Translate] Undo", "action": "Deshacer",
"deleted": "[TODO: Translate] Deleted {name}", "deleted": "Eliminado: {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)", "deletedBulk": "{count} elemento(s) eliminado(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.", "expired": "La ventana de deshacer ha caducado. El elemento se eliminó permanentemente.",
"failed": "[TODO: Translate] Undo failed: {error}", "failed": "No se pudo deshacer: {error}",
"restored": "[TODO: Translate] Item restored" "restored": "Elemento restaurado"
}, },
"virtual": { "virtual": {
"loadFailed": "Error al cargar elementos", "loadFailed": "Error al cargar elementos",
+9 -9
View File
@@ -1277,13 +1277,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}", "freesSpace": "Libère {size}",
"title": "Supprimer le modèle", "title": "Supprimer le modèle",
"message": "Êtes-vous sûr de vouloir supprimer ce modèle et tous les fichiers associés ?", "message": "Êtes-vous sûr de vouloir supprimer ce modèle et tous les fichiers associés ?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo." "recoverableWarning": "Le fichier sera définitivement supprimé après 20 secondes, sauf si vous annulez."
}, },
"deleteRecipe": { "deleteRecipe": {
"recoverableWarning": "Cette action peut être annulée pendant 30 secondes." "recoverableWarning": "Cette action peut être annulée pendant 20 secondes."
}, },
"excludeModel": { "excludeModel": {
"title": "Exclure le modèle", "title": "Exclure le modèle",
@@ -2120,12 +2120,12 @@
"copyFailed": "Échec de la copie" "copyFailed": "Échec de la copie"
}, },
"undo": { "undo": {
"action": "[TODO: Translate] Undo", "action": "Annuler",
"deleted": "[TODO: Translate] Deleted {name}", "deleted": "Supprimé : {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)", "deletedBulk": "{count} élément(s) supprimé(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.", "expired": "La fenêtre d'annulation a expiré. L'élément a été définitivement supprimé.",
"failed": "[TODO: Translate] Undo failed: {error}", "failed": "Échec de l'annulation : {error}",
"restored": "[TODO: Translate] Item restored" "restored": "Élément restauré"
}, },
"virtual": { "virtual": {
"loadFailed": "Échec du chargement des éléments", "loadFailed": "Échec du chargement des éléments",
+9 -9
View File
@@ -1277,13 +1277,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}", "freesSpace": "מפנה {size}",
"title": "מחק מודל", "title": "מחק מודל",
"message": "האם אתה בטוח שברצונך למחוק מודל זה וכל הקבצים הנלווים?", "message": "האם אתה בטוח שברצונך למחוק מודל זה וכל הקבצים הנלווים?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo." "recoverableWarning": "הקובץ יימחק לצמיתות לאחר 20 שניות, אלא אם תבטלו את הפעולה."
}, },
"deleteRecipe": { "deleteRecipe": {
"recoverableWarning": "ניתן לבטל פעולה זו תוך 30 שניות." "recoverableWarning": "ניתן לבטל פעולה זו תוך 20 שניות."
}, },
"excludeModel": { "excludeModel": {
"title": "החרג מודל", "title": "החרג מודל",
@@ -2120,12 +2120,12 @@
"copyFailed": "ההעתקה נכשלה" "copyFailed": "ההעתקה נכשלה"
}, },
"undo": { "undo": {
"action": "[TODO: Translate] Undo", "action": "בטל",
"deleted": "[TODO: Translate] Deleted {name}", "deleted": "נמחק: {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)", "deletedBulk": "{count} פריטים נמחקו",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.", "expired": "חלון הביטול פג. הפריט נמחק לצמיתות.",
"failed": "[TODO: Translate] Undo failed: {error}", "failed": "הביטול נכשל: {error}",
"restored": "[TODO: Translate] Item restored" "restored": "הפריט שוחזר"
}, },
"virtual": { "virtual": {
"loadFailed": "טעינת הפריטים נכשלה", "loadFailed": "טעינת הפריטים נכשלה",
+9 -9
View File
@@ -1277,13 +1277,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}", "freesSpace": "{size} を解放します",
"title": "モデルを削除", "title": "モデルを削除",
"message": "このモデルと関連するすべてのファイルを削除してもよろしいですか?", "message": "このモデルと関連するすべてのファイルを削除してもよろしいですか?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo." "recoverableWarning": "元に戻さない場合、このファイルは20秒後に完全に削除されます。"
}, },
"deleteRecipe": { "deleteRecipe": {
"recoverableWarning": "この操作は30秒以内であれば元に戻せます。" "recoverableWarning": "この操作は20秒以内であれば元に戻せます。"
}, },
"excludeModel": { "excludeModel": {
"title": "モデルを除外", "title": "モデルを除外",
@@ -2120,12 +2120,12 @@
"copyFailed": "コピーに失敗しました" "copyFailed": "コピーに失敗しました"
}, },
"undo": { "undo": {
"action": "[TODO: Translate] Undo", "action": "元に戻す",
"deleted": "[TODO: Translate] Deleted {name}", "deleted": "{name} を削除しました",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)", "deletedBulk": "{count} 個のアイテムを削除しました",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.", "expired": "元に戻せる時間が経過しました。アイテムは完全に削除されました。",
"failed": "[TODO: Translate] Undo failed: {error}", "failed": "元に戻せませんでした: {error}",
"restored": "[TODO: Translate] Item restored" "restored": "アイテムを復元しました"
}, },
"virtual": { "virtual": {
"loadFailed": "アイテムの読み込みに失敗しました", "loadFailed": "アイテムの読み込みに失敗しました",
+9 -9
View File
@@ -1277,13 +1277,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}", "freesSpace": "{size} 확보",
"title": "모델 삭제", "title": "모델 삭제",
"message": "이 모델과 모든 관련 파일을 삭제하시겠습니까?", "message": "이 모델과 모든 관련 파일을 삭제하시겠습니까?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo." "recoverableWarning": "실행 취소하지 않으면 20초 후에 파일이 영구적으로 삭제됩니다."
}, },
"deleteRecipe": { "deleteRecipe": {
"recoverableWarning": "이 작업은 30초 이내에 실행 취소할 수 있습니다." "recoverableWarning": "이 작업은 20초 이내에 실행 취소할 수 있습니다."
}, },
"excludeModel": { "excludeModel": {
"title": "모델 제외", "title": "모델 제외",
@@ -2120,12 +2120,12 @@
"copyFailed": "복사 실패" "copyFailed": "복사 실패"
}, },
"undo": { "undo": {
"action": "[TODO: Translate] Undo", "action": "실행 취소",
"deleted": "[TODO: Translate] Deleted {name}", "deleted": "{name} 삭제됨",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)", "deletedBulk": "{count}개 항목 삭제됨",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.", "expired": "실행 취소 기간이 만료되었습니다. 항목이 영구적으로 삭제되었습니다.",
"failed": "[TODO: Translate] Undo failed: {error}", "failed": "실행 취소 실패: {error}",
"restored": "[TODO: Translate] Item restored" "restored": "항목이 복원되었습니다"
}, },
"virtual": { "virtual": {
"loadFailed": "항목 로딩 실패", "loadFailed": "항목 로딩 실패",
+9 -9
View File
@@ -1277,13 +1277,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}", "freesSpace": "Освобождает {size}",
"title": "Удалить модель", "title": "Удалить модель",
"message": "Вы уверены, что хотите удалить эту модель и все связанные файлы?", "message": "Вы уверены, что хотите удалить эту модель и все связанные файлы?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo." "recoverableWarning": "Файл будет удалён навсегда через 20 секунд, если вы не отмените действие."
}, },
"deleteRecipe": { "deleteRecipe": {
"recoverableWarning": "Это действие можно отменить в течение 30 секунд." "recoverableWarning": "Это действие можно отменить в течение 20 секунд."
}, },
"excludeModel": { "excludeModel": {
"title": "Исключить модель", "title": "Исключить модель",
@@ -2120,12 +2120,12 @@
"copyFailed": "Копирование не удалось" "copyFailed": "Копирование не удалось"
}, },
"undo": { "undo": {
"action": "[TODO: Translate] Undo", "action": "Отменить",
"deleted": "[TODO: Translate] Deleted {name}", "deleted": "Удалено: {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)", "deletedBulk": "Удалено: {count} шт.",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.", "expired": "Время отмены истекло. Элемент был удалён навсегда.",
"failed": "[TODO: Translate] Undo failed: {error}", "failed": "Не удалось отменить: {error}",
"restored": "[TODO: Translate] Item restored" "restored": "Элемент восстановлен"
}, },
"virtual": { "virtual": {
"loadFailed": "Не удалось загрузить элементы", "loadFailed": "Не удалось загрузить элементы",
+9 -9
View File
@@ -1277,13 +1277,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}", "freesSpace": "释放 {size}",
"title": "删除模型", "title": "删除模型",
"message": "你确定要删除此模型及所有相关文件吗?", "message": "你确定要删除此模型及所有相关文件吗?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo." "recoverableWarning": "如果不撤销,文件将在 20 秒后被永久删除。"
}, },
"deleteRecipe": { "deleteRecipe": {
"recoverableWarning": "此操作可在 30 秒内撤销。" "recoverableWarning": "此操作可在 20 秒内撤销。"
}, },
"excludeModel": { "excludeModel": {
"title": "排除模型", "title": "排除模型",
@@ -2120,12 +2120,12 @@
"copyFailed": "复制失败" "copyFailed": "复制失败"
}, },
"undo": { "undo": {
"action": "[TODO: Translate] Undo", "action": "撤销",
"deleted": "[TODO: Translate] Deleted {name}", "deleted": "已删除 {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)", "deletedBulk": "已删除 {count} 个项目",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.", "expired": "撤销窗口已过期,项目已被永久删除。",
"failed": "[TODO: Translate] Undo failed: {error}", "failed": "撤销失败:{error}",
"restored": "[TODO: Translate] Item restored" "restored": "项目已恢复"
}, },
"virtual": { "virtual": {
"loadFailed": "加载项目失败", "loadFailed": "加载项目失败",
+9 -9
View File
@@ -1277,13 +1277,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}", "freesSpace": "釋放 {size}",
"title": "刪除模型", "title": "刪除模型",
"message": "您確定要刪除此模型及所有相關檔案嗎?", "message": "您確定要刪除此模型及所有相關檔案嗎?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo." "recoverableWarning": "如果未復原,檔案將在 20 秒後被永久刪除。"
}, },
"deleteRecipe": { "deleteRecipe": {
"recoverableWarning": "此操作可在 30 秒內復原。" "recoverableWarning": "此操作可在 20 秒內復原。"
}, },
"excludeModel": { "excludeModel": {
"title": "排除模型", "title": "排除模型",
@@ -2120,12 +2120,12 @@
"copyFailed": "複製失敗" "copyFailed": "複製失敗"
}, },
"undo": { "undo": {
"action": "[TODO: Translate] Undo", "action": "復原",
"deleted": "[TODO: Translate] Deleted {name}", "deleted": "已刪除 {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)", "deletedBulk": "已刪除 {count} 個項目",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.", "expired": "復原視窗已過期,項目已被永久刪除。",
"failed": "[TODO: Translate] Undo failed: {error}", "failed": "復原失敗:{error}",
"restored": "[TODO: Translate] Item restored" "restored": "項目已還原"
}, },
"virtual": { "virtual": {
"loadFailed": "載入項目失敗", "loadFailed": "載入項目失敗",
+1 -1
View File
@@ -43,7 +43,7 @@ from ..utils import settings_paths
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Undo window in seconds before a staged batch becomes purge-eligible. # Undo window in seconds before a staged batch becomes purge-eligible.
PENDING_DELETE_TTL_SECONDS = 30 PENDING_DELETE_TTL_SECONDS = 20
# Hidden staging directory name placed inside each deleted model's own folder # Hidden staging directory name placed inside each deleted model's own folder
# (sibling of the model artifacts) and under the settings dir for recipes. # (sibling of the model artifacts) and under the settings dir for recipes.
PENDING_DELETE_DIR_NAME = ".lm-pending-delete" PENDING_DELETE_DIR_NAME = ".lm-pending-delete"
+18
View File
@@ -107,6 +107,24 @@
white-space: nowrap; white-space: nowrap;
} }
.toast-close-btn {
flex-shrink: 0;
padding: 0 4px;
background: transparent;
color: var(--text-color);
border: none;
border-radius: 4px;
font-size: 1.1em;
line-height: 1;
opacity: 0.5;
cursor: pointer;
transition: opacity 0.2s ease;
}
.toast-close-btn:hover {
opacity: 1;
}
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.toast { .toast {
+16 -2
View File
@@ -236,11 +236,11 @@ export function showToast(key, params = {}, type = 'info', fallback = null) {
* @param {Object} [options] * @param {Object} [options]
* @param {string} [options.actionText] - Label for the action button (button omitted when empty) * @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 {Function} [options.onAction] - Callback invoked at most once on button click
* @param {number} [options.durationMs=30000] - How long the toast stays visible * @param {number} [options.durationMs=20000] - How long the toast stays visible
* @param {boolean} [options.countdown=true] - Show a ticking `(N)s` countdown * @param {boolean} [options.countdown=true] - Show a ticking `(N)s` countdown
*/ */
export function showActionToast(key, params = {}, type = 'info', options = {}) { export function showActionToast(key, params = {}, type = 'info', options = {}) {
const { actionText, onAction, durationMs = 30000, countdown = true } = options; const { actionText, onAction, durationMs = 20000, countdown = true } = options;
const isPlainMessage = typeof key === 'string' && /\s/.test(key); const isPlainMessage = typeof key === 'string' && /\s/.test(key);
const message = isPlainMessage ? key : translate(key, params); const message = isPlainMessage ? key : translate(key, params);
@@ -295,6 +295,20 @@ export function showActionToast(key, params = {}, type = 'info', options = {}) {
} }
}, 1000); }, 1000);
} }
// Manual close button: hides the toast early without firing onAction. The
// backend undo window keeps running and the batch is purged when it expires.
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'toast-close-btn';
closeBtn.textContent = '×';
closeBtn.setAttribute('aria-label', translate('common.actions.close'));
closeBtn.addEventListener('click', (event) => {
event.preventDefault();
clearCountdown();
dismiss();
});
toast.append(closeBtn);
} }
export function restoreFolderFilter() { export function restoreFolderFilter() {
+1 -1
View File
@@ -67,7 +67,7 @@ describe('translate() with real en.json locale', () => {
installWindowI18n(); installWindowI18n();
expect(translate('modals.deleteModel.recoverableWarning')).toBe( expect(translate('modals.deleteModel.recoverableWarning')).toBe(
'This will permanently delete the file after 30 seconds unless you undo.', 'This will permanently delete the file after 20 seconds unless you undo.',
); );
expect(translate('modals.deleteModel.freesSpace', { size: '1.2 MB' })).toBe( expect(translate('modals.deleteModel.freesSpace', { size: '1.2 MB' })).toBe(
'Frees 1.2 MB', 'Frees 1.2 MB',
+31 -3
View File
@@ -133,14 +133,14 @@ describe('UI helper DOM utilities', () => {
const countdown = toast.querySelector('.toast-countdown'); const countdown = toast.querySelector('.toast-countdown');
expect(countdown).not.toBeNull(); expect(countdown).not.toBeNull();
expect(countdown.textContent).toBe('(30s)'); expect(countdown.textContent).toBe('(20s)');
// Ticking one second updates the countdown text // Ticking one second updates the countdown text
vi.advanceTimersByTime(1000); vi.advanceTimersByTime(1000);
expect(countdown.textContent).toBe('(29s)'); expect(countdown.textContent).toBe('(19s)');
// Drain remaining timers so no state leaks into other tests // Drain remaining timers so no state leaks into other tests
vi.advanceTimersByTime(30000); vi.advanceTimersByTime(20000);
}); });
it('invokes onAction once and dismisses immediately when the button is clicked', async () => { it('invokes onAction once and dismisses immediately when the button is clicked', async () => {
@@ -186,6 +186,34 @@ describe('UI helper DOM utilities', () => {
expect(onAction).toHaveBeenCalledTimes(1); expect(onAction).toHaveBeenCalledTimes(1);
}); });
it('dismisses the toast via the close button without firing onAction', 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');
const countdown = toast.querySelector('.toast-countdown');
toast.querySelector('.toast-close-btn').click();
expect(onAction).not.toHaveBeenCalled();
expect(toast.classList.contains('show')).toBe(false);
// 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('(20s)');
toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
expect(document.querySelector('.toast-container .toast')).toBeNull();
});
it('dismisses the toast when the countdown reaches zero', async () => { it('dismisses the toast when the countdown reaches zero', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
translateMock.mockReturnValue('Deleted Demo Model'); translateMock.mockReturnValue('Deleted Demo Model');