feat: Bitwarden import bundle + settings search + quick-search hotkey + UX fixes

- Bitwarden CSV import: folders auto-created server-side; notes column on
  login rows surfaces as a "Notes" custom field instead of polluting tags;
  type=card / type=identity rows now mapped to kind=note with the
  credit-card / identity template + card_* / identity_* columns
  pulled into custom_fields; `fields` column parsed (Bitwarden's
  "label: value\nlabel: value" lines + our own JSON shape).
- Settings panel search: live filter at top of the panel, matches each
  .setting-row individually, hides whole section when no row matches,
  shows a "No matches" banner. Esc clears query (without closing
  Settings); Esc with empty query closes the panel.
- Quick-search hotkey customizable: SetQuickSearchHotkey added to
  PM.Bridge; cmd://autofill/hotkeys extended with qs_mods/qs_vk
  (independent of the autofill enabled flag — quick-search stays
  armed even when autofill is off); state.quickSearchHotkey synced
  via settings_json; new "Quick search picker" row in Settings.
- FireDAC SQLite folder POST/PUT: pre-declare ftString on color/icon
  params so .Clear (NULL) doesn't trip "[FireDAC][Phys][SQLite]-335
  type unknown" at Prepare — was crashing the CSV-import folder
  auto-creation path.
- Edge form-data autocomplete suppressed on slideover inputs (title,
  site, username, password, TOTP, note body, custom fields):
  autocomplete=off (new-password on secrets) + spellcheck=false. Fixes
  the "Informations enregistrées" dropdown popping over data after a
  field was edited.
- closeSlideOver blurs any focused descendant before removing .is-open
  so an invisible focused field can't react to arrow-down / backspace
  after dismissal.
- Slideover Esc handler upgraded to capture phase so it fires before
  the input's own keydown or browser-level Esc swallow on the active
  autocomplete popup.
- Settings panel Esc closes the panel when search input is empty;
  search keeps the keystroke when it has a query to clear.
- Discard-fantome on note open: customFields working copy and
  originalCustomJson now share the SAME normalized array — comparing
  raw plainCustom against the .map()'d working copy made notes look
  dirty on open.
- Delete / Backspace global shortcut: batch-trash on normal views,
  batch perm-delete on trash view, gated on selection + no input
  focused + no modal up.
- Toggle thumb vertical centering via top:50% + translateY(-50%);
  state checked uses translate(16px, -50%) to keep the centring.
- Batch bar disappears after per-card restore/perm-delete/trash:
  state.checked.delete(id) before render for the relevant flows;
  state.checked.clear() before render in emptyTrash and the new
  moveEntriesToFolder helper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-06-30 23:25:50 +01:00
parent 6869b7c692
commit bd14831449
7 changed files with 405 additions and 36 deletions
+54 -2
View File
@@ -438,10 +438,12 @@ input[type="range"]::-webkit-slider-thumb {
.toggle-slider::before {
content: '';
position: absolute;
left: 2px; top: 1px;
left: 2px;
top: 50%;
width: 14px; height: 14px;
background: var(--text-dim);
border-radius: 50%;
transform: translateY(-50%);
transition: all var(--t-fast);
}
.toggle input:checked + .toggle-slider {
@@ -450,7 +452,7 @@ input[type="range"]::-webkit-slider-thumb {
}
.toggle input:checked + .toggle-slider::before {
background: white;
transform: translateX(16px);
transform: translate(16px, -50%);
}
/* ---- 6. APP SHELL ---------------------------------------- */
@@ -2085,6 +2087,56 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
}
.slideover-body { padding: 20px; overflow-y: auto; flex: 1; }
.slideover-field { margin-bottom: 16px; }
/* Settings panel search — sits below the header, above the body. Only
present inside #settingsPanel. */
.settings-search-wrap {
position: relative;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center;
}
.settings-search-icon {
position: absolute; left: 26px; top: 50%;
transform: translateY(-50%);
width: 14px; height: 14px;
color: var(--text-faint);
pointer-events: none;
}
.settings-search-input {
flex: 1;
padding: 7px 30px 7px 32px;
background: var(--bg-elev-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px;
outline: none;
transition: border-color var(--t-fast);
}
.settings-search-input:focus { border-color: var(--accent); }
.settings-search-clear {
position: absolute; right: 22px; top: 50%;
transform: translateY(-50%);
background: transparent; border: 0; padding: 4px;
cursor: pointer;
color: var(--text-faint);
border-radius: 4px;
display: none;
}
.settings-search-clear svg { width: 12px; height: 12px; }
.settings-search-clear:hover { color: var(--text); background: var(--bg-elev-3); }
.settings-search-wrap.has-query .settings-search-clear { display: inline-flex; }
/* Hidden section + per-row + no-results banner driven by JS. */
.slideover-field.is-search-hidden,
.setting-row.is-search-hidden { display: none; }
.settings-no-results {
padding: 20px;
color: var(--text-faint);
font-size: 13px;
text-align: center;
display: none;
}
.settings-no-results.is-visible { display: block; }
.slideover-field-label { font-size: 11px; font-weight: 500; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
.slideover-field-value {
display: flex; align-items: center; gap: 6px;
@@ -14,6 +14,7 @@ implementation
uses
System.SysUtils, System.JSON, System.NetEncoding,
System.Generics.Collections,
Data.DB,
FireDAC.Comp.Client, FireDAC.Stan.Param,
IdCustomHTTPServer,
PM.Router, PM.JSON, PM.Database, PM.Session, PM.Audit, PM.RateLimit;
@@ -111,6 +112,10 @@ begin
'VALUES (:uid, :name, :color, :icon)';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('name').AsString := LName;
// Pre-declare type so .Clear (NULL) doesn't leave the param
// untyped — FireDAC SQLite rejects untyped params at Prepare.
LQ.ParamByName('color').DataType := ftString;
LQ.ParamByName('icon').DataType := ftString;
if LColor = '' then LQ.ParamByName('color').Clear
else LQ.ParamByName('color').AsString := LColor;
if LIcon = '' then LQ.ParamByName('icon').Clear
@@ -205,11 +210,13 @@ begin
LQ.ParamByName('name').AsString := LName;
if LHasColor then
begin
LQ.ParamByName('color').DataType := ftString;
if LColor = '' then LQ.ParamByName('color').Clear
else LQ.ParamByName('color').AsString := LColor;
end;
if LHasIcon then
begin
LQ.ParamByName('icon').DataType := ftString;
if LIcon = '' then LQ.ParamByName('icon').Clear
else LQ.ParamByName('icon').AsString := LIcon;
end;
+16
View File
@@ -146,6 +146,9 @@ type
// stays active.
function SetAutofillHotkeys(AFullMods, AFullVk,
APwdMods, APwdVk: Word): Boolean;
// Re-register the Ctrl+Shift+Q (default) quick-search hotkey with a
// user-chosen combo. True on success.
function SetQuickSearchHotkey(AMods, AVk: Word): Boolean;
// Convenience wrapper: register the historical defaults (Ctrl+Shift+L
// and Ctrl+Shift+P). Used by the host on first start; runtime changes
// go through SetAutofillHotkeys.
@@ -853,6 +856,19 @@ begin
Result := FAutofillFullActive and FAutofillPwdActive;
end;
function TPMBridge.SetQuickSearchHotkey(AMods, AVk: Word): Boolean;
begin
if FQuickSearchHotkeyRegistered then
begin
UnregisterHotKey(FMsgWindow, QUICK_SEARCH_HOTKEY_ID);
FQuickSearchHotkeyRegistered := False;
end;
if (AVk <> 0) and (AMods <> 0) then
FQuickSearchHotkeyRegistered := RegisterHotKey(FMsgWindow,
QUICK_SEARCH_HOTKEY_ID, AMods, AVk);
Result := FQuickSearchHotkeyRegistered;
end;
procedure TPMBridge.ApplyTitleBarTheme(ADark: Boolean);
const
DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
+11
View File
@@ -720,6 +720,17 @@ begin
// we register both with the supplied combos (replacing any prior).
else if ACmd = 'autofill/hotkeys' then
begin
// Quick-search combo is set unconditionally (independent of the
// autofill enabled flag) so the picker stays armed even when the
// autofill hotkeys are turned off.
if GetParam('qs_vk') <> '' then
begin
var LQsMods := Word(StrToIntDef(GetParam('qs_mods'), 6));
var LQsVk := Word(StrToIntDef(GetParam('qs_vk'), Ord('Q')));
var LQsOk := FBridge.SetQuickSearchHotkey(LQsMods, LQsVk);
LogLine(Format('Quick-search hotkey set — mods:%d vk:%d (ok=%s)',
[LQsMods, LQsVk, BoolToStr(LQsOk, True)]));
end;
if GetParam('enabled') <> '1' then
begin
FBridge.UnregisterAutofillHotkey;
Binary file not shown.
+16 -1
View File
@@ -403,7 +403,16 @@
<h3>Settings</h3>
<button class="icon-btn" id="settingsClose"><svg><use href="#i-x"/></svg></button>
</header>
<div class="slideover-body">
<div class="settings-search-wrap">
<svg class="settings-search-icon"><use href="#i-search"/></svg>
<input type="text" id="settingsSearch" class="settings-search-input"
placeholder="Search settings… (Esc to clear)" />
<button class="settings-search-clear" id="settingsSearchClear" type="button"
title="Clear search">
<svg><use href="#i-x"/></svg>
</button>
</div>
<div class="slideover-body" id="settingsBody">
<div class="slideover-field">
<div class="slideover-field-label">Appearance</div>
<div class="setting-row">
@@ -610,6 +619,12 @@
</span>
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingAutofillPwdCombo">Ctrl+Shift+P</button>
</div>
<div class="setting-row">
<span style="font-size:12px;color:var(--text-dim)">
Quick search picker (find entry, fill from anywhere)
</span>
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingQuickSearchCombo">Ctrl+Shift+Q</button>
</div>
<p style="font-size:11px;color:var(--text-faint);margin:6px 0 0;line-height:1.4">
Click a button, then press your new combo. Needs Ctrl, Alt or Win + a letter / digit / F-key.
</p>
+298 -30
View File
@@ -149,9 +149,14 @@ const Bridge = (() => {
if (!active) return;
const f = autofillComboToWin32(combos.full);
const p = autofillComboToWin32(combos.password);
let qs = '';
if (combos.quickSearch) {
const q = autofillComboToWin32(combos.quickSearch);
qs = '&qs_mods=' + q.mods + '&qs_vk=' + q.vk;
}
cmd('cmd://autofill/hotkeys?enabled=' + (enabled ? '1' : '0') +
'&full_mods=' + f.mods + '&full_vk=' + f.vk +
'&pwd_mods=' + p.mods + '&pwd_vk=' + p.vk);
'&pwd_mods=' + p.mods + '&pwd_vk=' + p.vk + qs);
},
// Called by Delphi after a setAutofillHotkeys request, with true if
@@ -532,6 +537,8 @@ const state = {
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"L"}'),
autofillHotkeyPwd: JSON.parse(localStorage.getItem('autofillHotkeyPwd') ||
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"P"}'),
quickSearchHotkey: JSON.parse(localStorage.getItem('quickSearchHotkey') ||
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"Q"}'),
sidebarCollapsed: JSON.parse(localStorage.getItem('sidebarCollapsed') ||
'{"folders":false,"tags":false,"tools":false}'),
// Fetch website favicons via the Delphi DuckDuckGo proxy. OFF by
@@ -4432,16 +4439,13 @@ async function openSlideOver(id, opts) {
// Working copy of the custom-fields array — mutated in place by
// buildCustomFieldRow handlers. The serialized JSON of this array
// at Save time is what gets encrypted into custom_fields/iv.
customFields: plainCustom.map(f => {
const out = {
label: f.label || '', value: f.value || '',
is_secret: !!f.is_secret,
};
if (Array.isArray(f.options) && f.options.length > 0)
out.options = f.options.slice();
return out;
}),
originalCustomJson: JSON.stringify(plainCustom),
// customFields + originalCustomJson are assigned just after soState
// is constructed (see below) so both sides of the dirty check use
// the SAME normalized shape — comparing raw plainCustom against the
// mapped working copy would falsely fire dirty on entries whose
// stored blob carries extra/legacy keys.
customFields: [],
originalCustomJson: '[]',
originalEncrypted: isNew ? null : e.encrypted_password,
originalIV: isNew ? null : e.iv,
originalTotpEncrypted: isNew ? null : e.totp_secret,
@@ -4459,6 +4463,21 @@ async function openSlideOver(id, opts) {
pendingAttachments: [],
};
// Normalize the custom-fields array ONCE — the working copy and the
// dirty-check baseline must share the same shape, otherwise stripped
// legacy keys (empty options[], stray metadata) make the JSON diverge
// on open and the entry looks dirty without any user input.
soState.customFields = plainCustom.map(f => {
const out = {
label: f.label || '', value: f.value || '',
is_secret: !!f.is_secret,
};
if (Array.isArray(f.options) && f.options.length > 0)
out.options = f.options.slice();
return out;
});
soState.originalCustomJson = JSON.stringify(soState.customFields);
if (isNote) {
// Notes: minimal layout — name + multiline body + folder + tags.
// No icon (covered by sidebar icon), no site/user/totp.
@@ -4603,6 +4622,7 @@ function buildCustomFieldRow(field, idx, rerender) {
const labelInput = el('input', {
type: 'text', class: 'so-input so-custom-label',
placeholder: 'Label (e.g. PIN, Account #)',
autocomplete: 'off', spellcheck: 'false',
});
labelInput.value = field.label || '';
labelInput.addEventListener('input', () => {
@@ -4632,6 +4652,8 @@ function buildCustomFieldRow(field, idx, rerender) {
type: field.is_secret ? 'password' : 'text',
class: 'so-input so-custom-value',
placeholder: 'Value',
autocomplete: field.is_secret ? 'new-password' : 'off',
spellcheck: 'false',
});
valueInput.value = field.value || '';
valueInput.addEventListener('input', () => {
@@ -4711,6 +4733,7 @@ function soNoteBodyField(value) {
class: 'so-input so-note-body',
rows: 12,
placeholder: 'Encrypted with your vault key. Nothing leaves your device.',
autocomplete: 'off', autocorrect: 'off', spellcheck: 'false',
});
ta.value = value || '';
wrap.appendChild(ta);
@@ -4720,7 +4743,16 @@ function soNoteBodyField(value) {
function soEditableField(label, id, value) {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, label));
const input = el('input', { type: 'text', id, value, class: 'so-input' });
const input = el('input', {
type: 'text', id, value, class: 'so-input',
// Disable Edge / Chromium "saved form data" history — these
// fields can carry titles, usernames and other identifying
// info that shouldn't end up in the browser's autocomplete
// dropdown (visible via arrow-down on a focused field).
autocomplete: 'off',
autocorrect: 'off',
spellcheck: 'false',
});
input.addEventListener('keydown', soOnEnterSave);
wrap.appendChild(input);
return wrap;
@@ -4818,6 +4850,7 @@ function soPasswordField(plain) {
const input = el('input', {
type: 'password', id: 'soPassword', value: plain,
class: 'so-input', style: 'flex:1;font-family:JetBrains Mono,monospace',
autocomplete: 'new-password', spellcheck: 'false',
});
input.addEventListener('keydown', soOnEnterSave);
const toggle = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show/hide' });
@@ -4908,6 +4941,7 @@ function soTotpField(plainSecret) {
value: plainSecret || '',
class: 'so-input',
placeholder: 'Paste base32 secret or otpauth:// URI',
autocomplete: 'new-password', spellcheck: 'false',
on: { keydown: soOnEnterSave },
style: 'flex:1;font-family:JetBrains Mono,monospace',
autocomplete: 'off', spellcheck: 'false',
@@ -5387,6 +5421,12 @@ function passwordField(plain) {
function closeSlideOver() {
stopTotpTick();
// Blur any input inside the slideover BEFORE we hide it — otherwise
// focus lingers on an invisible field and Edge's saved-form-data
// popup ("Informations enregistrées") can still pop on arrow-down /
// backspace, leaking past values to the user-visible UI.
const ae = document.activeElement;
if (ae && $('#slideover').contains(ae) && typeof ae.blur === 'function') ae.blur();
$('#slideover').classList.remove('is-open');
state.selectedId = null;
renderGrid();
@@ -7909,7 +7949,20 @@ function parseEntriesFromCSV(text) {
// fall back to a heuristic (empty site + non-empty notes = a note).
const colKind = findColumn(headers, ['kind', 'type', 'item_type']);
const colTemplate = findColumn(headers, ['template', 'subtype']);
const colCustom = findColumn(headers, ['custom_fields', 'custom']);
const colCustom = findColumn(headers, ['custom_fields', 'custom', 'fields']);
// Bitwarden card columns — only used when type=card. Each maps to a
// custom field on a credit-card-template note.
const colCardHolder = findColumn(headers, ['card_cardholdername', 'card_holder', 'cardholder']);
const colCardBrand = findColumn(headers, ['card_brand', 'card_type']);
const colCardNumber = findColumn(headers, ['card_number', 'cardnumber']);
const colCardExpM = findColumn(headers, ['card_expmonth', 'card_exp_month']);
const colCardExpY = findColumn(headers, ['card_expyear', 'card_exp_year']);
const colCardCode = findColumn(headers, ['card_code', 'card_cvv', 'card_cvc']);
// Bitwarden identity columns — mapped to identity-template note.
const colIdFirst = findColumn(headers, ['identity_firstname']);
const colIdLast = findColumn(headers, ['identity_lastname']);
const colIdEmail = findColumn(headers, ['identity_email']);
const colIdPhone = findColumn(headers, ['identity_phone']);
if (colSite === null && colTitle === null && colUser === null)
throw new Error('No recognizable title/url or username column in CSV header');
@@ -7928,9 +7981,14 @@ function parseEntriesFromCSV(text) {
// CSVs (Bitwarden/KeePass) — when site+user+pwd are all empty but
// notes/title is set, that's a secure-note row.
let kindRaw = (colKind !== null ? String(r[colKind] || '').toLowerCase().trim() : '');
let kind = (kindRaw === 'note' || kindRaw === 'secure_note') ? 'note' : 'login';
let kind = (kindRaw === 'note' || kindRaw === 'secure_note') ? 'note' :
(kindRaw === 'card' || kindRaw === 'identity') ? 'note' : 'login';
if (kindRaw === '' && !siteRaw && !pwd && notesRaw) kind = 'note';
const templateRaw = (colTemplate !== null ? String(r[colTemplate] || '').trim() : '');
let templateRaw = (colTemplate !== null ? String(r[colTemplate] || '').trim() : '');
// Bitwarden type=card / type=identity → note kind + appropriate
// template. The card/identity columns become custom fields below.
if (kindRaw === 'card') templateRaw = templateRaw || 'credit-card';
if (kindRaw === 'identity') templateRaw = templateRaw || 'identity';
// Notes legitimately have no site; their body is in `notes` (or in
// `password` when round-tripping our own CSV — we wrote the body
@@ -7943,16 +8001,52 @@ function parseEntriesFromCSV(text) {
const raw = String(r[colCustom] || '').trim();
if (raw) {
try {
// Our own export: JSON array of {label, value, is_secret}
const arr = JSON.parse(raw);
if (Array.isArray(arr))
cf = arr.filter(f => f && typeof f === 'object' && f.label);
} catch {}
} catch {
// Bitwarden / Chrome / KeePass CSV: newline-separated
// "label: value" lines (sometimes "label=value"). Split,
// pick the FIRST separator only so values can contain
// ":" or "=" without being mangled.
raw.split(/\r?\n/).forEach(line => {
line = line.trim();
if (!line) return;
const sep = line.search(/[:=]/);
if (sep <= 0) return;
const label = line.slice(0, sep).trim();
const value = line.slice(sep + 1).trim();
if (label) cf.push({ label, value, is_secret: false });
});
}
}
}
if (kind === 'note') {
const body = pwd || notesRaw;
if (!body) { skipped++; continue; }
// Pull Bitwarden card/identity columns into custom_fields so
// the type=card / type=identity rows survive the import.
const push = (label, val, is_secret) => {
if (val) cf.push({ label, value: val, is_secret: !!is_secret });
};
if (kindRaw === 'card') {
push('Cardholder', colCardHolder !== null ? String(r[colCardHolder] || '').trim() : '');
push('Brand', colCardBrand !== null ? String(r[colCardBrand] || '').trim() : '');
push('Number', colCardNumber !== null ? String(r[colCardNumber] || '').trim() : '', true);
const expM = colCardExpM !== null ? String(r[colCardExpM] || '').trim() : '';
const expY = colCardExpY !== null ? String(r[colCardExpY] || '').trim() : '';
if (expM || expY) push('Expires', (expM && expY) ? (expM + '/' + expY) : (expM || expY));
push('CVV', colCardCode !== null ? String(r[colCardCode] || '').trim() : '', true);
}
if (kindRaw === 'identity') {
const f = colIdFirst !== null ? String(r[colIdFirst] || '').trim() : '';
const l = colIdLast !== null ? String(r[colIdLast] || '').trim() : '';
if (f || l) push('Name', (f && l) ? (f + ' ' + l) : (f || l));
push('Email', colIdEmail !== null ? String(r[colIdEmail] || '').trim() : '');
push('Phone', colIdPhone !== null ? String(r[colIdPhone] || '').trim() : '');
}
const body = pwd || notesRaw || ' '; // template carries data via cf
if (!body && cf.length === 0) { skipped++; continue; }
const tagsArr = [];
if (colTags !== null) {
String(r[colTags] || '').split(/[,;]/).forEach(t => {
@@ -7981,9 +8075,9 @@ function parseEntriesFromCSV(text) {
const title = titleRaw || '';
if (!site || !pwd) { skipped++; continue; }
// Tags: combine the tags column and any free-form notes into a
// comma-separated string. Notes often contain useful metadata we
// don't want to drop on the floor.
// Tags: only the explicit tags column. Free-form notes are
// surfaced as a custom "Notes" field below — putting prose into
// the tag chip strip turned it into noise (and lost line breaks).
let tagsArr = [];
if (colTags !== null) {
String(r[colTags] || '').split(/[,;]/).forEach(t => {
@@ -7991,9 +8085,12 @@ function parseEntriesFromCSV(text) {
if (t) tagsArr.push(t);
});
}
// Bitwarden / KeePass / Chrome login rows carry per-entry notes
// in a `notes` column. Preserve them as a non-secret custom field
// so the body survives round-trip without polluting tags.
if (colNotes !== null) {
const n = String(r[colNotes] || '').trim();
if (n && n.length < 80) tagsArr.push(n); // long notes become noise as tags
if (n) cf.push({ label: 'Notes', value: n, is_secret: false });
}
// TOTP: support raw base32 OR full otpauth:// URI in the cell.
@@ -8256,6 +8353,34 @@ async function doImport() {
}
}
// CSV imports (Bitwarden / KeePass / Chrome) don't carry a
// folders[] block — they just stamp a folder name on each row.
// Bulk-import stores the name but never creates the folders
// table row, so the sidebar wouldn't show the new folder.
// Auto-create any referenced folder that doesn't exist yet.
const referenced = new Set();
for (const e of parsed.entries) {
const f = (e.folder || '').trim();
if (f && f !== 'All') referenced.add(f);
}
if (referenced.size > 0) {
const localNames = new Set((state.folders || [])
.filter(f => f && f.name).map(f => f.name));
let createdMissing = 0;
for (const name of referenced) {
if (localNames.has(name)) continue;
try {
await api('/folders', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ name, color: '', icon: '' }),
});
createdMissing++;
} catch (_) { /* duplicate or invalid — skip silently */ }
}
if (createdMissing > 0) await loadFolders();
}
toast('Encrypting ' + parsed.entries.length + ' entries…');
const encrypted = [];
@@ -8628,10 +8753,12 @@ function autofillCaptureFromEvent(e) {
function autofillPushHotkeys() {
localStorage.setItem('autofillHotkeyFull', JSON.stringify(state.autofillHotkeyFull));
localStorage.setItem('autofillHotkeyPwd', JSON.stringify(state.autofillHotkeyPwd));
localStorage.setItem('quickSearchHotkey', JSON.stringify(state.quickSearchHotkey));
if (Bridge.active) {
Bridge.setAutofillHotkeys(state.autofillEnabled, {
full: state.autofillHotkeyFull,
password: state.autofillHotkeyPwd,
quickSearch: state.quickSearchHotkey,
});
}
}
@@ -8863,6 +8990,7 @@ function openSettings() {
// Hotkey capture buttons — labels reflect current combos.
$('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull);
$('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd);
$('#settingQuickSearchCombo').textContent = autofillComboLabel(state.quickSearchHotkey);
$('#settingAutofillHotkeysRow').style.display = Bridge.active ? '' : 'none';
// Start-with-Windows toggle: only meaningful inside the Delphi host
// (registry access). Hide for the PHP frontend.
@@ -8933,11 +9061,83 @@ function openSettings() {
}
$('#settingsPanel').classList.add('is-open');
// Reset the search filter every time Settings is re-opened so the
// user lands on the full panel, not the last filtered view.
const si = $('#settingsSearch');
if (si) { si.value = ''; applySettingsSearch(''); }
}
function closeSettings() {
$('#settingsPanel').classList.remove('is-open');
}
// Filter the settings panel by text. Matches against the label + the
// section body so e.g. "Ctrl" finds the hotkeys section via its button
// labels. Empty query = show everything. Adds a "No matches" hint when
// every section is hidden.
function applySettingsSearch(rawQuery) {
const panel = $('#settingsPanel');
if (!panel) return;
const wrap = $('.settings-search-wrap');
const q = (rawQuery || '').trim().toLowerCase();
wrap && wrap.classList.toggle('has-query', q.length > 0);
const sections = panel.querySelectorAll('.slideover-body > .slideover-field');
let totalShownRows = 0;
sections.forEach(sec => {
// No query: reset everything to visible.
if (!q) {
sec.classList.remove('is-search-hidden');
sec.querySelectorAll('.is-search-hidden').forEach(n =>
n.classList.remove('is-search-hidden'));
return;
}
// Section label text is part of the section's identity (e.g.
// "Sync" or "Security") — a query that hits the label keeps the
// whole section visible without per-row filtering.
const labelEl = sec.querySelector('.slideover-field-label');
const labelTxt = (labelEl ? labelEl.innerText : '').toLowerCase();
const labelMatch = labelTxt && labelTxt.indexOf(q) >= 0;
// Per-row filter: each .setting-row is an individually-matchable
// entry. Non-row children (paragraphs, button groups, hints) keep
// their default visibility — they're context for whichever row is
// shown, not standalone matches.
const rows = sec.querySelectorAll(':scope > .setting-row');
let rowMatches = 0;
rows.forEach(row => {
if (labelMatch) {
row.classList.remove('is-search-hidden');
rowMatches++;
return;
}
const txt = (row.innerText || '').toLowerCase();
const hit = txt.indexOf(q) >= 0;
row.classList.toggle('is-search-hidden', !hit);
if (hit) rowMatches++;
});
// Section has no rows at all (button-only section like Import /
// Recovery): match against the whole section text.
const sectionHasRows = rows.length > 0;
const sectionHit = labelMatch ||
(!sectionHasRows && (sec.innerText || '').toLowerCase().indexOf(q) >= 0) ||
rowMatches > 0;
sec.classList.toggle('is-search-hidden', !sectionHit);
if (sectionHit) totalShownRows += sectionHasRows ? rowMatches : 1;
});
let banner = panel.querySelector('.settings-no-results');
if (!banner) {
banner = el('div', { class: 'settings-no-results' },
'No settings match your search.');
const body = panel.querySelector('.slideover-body');
if (body) body.appendChild(banner);
}
banner.classList.toggle('is-visible', q.length > 0 && totalShownRows === 0);
}
// ---- Auto-lock with 30s warning countdown -------------------
const WARNING_SECONDS = 30;
let autoLockTimer = null;
@@ -9747,7 +9947,7 @@ const SYNCED_SETTING_KEYS = [
// Hotkey combos are user preferences — values are portable. The
// registration itself is Windows-only, so non-Windows clients just
// ignore them.
'autofillHotkeyFull', 'autofillHotkeyPwd',
'autofillHotkeyFull', 'autofillHotkeyPwd', 'quickSearchHotkey',
// Sidebar section collapsed state. Object of { folders, tags, tools }
// booleans. Synced so the user gets the same fold state across devices.
'sidebarCollapsed',
@@ -9811,6 +10011,7 @@ async function loadServerSettings() {
case 'pageSize': localStorage.setItem('pageSize', String(v)); break;
case 'autofillHotkeyFull':
case 'autofillHotkeyPwd':
case 'quickSearchHotkey':
case 'sidebarCollapsed':
// Object; persist as JSON so the next cold start picks it up.
localStorage.setItem(k, JSON.stringify(v));
@@ -9982,6 +10183,26 @@ async function init() {
});
document.addEventListener('keydown', handleCardCursorKey);
// Delete / Backspace on the grid (no input focused, no modal open)
// triggers the batch action matching the current view: soft-trash for
// normal views, permanent-delete for the trash view. Mirrors what the
// batch bar does, just via keyboard.
document.addEventListener('keydown', e => {
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
if (e.ctrlKey || e.altKey || e.metaKey) return;
const tag = (e.target && e.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
if (e.target && e.target.isContentEditable) return;
if (!$('#appShell') || $('#appShell').classList.contains('is-hidden')) return;
if (document.querySelector('.modal:not(.is-hidden)')) return;
if ($('#slideover') && $('#slideover').classList.contains('is-open')) return;
if ($('#settingsPanel') && $('#settingsPanel').classList.contains('is-open')) return;
if (state.checked.size === 0) return;
e.preventDefault();
if (state.view === 'trash') batchPermDelete();
else batchDelete();
});
// Auth tabs
$$('.auth-tab').forEach(t => {
t.addEventListener('click', () => {
@@ -10173,13 +10394,16 @@ async function init() {
// an input and ends outside doesn't trigger close (the resulting click
// event has a target outside the slideover even though the user never
// intended to dismiss it).
// Esc closes the slideover.
// Esc closes the slideover. Registered with capture=true so it fires
// BEFORE any input-level handler that might call stopPropagation, and
// before the browser swallows the keystroke for things like clearing
// an active form-autocomplete popup on a focused field.
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (!$('#slideover').classList.contains('is-open')) return;
if (document.querySelector('.modal:not(.is-hidden)')) return;
requestCloseSlideOver();
});
}, true);
// Click-outside closes too. mousedown origin is captured so a
// drag-selection that starts inside an input and ends outside doesn't
// count as an outside click. Cards/rows/modals/etc. are whitelisted
@@ -10352,6 +10576,41 @@ async function init() {
// Settings slide-over
$('#settingsBtn').addEventListener('click', openSettings);
$('#settingsClose').addEventListener('click', closeSettings);
// Escape closes the Settings panel — unless the search input is
// focused with a non-empty query (in that case its own handler
// already swallowed the event and cleared the query). Skip when a
// confirm/reauth modal is up so its Escape stays the priority.
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (!$('#settingsPanel').classList.contains('is-open')) return;
if (document.querySelector('.modal:not(.is-hidden)')) return;
closeSettings();
});
// Settings search box: live filter on every input. Escape clears the
// query (without closing the panel — the existing Esc handler also
// closes settings, but only when focus isn't inside an input).
const sInput = $('#settingsSearch');
const sClear = $('#settingsSearchClear');
if (sInput) {
sInput.addEventListener('input', e => applySettingsSearch(e.target.value));
sInput.addEventListener('keydown', e => {
if (e.key === 'Escape' && sInput.value) {
e.stopPropagation();
sInput.value = '';
applySettingsSearch('');
}
});
}
if (sClear) {
sClear.addEventListener('click', () => {
if (!sInput) return;
sInput.value = '';
applySettingsSearch('');
sInput.focus();
});
}
// Theme: setTheme already writes localStorage. Capture before/after so
// sync only fires if it actually changed.
$('#settingTheme').addEventListener('change', e => {
@@ -10613,16 +10872,22 @@ async function init() {
finish(true);
return;
}
// Reject if it collides with the other slot.
const other = (kind === 'full') ? state.autofillHotkeyPwd : state.autofillHotkeyFull;
if (JSON.stringify(other) === JSON.stringify(captured)) {
toast('That combo is already used by the other hotkey', 'warning');
// Reject if it collides with any of the other configurable slots.
const others = [
kind !== 'full' ? state.autofillHotkeyFull : null,
kind !== 'password' ? state.autofillHotkeyPwd : null,
kind !== 'quickSearch' ? state.quickSearchHotkey : null,
].filter(Boolean);
const capJson = JSON.stringify(captured);
if (others.some(o => JSON.stringify(o) === capJson)) {
toast('That combo is already used by another hotkey', 'warning');
finish(true);
return;
}
// Commit.
if (kind === 'full') state.autofillHotkeyFull = captured;
else state.autofillHotkeyPwd = captured;
else if (kind === 'password') state.autofillHotkeyPwd = captured;
else /* quickSearch */ state.quickSearchHotkey = captured;
btn.textContent = autofillComboLabel(captured);
finish(false);
autofillPushHotkeys(); // re-register in Delphi
@@ -10633,12 +10898,15 @@ async function init() {
}
bindHotkeyCapture('#settingAutofillFullCombo', 'full');
bindHotkeyCapture('#settingAutofillPwdCombo', 'password');
bindHotkeyCapture('#settingQuickSearchCombo', 'quickSearch');
$('#settingAutofillResetHotkeys').addEventListener('click', () => {
state.autofillHotkeyFull = { ctrl: true, shift: true, alt: false, win: false, key: 'L' };
state.autofillHotkeyPwd = { ctrl: true, shift: true, alt: false, win: false, key: 'P' };
state.quickSearchHotkey = { ctrl: true, shift: true, alt: false, win: false, key: 'Q' };
$('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull);
$('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd);
$('#settingQuickSearchCombo').textContent = autofillComboLabel(state.quickSearchHotkey);
autofillPushHotkeys();
saveServerSettings();
toast('Hotkeys reset to defaults');