diff --git a/css/style.css b/css/style.css index 0c4c009..3dc0dab 100644 --- a/css/style.css +++ b/css/style.css @@ -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; diff --git a/delphi-backend/Handlers/PM.Handler.Folders.pas b/delphi-backend/Handlers/PM.Handler.Folders.pas index 4873ca4..d176253 100644 --- a/delphi-backend/Handlers/PM.Handler.Folders.pas +++ b/delphi-backend/Handlers/PM.Handler.Folders.pas @@ -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; diff --git a/delphi-backend/Source/PM.Bridge.pas b/delphi-backend/Source/PM.Bridge.pas index 30363ae..ee599bb 100644 --- a/delphi-backend/Source/PM.Bridge.pas +++ b/delphi-backend/Source/PM.Bridge.pas @@ -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; diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index a9cea6e..b68040a 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -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; diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index 7ab11e6..b7359cd 100644 Binary files a/delphi-backend/assets/assets.res and b/delphi-backend/assets/assets.res differ diff --git a/index.html b/index.html index 7bd1e9c..777f534 100644 --- a/index.html +++ b/index.html @@ -403,7 +403,16 @@

Settings

-
+
+ + + +
+
Appearance
@@ -610,6 +619,12 @@
+
+ + Quick search picker (find entry, fill from anywhere) + + +

Click a button, then press your new combo. Needs Ctrl, Alt or Win + a letter / digit / F-key.

diff --git a/js/app.js b/js/app.js index 5fc56d7..a2977ea 100644 --- a/js/app.js +++ b/js/app.js @@ -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, + 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; + if (kind === 'full') state.autofillHotkeyFull = 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');