fix(editor): outside tag/folder changes land in the open editor

Tagging or moving an entry from the batch bar / kebab / drag while that same
entry sat open in the editor was lost on the next Save: the editor still held
the payload it loaded before the change and re-shipped it wholesale.

editorAbsorbChange(id, {tag|folder}) mirrors the change into soState AND
soState.original (so the dirty check doesn't fire on an edit the user didn't
type), called from all five write paths: batchAddTag, batchMoveToFolder,
addTagToEntry, moveEntryToFolder, moveEntriesToFolder.

Two bugs found next door:
- moveEntriesToFolder (multi-drag) PUT without withEncryptedMeta. The
  cleartext columns are gone server-side, so it shipped fields the server
  can't store and blanked title/site/username/tags at rest.
- The kebab menu offered Pin/Edit/Duplicate/Move to trash on TRASHED rows —
  the table view reaches it in trash, where only Restore / Delete forever
  make sense.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-22 23:44:10 +01:00
parent e1ddc65f85
commit d146fbe796
+50 -4
View File
@@ -2197,6 +2197,7 @@ async function addTagToEntry(id, tag) {
})),
});
e.tags = tags.join(',');
editorAbsorbChange(id, { tag });
render();
toast('Tagged "' + tag + '"');
} catch (err) { toast(err.message, 'error'); }
@@ -2737,7 +2738,18 @@ function buildKebabMenu(entry) {
const menu = el('div', { class: 'entry-kebab-menu' });
const isNote = entry.kind === 'note';
const items = [
// A trashed row can only be restored or purged — pin/favourite/edit/
// duplicate/trash all assume a live entry. The card and list views swap
// their buttons for restore+purge already; the table view reaches this
// same menu in trash, so the swap has to happen here too.
const items = state.view === 'trash' ? [
{ lbl: 'Restore', ic: 'i-rotate-ccw',
fn: () => restoreEntry(entry.id) },
{ lbl: isNote ? 'Copy note content' : 'Copy password',
ic: 'i-copy', fn: () => copyPassword(entry) },
{ lbl: 'Delete forever', ic: 'i-trash',
fn: () => permanentDelete(entry.id), danger: true },
] : [
{ lbl: entry.pinned ? 'Unpin' : 'Pin to top',
ic: 'i-pin', fn: () => togglePin(entry.id) },
{ lbl: entry.favorite ? 'Unfavorite' : 'Favorite',
@@ -2745,7 +2757,8 @@ function buildKebabMenu(entry) {
{ lbl: isNote ? 'Copy note content' : 'Copy password',
ic: 'i-copy', fn: () => copyPassword(entry) },
];
// Username is login-only — hide the menu item for notes (no username field).
if (state.view !== 'trash') {
// Username is login-only — no such field on a note.
if (!isNote)
items.push({ lbl: 'Copy username', ic: 'i-user', fn: () => copyUsername(entry) });
items.push(
@@ -2753,6 +2766,7 @@ function buildKebabMenu(entry) {
{ lbl: 'Duplicate', ic: 'i-copy', fn: () => duplicateEntry(entry) },
{ lbl: 'Move to trash', ic: 'i-trash', fn: () => deleteEntry(entry.id), danger: true },
);
}
items.forEach(it => {
const mi = el('button', {
class: 'kebab-item' + (it.danger ? ' is-danger' : ''),
@@ -3479,6 +3493,30 @@ function clearChecked() {
renderGrid();
}
// A folder/tag change applied to an entry from OUTSIDE the editor (batch bar,
// drag-to-folder, kebab) also has to land in the open editor. Without this the
// editor still holds the pre-change payload, and its next Save re-ships it —
// silently reverting the change the user just made.
// `original` is updated alongside the working value so the dirty check (and
// the Save button's visibility) don't fire on a change the user didn't type.
function editorAbsorbChange(id, change) {
if (!soState || soState.id == null || soState.id !== id) return;
if (change.folder != null) {
// The outside action is the more recent explicit intent, so it wins
// over an unsaved folder pick in the editor.
soState.original.folder = change.folder;
const sel = $('#soFolder');
if (sel) sel.value = change.folder;
}
if (change.tag && !soState.tags.includes(change.tag)) {
soState.tags.push(change.tag);
soState.original.tags = soState.original.tags
? soState.original.tags + ',' + change.tag : change.tag;
renderSoChips();
}
soDirtyCheck();
}
async function batchMoveToFolder(folder) {
const ids = Array.from(state.checked);
if (!ids.length) return;
@@ -3509,6 +3547,7 @@ async function batchMoveToFolder(folder) {
})),
});
e.folder = folder;
editorAbsorbChange(id, { folder });
} catch (err) { /* ignore individual failures */ }
}
toast(ids.length + ' moved to ' + folder);
@@ -3546,6 +3585,7 @@ async function batchAddTag(tag) {
})),
});
e.tags = tags.join(',');
editorAbsorbChange(id, { tag });
} catch (err) {}
}
toast('Tagged ' + ids.length + ' as "' + tag + '"');
@@ -5345,7 +5385,11 @@ async function moveEntriesToFolder(ids, folder) {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
// withEncryptedMeta is mandatory on every /entries write —
// the cleartext site/username/title/tags columns are gone
// server-side, so a raw body ships nothing the server can
// store and blanks the *_enc blobs.
body: JSON.stringify(await withEncryptedMeta({
site: e.site,
title: e.title || '',
username: e.username,
@@ -5358,9 +5402,10 @@ async function moveEntriesToFolder(ids, folder) {
totp_iv: e.totp_iv || '',
custom_fields: e.custom_fields || '',
custom_fields_iv: e.custom_fields_iv || '',
}),
})),
});
e.folder = folder;
editorAbsorbChange(id, { folder });
ok++;
} catch (err) { /* try the rest */ }
}
@@ -5398,6 +5443,7 @@ async function moveEntryToFolder(id, folder) {
})),
});
e.folder = folder;
editorAbsorbChange(id, { folder });
render();
toast('Moved to ' + folder);
} catch (err) { toast(err.message, 'error'); }