From 7b19145226cd7b0ae58085d6dc5e21d9ec9dae21 Mon Sep 17 00:00:00 2001 From: Andrew <159703254+andrewsoonqn@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:32:31 +0800 Subject: [PATCH] Share one fuzzy matcher across both dialogs Plugins dialog and the Speed Dial popup each carried a private copy of the same fuzzy matcher and had already drifted. Move the pure functions into a shared module loaded by both pages, and add a unit test beside it. --- resources/web/dialog/PluginsDialog/index.html | 1 + .../web/dialog/PluginsDialog/plugin-search.js | 56 ++---------------- resources/web/dialog/SpeedDial/index.html | 1 + resources/web/dialog/SpeedDial/speeddial.js | 33 ++--------- resources/web/js/fuzzy-search.js | 58 +++++++++++++++++++ resources/web/js/fuzzy-search.test.js | 37 ++++++++++++ 6 files changed, 106 insertions(+), 80 deletions(-) create mode 100644 resources/web/js/fuzzy-search.js create mode 100644 resources/web/js/fuzzy-search.test.js diff --git a/resources/web/dialog/PluginsDialog/index.html b/resources/web/dialog/PluginsDialog/index.html index bca59a9a29..9add22a95f 100644 --- a/resources/web/dialog/PluginsDialog/index.html +++ b/resources/web/dialog/PluginsDialog/index.html @@ -17,6 +17,7 @@ +
diff --git a/resources/web/dialog/PluginsDialog/plugin-search.js b/resources/web/dialog/PluginsDialog/plugin-search.js index 2df851507b..7f7f32da8f 100644 --- a/resources/web/dialog/PluginsDialog/plugin-search.js +++ b/resources/web/dialog/PluginsDialog/plugin-search.js @@ -4,60 +4,14 @@ function PluginSearchActive() { return pluginSearch.query.length > 0; } -// --- matcher: fold per-character on the fly so matched offsets stay in ORIGINAL coordinates --- -// why: highlighting marks slices of the original string; a separate folded string would desync offsets. -function FoldChar(ch) { - return ch.normalize("NFD").replace(/\p{Diacritic}/gu, ""); // accents always folded (both Cc states) -} -function Norm(ch, caseSensitive) { - const folded = FoldChar(ch); - return caseSensitive ? folded : folded.toLowerCase(); // Cc controls case only -} -function EscapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - +// why: matcher (FoldChar/Norm/FuzzyRanges/WholeWordRanges) lives in shared ../js/fuzzy-search.js, +// loaded before this script - it is shared with the Speed Dial popup. Cc = pluginSearch.caseSensitive. function MatchText(text, query) { if (!query) return []; - return pluginSearch.wholeWord ? WholeWordRanges(text, query) : FuzzyRanges(text, query); -} - -// Fuzzy: ordered subsequence. Builds ranges in original coordinates, merging adjacent runs on the fly. -function FuzzyRanges(text, query) { - const caseSensitive = pluginSearch.caseSensitive; - const needle = Array.from(query).map((ch) => Norm(ch, caseSensitive)).join(""); - const ranges = []; - let qi = 0; - for (let i = 0; i < text.length && qi < needle.length; i++) { - if (Norm(text[i], caseSensitive) === needle[qi]) { - const last = ranges[ranges.length - 1]; - if (last && last[1] === i) - last[1] = i + 1; - else - ranges.push([i, i + 1]); - qi++; - } - } - return qi === needle.length ? ranges : null; -} - -// Whole word: literal \b-bounded match that bypasses fuzzy; Cc still applies. The per-char fold keeps the -// haystack length-aligned to the original text, so regex indices map straight back to original offsets. -// note: one-to-many folds (ligatures, eszett) shift offsets by a char; rare in plugin names, cosmetic only. -function WholeWordRanges(text, query) { - const caseSensitive = pluginSearch.caseSensitive; - const haystack = Array.from(text).map((ch) => Norm(ch, caseSensitive)).join(""); - const needle = Array.from(query).map((ch) => Norm(ch, caseSensitive)).join(""); - if (!needle) - return null; - const re = new RegExp(`\\b${EscapeRegExp(needle)}\\b`, "g"); - const ranges = []; - let match; - // why: needle is non-empty, so \b-bounded matches are never zero-length - no empty-match guard needed. - while ((match = re.exec(haystack)) !== null) - ranges.push([match.index, match.index + match[0].length]); - return ranges.length > 0 ? ranges : null; + return pluginSearch.wholeWord + ? WholeWordRanges(text, query, pluginSearch.caseSensitive) + : FuzzyRanges(text, query, pluginSearch.caseSensitive); } // Per-plugin evaluator consumed by RenderPlugins. The name text mirrors LabelCell's pluginLabelText so diff --git a/resources/web/dialog/SpeedDial/index.html b/resources/web/dialog/SpeedDial/index.html index 0947bf3085..95a34b377a 100644 --- a/resources/web/dialog/SpeedDial/index.html +++ b/resources/web/dialog/SpeedDial/index.html @@ -7,6 +7,7 @@ + diff --git a/resources/web/dialog/SpeedDial/speeddial.js b/resources/web/dialog/SpeedDial/speeddial.js index 7ff8ef5bd2..51ad65e1be 100644 --- a/resources/web/dialog/SpeedDial/speeddial.js +++ b/resources/web/dialog/SpeedDial/speeddial.js @@ -9,38 +9,13 @@ var sel = { zone: "list", i: 0 }; // zone: 'list' | 'fav' var lastResizeHeight = 0; var matchIndex = {}; -function FoldChar(ch) { - return ch.normalize("NFD").replace(/\p{Diacritic}/gu, ""); -} - -function NormChar(ch) { - return FoldChar(ch).toLowerCase(); -} +// why: fuzzy matcher (FoldChar/Norm/FuzzyRanges) lives in shared ../../js/fuzzy-search.js, loaded before +// this script - it is shared with the Plugins dialog. Speed dial search is always case-insensitive. // element handles, assigned in OnInit (kept null so load-time touches no DOM) var qEl = null, listEl = null, favEl = null, clearEl = null, eyeEl = null, countEl = null; // ---- pure helpers (no DOM; unit-tested) ------------------------------------- -function fuzzyRanges(text, query) { - var t = text || ""; - var q = query || ""; - if (!q) - return null; - var needle = Array.from(q).map(function (c) { return NormChar(c); }).join(""); - var ranges = []; - var qi = 0; - for (var i = 0; i < t.length && qi < needle.length; i++) { - if (NormChar(t[i]) === needle[qi]) { - if (ranges.length && ranges[ranges.length - 1][1] === i) - ranges[ranges.length - 1][1] = i + 1; - else - ranges.push([i, i + 1]); - qi++; - } - } - return qi === needle.length ? ranges : null; -} - function filterActions(actions, query) { var q = (query || "").trim(); var list = actions || []; @@ -51,8 +26,8 @@ function filterActions(actions, query) { var out = []; for (var i = 0; i < list.length; i++) { var a = list[i]; - var titleMatch = fuzzyRanges(a.title, q); - var pkgMatch = fuzzyRanges(a.pkg, q); + var titleMatch = FuzzyRanges(a.title, q, false); + var pkgMatch = FuzzyRanges(a.pkg, q, false); if (!titleMatch && !pkgMatch) continue; matchIndex[a.id] = { title: titleMatch, pkg: pkgMatch, useTitle: !!titleMatch }; diff --git a/resources/web/js/fuzzy-search.js b/resources/web/js/fuzzy-search.js new file mode 100644 index 0000000000..9e00e40eae --- /dev/null +++ b/resources/web/js/fuzzy-search.js @@ -0,0 +1,58 @@ +// Shared fuzzy-search core for the webview dialogs (Plugins dialog, Speed Dial popup). +// why: both pages carried their own copy of this matcher and had already drifted; one source of truth. +// note: keep this DOM-free and plain global-scope (no export/module) - it is loaded by