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:
@@ -2197,6 +2197,7 @@ async function addTagToEntry(id, tag) {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
e.tags = tags.join(',');
|
e.tags = tags.join(',');
|
||||||
|
editorAbsorbChange(id, { tag });
|
||||||
render();
|
render();
|
||||||
toast('Tagged "' + tag + '"');
|
toast('Tagged "' + tag + '"');
|
||||||
} catch (err) { toast(err.message, 'error'); }
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
@@ -2737,7 +2738,18 @@ function buildKebabMenu(entry) {
|
|||||||
|
|
||||||
const menu = el('div', { class: 'entry-kebab-menu' });
|
const menu = el('div', { class: 'entry-kebab-menu' });
|
||||||
const isNote = entry.kind === 'note';
|
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',
|
{ lbl: entry.pinned ? 'Unpin' : 'Pin to top',
|
||||||
ic: 'i-pin', fn: () => togglePin(entry.id) },
|
ic: 'i-pin', fn: () => togglePin(entry.id) },
|
||||||
{ lbl: entry.favorite ? 'Unfavorite' : 'Favorite',
|
{ lbl: entry.favorite ? 'Unfavorite' : 'Favorite',
|
||||||
@@ -2745,14 +2757,16 @@ function buildKebabMenu(entry) {
|
|||||||
{ lbl: isNote ? 'Copy note content' : 'Copy password',
|
{ lbl: isNote ? 'Copy note content' : 'Copy password',
|
||||||
ic: 'i-copy', fn: () => copyPassword(entry) },
|
ic: 'i-copy', fn: () => copyPassword(entry) },
|
||||||
];
|
];
|
||||||
// Username is login-only — hide the menu item for notes (no username field).
|
if (state.view !== 'trash') {
|
||||||
if (!isNote)
|
// Username is login-only — no such field on a note.
|
||||||
items.push({ lbl: 'Copy username', ic: 'i-user', fn: () => copyUsername(entry) });
|
if (!isNote)
|
||||||
items.push(
|
items.push({ lbl: 'Copy username', ic: 'i-user', fn: () => copyUsername(entry) });
|
||||||
{ lbl: 'Edit', ic: 'i-edit', fn: () => openSlideOver(entry.id) },
|
items.push(
|
||||||
{ lbl: 'Duplicate', ic: 'i-copy', fn: () => duplicateEntry(entry) },
|
{ lbl: 'Edit', ic: 'i-edit', fn: () => openSlideOver(entry.id) },
|
||||||
{ lbl: 'Move to trash', ic: 'i-trash', fn: () => deleteEntry(entry.id), danger: true },
|
{ lbl: 'Duplicate', ic: 'i-copy', fn: () => duplicateEntry(entry) },
|
||||||
);
|
{ lbl: 'Move to trash', ic: 'i-trash', fn: () => deleteEntry(entry.id), danger: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
items.forEach(it => {
|
items.forEach(it => {
|
||||||
const mi = el('button', {
|
const mi = el('button', {
|
||||||
class: 'kebab-item' + (it.danger ? ' is-danger' : ''),
|
class: 'kebab-item' + (it.danger ? ' is-danger' : ''),
|
||||||
@@ -3479,6 +3493,30 @@ function clearChecked() {
|
|||||||
renderGrid();
|
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) {
|
async function batchMoveToFolder(folder) {
|
||||||
const ids = Array.from(state.checked);
|
const ids = Array.from(state.checked);
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
@@ -3509,6 +3547,7 @@ async function batchMoveToFolder(folder) {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
e.folder = folder;
|
e.folder = folder;
|
||||||
|
editorAbsorbChange(id, { folder });
|
||||||
} catch (err) { /* ignore individual failures */ }
|
} catch (err) { /* ignore individual failures */ }
|
||||||
}
|
}
|
||||||
toast(ids.length + ' moved to ' + folder);
|
toast(ids.length + ' moved to ' + folder);
|
||||||
@@ -3546,6 +3585,7 @@ async function batchAddTag(tag) {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
e.tags = tags.join(',');
|
e.tags = tags.join(',');
|
||||||
|
editorAbsorbChange(id, { tag });
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
}
|
}
|
||||||
toast('Tagged ' + ids.length + ' as "' + tag + '"');
|
toast('Tagged ' + ids.length + ' as "' + tag + '"');
|
||||||
@@ -5345,7 +5385,11 @@ async function moveEntriesToFolder(ids, folder) {
|
|||||||
await api('/entries/' + id, {
|
await api('/entries/' + id, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
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,
|
site: e.site,
|
||||||
title: e.title || '',
|
title: e.title || '',
|
||||||
username: e.username,
|
username: e.username,
|
||||||
@@ -5358,9 +5402,10 @@ async function moveEntriesToFolder(ids, folder) {
|
|||||||
totp_iv: e.totp_iv || '',
|
totp_iv: e.totp_iv || '',
|
||||||
custom_fields: e.custom_fields || '',
|
custom_fields: e.custom_fields || '',
|
||||||
custom_fields_iv: e.custom_fields_iv || '',
|
custom_fields_iv: e.custom_fields_iv || '',
|
||||||
}),
|
})),
|
||||||
});
|
});
|
||||||
e.folder = folder;
|
e.folder = folder;
|
||||||
|
editorAbsorbChange(id, { folder });
|
||||||
ok++;
|
ok++;
|
||||||
} catch (err) { /* try the rest */ }
|
} catch (err) { /* try the rest */ }
|
||||||
}
|
}
|
||||||
@@ -5398,6 +5443,7 @@ async function moveEntryToFolder(id, folder) {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
e.folder = folder;
|
e.folder = folder;
|
||||||
|
editorAbsorbChange(id, { folder });
|
||||||
render();
|
render();
|
||||||
toast('Moved to ' + folder);
|
toast('Moved to ' + folder);
|
||||||
} catch (err) { toast(err.message, 'error'); }
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
|
|||||||
Reference in New Issue
Block a user