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
↓ peach-browser-5233be9a.tar.gzatlas at this save
files changed9 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 −0manifest.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": {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",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",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": {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;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>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({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+});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+}