fix(delete): one editor gate for every delete path

Batch Delete had the bug the single ✕ just lost: it never touched the open
editor, so an entry could be trashed while its editor stayed open on it,
unsaved edits and all. Same hole in batch purge, permanent delete and empty
trash.

closeEditorForDeleted(ids, alreadyConfirmed) now owns the rule for all five
call sites: close the editor only when it holds one of the deleted ids, and
show the discard prompt only when no delete modal was shown (one modal per
action; cancelling it deletes nothing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-22 23:22:14 +01:00
parent 4b9ffaab62
commit 3dbce780d2
+19 -11
View File
@@ -3562,6 +3562,7 @@ async function batchDelete() {
danger: true, danger: true,
}); });
if (!ok) return; if (!ok) return;
await closeEditorForDeleted(ids, true);
for (const id of ids) { for (const id of ids) {
try { try {
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() }); await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
@@ -3599,6 +3600,7 @@ async function batchPermDelete() {
danger: true, danger: true,
}); });
if (!ok) return; if (!ok) return;
await closeEditorForDeleted(ids, true);
for (const id of ids) { for (const id of ids) {
try { try {
await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() }); await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() });
@@ -5162,6 +5164,7 @@ async function permanentDelete(id) {
danger: true, danger: true,
}); });
if (!ok) return; if (!ok) return;
await closeEditorForDeleted([id], true);
try { try {
await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() }); await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() });
toast('Deleted permanently'); toast('Deleted permanently');
@@ -5180,6 +5183,7 @@ async function emptyTrash() {
danger: true, danger: true,
}); });
if (!ok) return; if (!ok) return;
await closeEditorForDeleted(state.trashed.map(t => t.id), true);
try { try {
await api('/entries/trash/empty', { method: 'DELETE', headers: authHeaders() }); await api('/entries/trash/empty', { method: 'DELETE', headers: authHeaders() });
toast('Trash emptied'); toast('Trash emptied');
@@ -5276,8 +5280,21 @@ function flashEntry(id) {
}, 50); }, 50);
} }
// Every delete path (single, batch, permanent) routes its editor handling
// through here. The open editor must not outlive its entry, and its unsaved
// edits must not vanish unannounced. Returns false when the user backed out
// of the discard prompt — the caller then deletes nothing.
// `alreadyConfirmed` = the caller already showed a delete modal, so the
// discard prompt is suppressed: one modal per action.
async function closeEditorForDeleted(ids, alreadyConfirmed) {
if (!soState || soState.id == null || !ids.includes(soState.id)) return true;
if (alreadyConfirmed) { closeSlideOver(); return true; }
return await requestCloseSlideOver();
}
async function deleteEntry(id) { async function deleteEntry(id) {
const e = state.entries.find(x => x.id === id); const e = state.entries.find(x => x.id === id);
let confirmed = false;
if (state.askBeforeDelete) { if (state.askBeforeDelete) {
const ok = await confirmDialog({ const ok = await confirmDialog({
title: 'Move to trash', title: 'Move to trash',
@@ -5286,21 +5303,12 @@ async function deleteEntry(id) {
danger: true, danger: true,
}); });
if (!ok) return; if (!ok) return;
} else if (soState && soState.id === id) { confirmed = true;
// No delete confirmation configured: a misclick on ✕ would trash the
// entry AND drop the edits in the open editor with no warning at all.
// The discard gate stands in for it (it no-ops when nothing is dirty).
// Skipped when askBeforeDelete already asked — one modal is enough.
if (!(await requestCloseSlideOver())) return;
} }
if (!(await closeEditorForDeleted([id], confirmed))) return;
try { try {
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() }); await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
toast('Moved to trash'); toast('Moved to trash');
// Close the editor ONLY when it holds the entry being trashed. Deleting
// some other row used to close it unconditionally, silently dropping
// unsaved edits (no discard prompt — this path bypasses
// requestCloseSlideOver by design: the entry is gone either way).
if (soState && soState.id === id) closeSlideOver();
state.checked.delete(id); state.checked.delete(id);
await loadEntries(); await loadEntries();
state.trashedCount = (state.trashedCount || 0) + 1; state.trashedCount = (state.trashedCount || 0) + 1;