feat(hotkeys): persistent conflict marker in Settings

A toast is gone by the time the user opens Settings, so a hotkey Windows
refused looked perfectly configured while doing nothing. The offending row now
carries a ⚠ marker (amber border + tooltip explaining the combo is taken)
until a re-registration clears it.

Delphi reports slot ids instead of labels; HOTKEY_SLOTS maps id → button +
label in one place, so JS owns the wording and can flag the exact row. The
markers are re-applied by openSettings, surviving panel re-renders.

Also stop toasting success on the startup push (it fires on every unlock) —
autofillPushHotkeys(true). A clash still warns: that one is actionable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-25 17:52:05 +01:00
parent 3d3ab3b02b
commit 4965ff2518
4 changed files with 73 additions and 19 deletions
+33 -1
View File
@@ -12,6 +12,32 @@
// Win32 modifier flags for RegisterHotKey.
const WIN32_MOD = { alt: 0x0001, ctrl: 0x0002, shift: 0x0004, win: 0x0008 };
// The five configurable global hotkeys: slot id → Settings button + label.
// Delphi reports registration failures by slot id; this is the single place
// mapping them to UI, so a new hotkey only has to be added here.
const HOTKEY_SLOTS = {
full: { btn: '#settingAutofillFullCombo', label: 'Autofill (full)' },
password: { btn: '#settingAutofillPwdCombo', label: 'Password only' },
totp: { btn: '#settingAutofillTotpCombo', label: '2FA code' },
quickSearch: { btn: '#settingQuickSearchCombo', label: 'Quick search' },
newEntry: { btn: '#settingNewEntryCombo', label: 'New entry' },
};
// Flag the Settings rows whose combo Windows refused. Re-applied whenever the
// panel is (re)rendered, so the warning outlives the toast.
function renderHotkeyConflicts() {
const bad = new Set(state.hotkeyConflicts || []);
for (const [slot, def] of Object.entries(HOTKEY_SLOTS)) {
const btn = document.querySelector(def.btn);
if (!btn) continue;
const clash = bad.has(slot);
btn.classList.toggle('is-conflict', clash);
if (clash) btn.title = 'This combo is already used by another app — ' +
'the hotkey does nothing. Pick another one.';
else btn.removeAttribute('title');
}
}
// Format a combo for human display: "Ctrl+Shift+L".
function autofillComboLabel(c) {
if (!c || !c.key) return '— not set —';
@@ -78,7 +104,13 @@ function autofillCaptureFromEvent(e) {
// Push current state to Delphi (toggle + both combos) and persist.
// Called after any change so Delphi's RegisterHotKey reflects state.
function autofillPushHotkeys() {
// Set by the startup push so the Delphi callback can stay quiet about
// success — "Hotkeys updated" on every unlock is noise. A CLASH is still
// reported: it's actionable and the user has no other way to learn about it.
let hotkeyPushSilent = false;
function autofillPushHotkeys(silent) {
hotkeyPushSilent = !!silent;
localStorage.setItem('autofillHotkeyFull', JSON.stringify(state.autofillHotkeyFull));
localStorage.setItem('autofillHotkeyPwd', JSON.stringify(state.autofillHotkeyPwd));
localStorage.setItem('autofillHotkeyTotp', JSON.stringify(state.autofillHotkeyTotp));