5233be9a5934

feat: Add vault search MRU scope toggle (v3.6.148)

time
2026-06-03 03:40 UTC · 4 months ago
machine
koh2
agent
GPT 5.4 Mini
parent
ccffed5200ab
9 files manifest.firefox.json+1 −1manifest.json+1 −1package-lock.json+2 −2package.json+1 −1src/popup/styles/popup.css+6 −0src/popup/views/Vault.tsx+33 −94src/popup/views/contracts.test.tsx+43 −0src/popup/views/vault-search.test.ts+132 −0src/popup/views/vault-search.ts+148 −0
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "3.6.147",5+  "version": "3.6.148",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "3.6.147",4+  "version": "3.6.148",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package-lock.json+2 −2
@@ -1,12 +1,12 @@11 {22   "name": "peach",3-  "version": "3.6.147",3+  "version": "3.6.148",44   "lockfileVersion": 3,55   "requires": true,66   "packages": {77     "": {88       "name": "peach",9-      "version": "3.6.147",9+      "version": "3.6.148",1010       "dependencies": {1111         "@bokuweb/zstd-wasm": "^0.0.27",1212         "@stablelib/blake2s": "^2.0.1",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "3.6.147",3+  "version": "3.6.148",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/popup/styles/popup.css+6 −0
@@ -5480,6 +5480,12 @@54805480   color: var(--peach-t0);54815481 }54825482 5483+.peach-sort-scope-btn {5484+  padding-inline: 8px;5485+  font-size: 0.53125rem;5486+  letter-spacing: 0.05em;5487+}5488+54835489 .peach-sort-domain-hint {54845490   margin-left: auto;54855491   font-family: "IBM Plex Mono", monospace;

view file

src/popup/views/Vault.tsx+33 −94
@@ -1,9 +1,19 @@11 import { createEffect, createMemo, createSignal, For, Show } from "solid-js";22 import type { VaultEntry } from "../../shared/types";3-import { extractDomain, getDisplayDomain } from "../../shared/domain";3+import { getDisplayDomain } from "../../shared/domain";44 import { faviconUrl, faviconImgSig, globeFaviconSig, titleInitials, titleBackground } from "../../shared/favicon";55 import { popupCopy } from "../copy";6+import {7+  filterAndSortVaultEntries,8+  entryMatchesDomain,9+  VAULT_SEARCH_SCOPE_OPTIONS,10+  VAULT_SORT_OPTIONS,11+  type SearchScope,12+  type SortOrder,13+} from "./vault-search";614 15+export type { SortOrder } from "./vault-search";16+717 function vibrate(ms: number) {818   try { navigator.vibrate?.(ms); } catch { /* unsupported */ }919 }@@ -84,8 +94,6 @@8494   </svg>8595 );8696 87-export type SortOrder = "url-first" | "az" | "za" | "recent";88-8997 interface VaultProps {9098   entries: VaultEntry[];9199   selectedEntryId?: string;@@ -107,6 +115,7 @@107115 108116 export function VaultView(props: VaultProps) {109117   const [search, setSearch] = createSignal("");118+  const [searchScope, setSearchScope] = createSignal<SearchScope>("both");110119   const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());111120   const [notesOnly, setNotesOnly] = createSignal(false);112121   const [selecting, setSelecting] = createSignal(false);@@ -236,90 +245,15 @@236245     setSelectedTags(current);237246   }238247 239-  function fuzzyMatch(query: string, target: string): boolean {240-    query = query.toLowerCase();241-    target = target.toLowerCase();242-    let qi = 0;243-    for (let i = 0; i < target.length && qi < query.length; i++) {244-      if (target[i] === query[qi]) qi++;245-    }246-    return qi === query.length;247-  }248-249-  function isExactPrefix(query: string, target: string): boolean {250-    return target.toLowerCase().startsWith(query.toLowerCase());251-  }252-253-  function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {254-    if (!domain) return false;255-    if (entry.passkey?.rpId === domain) return true;256-    if (entry.type !== "login") return false;257-    // Tab domain is the eTLD+1 from extractDomain — compare against eTLD+1 of258-    // each entry URL too so subdomain entries also match.259-    if (entry.url && extractDomain(entry.url) === domain) return true;260-    for (const extra of entry.additionalUrls ?? []) {261-      if (extractDomain(extra) === domain) return true;262-    }263-    return false;264-  }265-266248   const filteredEntries = createMemo(() => {267-    let result = props.entries;268-269-    if (notesOnly()) {270-      result = result.filter(entry => entry.type === "note");271-    }272-273-    if (selectedTags().size > 0) {274-      result = result.filter(entry =>275-        Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))276-      );277-    }278-279-    const q = search().trim();280-    if (q) {281-      const searchableFor = (e: VaultEntry): string => {282-        if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;283-        const passkeyText = e.passkey284-          ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`285-          : "";286-        return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;287-      };288-      result = result289-        .filter((entry) => fuzzyMatch(q, searchableFor(entry)))290-        .sort((a, b) => {291-          const aSearchable = searchableFor(a).toLowerCase();292-          const bSearchable = searchableFor(b).toLowerCase();293-          const qLower = q.toLowerCase();294-          const aExact = isExactPrefix(qLower, aSearchable);295-          const bExact = isExactPrefix(qLower, bSearchable);296-          if (aExact && !bExact) return -1;297-          if (!aExact && bExact) return 1;298-          return 0;299-        });300-      return result;301-    }302-303-    const tabDomain = props.currentTabDomain ?? "";304-    const order = props.sortOrder;305-306-    if (order === "az") {307-      return [...result].sort((a, b) => a.title.localeCompare(b.title));308-    }309-    if (order === "za") {310-      return [...result].sort((a, b) => b.title.localeCompare(a.title));311-    }312-    if (order === "recent") {313-      return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));314-    }315-    if (tabDomain) {316-      return [...result].sort((a, b) => {317-        const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;318-        const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;319-        return aMatch - bMatch;320-      });321-    }322-    return result;249+    return filterAndSortVaultEntries(props.entries, {250+      notesOnly: notesOnly(),251+      selectedTags: selectedTags(),252+      query: search(),253+      searchScope: searchScope(),254+      sortOrder: props.sortOrder,255+      currentTabDomain: props.currentTabDomain,256+    });323257   });324258 325259   // Declaration order matters: `allVisibleIds` and `allSelected` read@@ -414,13 +348,6 @@414348     }415349   }416350 417-  const sortLabels: { key: SortOrder; label: string }[] = [418-    { key: "url-first", label: "smart" },419-    { key: "az", label: "A–Z" },420-    { key: "za", label: "Z–A" },421-    { key: "recent", label: "newest" },422-  ];423-424351   return (425352     <div class="peach-overlay-view ov-enter-up" style="display:flex">426353       <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>@@ -470,11 +397,23 @@470397           when={selecting()}471398           fallback={472399             <>473-              <For each={sortLabels}>400+              <For each={VAULT_SORT_OPTIONS}>474401                 {({ key, label }) => (475402                   <button476403                     class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}477404                     onClick={() => props.onSortOrderChange(key)}405+                  >406+                    {label}407+                  </button>408+                )}409+              </For>410+              <For each={VAULT_SEARCH_SCOPE_OPTIONS}>411+                {({ key, label }) => (412+                  <button413+                    class={`peach-sort-btn peach-sort-scope-btn${searchScope() === key ? " is-active" : ""}`}414+                    onClick={() => setSearchScope(key)}415+                    aria-label={`Search ${label}`}416+                    title={`Search ${label}`}478417                   >479418                     {label}480419                   </button>

view file

src/popup/views/contracts.test.tsx+43 −0
@@ -830,6 +830,49 @@830830     expect(document.activeElement).toBe(search);831831   });832832 833+  it("Vault search scope toggle flips between emails and usernames", async () => {834+    const container = mount(835+      <VaultView836+        entries={[837+          sampleEntry({838+            id: "entry-2",839+            title: "Mailbox",840+            username: "june-user",841+            email: "[email protected]",842+            url: "https://mail.example.com",843+          }),844+        ]}845+        sortOrder="recent"846+        onSelectEntry={vi.fn()}847+        onSortOrderChange={vi.fn()}848+        onAdd={vi.fn()}849+        onClose={vi.fn()}850+      />851+    );852+853+    await flushView();854+855+    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;856+    inputText(search, "june-user");857+    await flushView();858+859+    expect(container.querySelectorAll(".peach-entry-row")).toHaveLength(1);860+861+    const scopeButtons = Array.from(container.querySelectorAll(".peach-sort-scope-btn")) as HTMLButtonElement[];862+    const emails = scopeButtons.find((button) => button.textContent?.trim() === "emails");863+    const usernames = scopeButtons.find((button) => button.textContent?.trim() === "usernames");864+    expect(emails).toBeTruthy();865+    expect(usernames).toBeTruthy();866+867+    emails?.click();868+    await flushView();869+    expect(container.querySelectorAll(".peach-entry-row")).toHaveLength(0);870+871+    usernames?.click();872+    await flushView();873+    expect(container.querySelectorAll(".peach-entry-row")).toHaveLength(1);874+  });875+833876   it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {834877     parseImportSourceMock.mockResolvedValue(835878       sampleImportResult({

view file

src/popup/views/vault-search.test.ts+132 −0
@@ -0,0 +1,132 @@1+import { describe, expect, it } from "vitest";2+import type { PasskeyData, VaultEntry } from "../../shared/types";3+import { filterAndSortVaultEntries } from "./vault-search";4+5+function basePasskey(overrides: Partial<PasskeyData> = {}): PasskeyData {6+  return {7+    rpId: "example.com",8+    rpName: "Example",9+    credentialId: "cred-1",10+    publicKeyCose: "cose",11+    userHandle: "handle",12+    userName: "passkey-user",13+    userDisplayName: "Passkey User",14+    algorithm: -7,15+    signCount: 0,16+    aaguid: "",17+    backupEligible: false,18+    backupState: false,19+    createdAt: 1,20+    ...overrides,21+  };22+}23+24+function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {25+  return {26+    id: "entry-1",27+    title: "Example",28+    type: "login",29+    createdAt: 1,30+    modifiedAt: 1,31+    ...overrides,32+  };33+}34+35+describe("vault-search", () => {36+  it("sorts recent entries by last-used timestamps", () => {37+    const entries = [38+      entry({ id: "created-only", title: "Created only", modifiedAt: 10 }),39+      entry({ id: "login-used", title: "Login used", lastUsedAt: 300, modifiedAt: 20 }),40+      entry({ id: "passkey-used", title: "Passkey used", passkey: basePasskey({ lastUsedAt: 200 }) }),41+    ];42+43+    const sorted = filterAndSortVaultEntries(entries, {44+      notesOnly: false,45+      selectedTags: new Set(),46+      query: "",47+      searchScope: "both",48+      sortOrder: "recent",49+    });50+51+    expect(sorted.map((candidate) => candidate.id)).toEqual([52+      "login-used",53+      "passkey-used",54+      "created-only",55+    ]);56+  });57+58+  it("respects the email and username search scope toggle", () => {59+    const sample = entry({60+      id: "identity",61+      title: "Mailbox",62+      username: "june-user",63+      email: "[email protected]",64+      url: "https://mail.example.com",65+    });66+67+    const emailsOnly = filterAndSortVaultEntries([sample], {68+      notesOnly: false,69+      selectedTags: new Set(),70+      query: "june-user",71+      searchScope: "emails",72+      sortOrder: "recent",73+    });74+    const usernamesOnly = filterAndSortVaultEntries([sample], {75+      notesOnly: false,76+      selectedTags: new Set(),77+      query: "june-user",78+      searchScope: "usernames",79+      sortOrder: "recent",80+    });81+    const both = filterAndSortVaultEntries([sample], {82+      notesOnly: false,83+      selectedTags: new Set(),84+      query: "june-user",85+      searchScope: "both",86+      sortOrder: "recent",87+    });88+89+    expect(emailsOnly).toHaveLength(0);90+    expect(usernamesOnly).toHaveLength(1);91+    expect(both).toHaveLength(1);92+  });93+94+  it("uses recent usage as the tie-breaker for matching search results", () => {95+    const entries = [96+      entry({97+        id: "older",98+        title: "Alpha",99+        username: "alpha-user",100+        modifiedAt: 10,101+        lastUsedAt: 100,102+      }),103+      entry({104+        id: "newer",105+        title: "Alpha",106+        username: "alpha-user",107+        modifiedAt: 20,108+        lastUsedAt: 200,109+      }),110+      entry({111+        id: "fallback",112+        title: "Alpha",113+        username: "alpha-user",114+        modifiedAt: 30,115+      }),116+    ];117+118+    const sorted = filterAndSortVaultEntries(entries, {119+      notesOnly: false,120+      selectedTags: new Set(),121+      query: "alpha",122+      searchScope: "usernames",123+      sortOrder: "recent",124+    });125+126+    expect(sorted.map((candidate) => candidate.id)).toEqual([127+      "newer",128+      "older",129+      "fallback",130+    ]);131+  });132+});

view file

src/popup/views/vault-search.ts+148 −0
@@ -0,0 +1,148 @@1+import { extractDomain } from "../../shared/domain";2+import type { VaultEntry } from "../../shared/types";3+4+export type SortOrder = "url-first" | "az" | "za" | "recent";5+export type SearchScope = "both" | "emails" | "usernames";6+7+export const VAULT_SORT_OPTIONS: { key: SortOrder; label: string }[] = [8+  { key: "url-first", label: "smart" },9+  { key: "az", label: "A–Z" },10+  { key: "za", label: "Z–A" },11+  { key: "recent", label: "used" },12+];13+14+export const VAULT_SEARCH_SCOPE_OPTIONS: { key: SearchScope; label: string }[] = [15+  { key: "both", label: "both" },16+  { key: "emails", label: "emails" },17+  { key: "usernames", label: "usernames" },18+];19+20+function fuzzyMatch(query: string, target: string): boolean {21+  const q = query.toLowerCase();22+  const t = target.toLowerCase();23+  let qi = 0;24+  for (let i = 0; i < t.length && qi < q.length; i += 1) {25+    if (t[i] === q[qi]) qi += 1;26+  }27+  return qi === q.length;28+}29+30+function isExactPrefix(query: string, target: string): boolean {31+  return target.toLowerCase().startsWith(query.toLowerCase());32+}33+34+function compareTitle(a: VaultEntry, b: VaultEntry): number {35+  return a.title.localeCompare(b.title) || a.id.localeCompare(b.id);36+}37+38+export function getVaultEntryLastUsedAt(entry: VaultEntry): number {39+  return entry.passkey?.lastUsedAt40+    ?? entry.lastUsedAt41+    ?? entry.modifiedAt42+    ?? entry.createdAt43+    ?? 0;44+}45+46+export function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {47+  if (!domain) return false;48+  if (entry.passkey?.rpId === domain) return true;49+  if (entry.type !== "login") return false;50+  if (entry.url && extractDomain(entry.url) === domain) return true;51+  for (const extra of entry.additionalUrls ?? []) {52+    if (extractDomain(extra) === domain) return true;53+  }54+  return false;55+}56+57+export function getVaultEntrySearchText(entry: VaultEntry, scope: SearchScope): string {58+  if (entry.type === "note") return `${entry.title} ${entry.notes ?? ""}`;59+60+  const identityParts: string[] = [];61+  if (scope !== "usernames" && entry.email) identityParts.push(entry.email);62+  if (scope !== "emails" && entry.username) identityParts.push(entry.username);63+  if (scope !== "emails" && entry.passkey?.userName) identityParts.push(entry.passkey.userName);64+  if (entry.passkey?.userDisplayName) identityParts.push(entry.passkey.userDisplayName);65+66+  const passkeyParts = entry.passkey67+    ? `${entry.passkey.rpId} ${entry.passkey.userName} ${entry.passkey.userDisplayName}`68+    : "";69+70+  return `${entry.title} ${entry.url ?? ""} ${identityParts.join(" ")} ${passkeyParts}`;71+}72+73+function compareSearchHits(74+  a: VaultEntry,75+  b: VaultEntry,76+  query: string,77+  scope: SearchScope78+): number {79+  const qLower = query.toLowerCase();80+  const aSearchable = getVaultEntrySearchText(a, scope);81+  const bSearchable = getVaultEntrySearchText(b, scope);82+  const aExact = isExactPrefix(qLower, aSearchable);83+  const bExact = isExactPrefix(qLower, bSearchable);84+  if (aExact && !bExact) return -1;85+  if (!aExact && bExact) return 1;86+  const recency = getVaultEntryLastUsedAt(b) - getVaultEntryLastUsedAt(a);87+  return recency || compareTitle(a, b);88+}89+90+function compareRecent(a: VaultEntry, b: VaultEntry): number {91+  const recency = getVaultEntryLastUsedAt(b) - getVaultEntryLastUsedAt(a);92+  return recency || compareTitle(a, b);93+}94+95+function compareDomainFirst(a: VaultEntry, b: VaultEntry, domain: string): number {96+  const aMatch = entryMatchesDomain(a, domain) ? 0 : 1;97+  const bMatch = entryMatchesDomain(b, domain) ? 0 : 1;98+  if (aMatch !== bMatch) return aMatch - bMatch;99+  return compareRecent(a, b);100+}101+102+export interface VaultListFilters {103+  notesOnly: boolean;104+  selectedTags: ReadonlySet<string>;105+  query: string;106+  searchScope: SearchScope;107+  sortOrder: SortOrder;108+  currentTabDomain?: string;109+}110+111+export function filterAndSortVaultEntries(112+  entries: VaultEntry[],113+  filters: VaultListFilters114+): VaultEntry[] {115+  let result = entries;116+117+  if (filters.notesOnly) {118+    result = result.filter((entry) => entry.type === "note");119+  }120+121+  if (filters.selectedTags.size > 0) {122+    result = result.filter((entry) =>123+      Array.from(filters.selectedTags).every((tag) => entry.tags?.includes(tag))124+    );125+  }126+127+  const q = filters.query.trim();128+  if (q) {129+    result = result130+      .filter((entry) => fuzzyMatch(q, getVaultEntrySearchText(entry, filters.searchScope)))131+      .sort((a, b) => compareSearchHits(a, b, q, filters.searchScope));132+    return result;133+  }134+135+  if (filters.sortOrder === "az") {136+    return [...result].sort((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id));137+  }138+  if (filters.sortOrder === "za") {139+    return [...result].sort((a, b) => b.title.localeCompare(a.title) || b.id.localeCompare(a.id));140+  }141+  if (filters.sortOrder === "recent") {142+    return [...result].sort(compareRecent);143+  }144+  if (filters.currentTabDomain) {145+    return [...result].sort((a, b) => compareDomainFirst(a, b, filters.currentTabDomain!));146+  }147+  return result;148+}

view file