From f4fef17a47190c99dfe68a98f5593422e539931b Mon Sep 17 00:00:00 2001 From: Davide Cazzetta Date: Thu, 2 Jul 2026 15:41:17 +0200 Subject: [PATCH] feat(sidebar): typo-tolerant fuzzy matching for the table/trigger filters Replace the substring filter with Fuse.js so misspellings still match (e.g. "ordrs" finds "orders") and the closest names rank first. Applied to the table and trigger filters across the schema, database, and flat layouts via a shared fuzzyFilter helper. Adds the fuse.js dependency. --- package.json | 1 + pnpm-lock.yaml | 9 +++++ src/components/layout/ExplorerSidebar.tsx | 9 ++--- .../layout/sidebar/SidebarDatabaseItem.tsx | 9 ++--- .../layout/sidebar/SidebarSchemaItem.tsx | 9 ++--- src/utils/fuzzy.ts | 18 +++++++++ tests/utils/fuzzy.test.ts | 40 +++++++++++++++++++ 7 files changed, 77 insertions(+), 18 deletions(-) create mode 100644 src/utils/fuzzy.ts create mode 100644 tests/utils/fuzzy.test.ts diff --git a/package.json b/package.json index 99f5d2e9..85d1a470 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "clsx": "^2.1.1", "dagre": "^0.8.5", "emoji-picker-react": "^4.19.1", + "fuse.js": "^7.4.2", "i18next": "^25.10.10", "i18next-browser-languagedetector": "^8.2.1", "json-edit-react": "^1.29.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 632fee83..025f756b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: emoji-picker-react: specifier: ^4.19.1 version: 4.19.1(react@19.2.4) + fuse.js: + specifier: ^7.4.2 + version: 7.4.2 i18next: specifier: ^25.10.10 version: 25.10.10(typescript@5.9.3) @@ -1810,6 +1813,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + fuse.js@7.4.2: + resolution: {integrity: sha512-LVbzjD4WA6UP5B1UnP8wuaXJiLnqMdM/E4fiJXTJ5haJ5b/MBNsK29h2fm6swEoQaVQjvYFWKLE2RanyZIoRVQ==} + engines: {node: '>=10'} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -4550,6 +4557,8 @@ snapshots: function-bind@1.1.2: {} + fuse.js@7.4.2: {} + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} diff --git a/src/components/layout/ExplorerSidebar.tsx b/src/components/layout/ExplorerSidebar.tsx index 835a00b3..cede0dea 100644 --- a/src/components/layout/ExplorerSidebar.tsx +++ b/src/components/layout/ExplorerSidebar.tsx @@ -64,6 +64,7 @@ import { ConfirmModal } from "../modals/ConfirmModal"; import { Accordion } from "./sidebar/Accordion"; import { SidebarTableItem } from "./sidebar/SidebarTableItem"; import { buildTableItemSelector } from "../../utils/sidebarTableItem"; +import { fuzzyFilter } from "../../utils/fuzzy"; import { SidebarViewItem } from "./sidebar/SidebarViewItem"; import { SidebarRoutineItem } from "./sidebar/SidebarRoutineItem"; import { SidebarSchemaItem } from "./sidebar/SidebarSchemaItem"; @@ -1455,9 +1456,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar )} {(() => { - const filtered = tableFilter - ? tables.filter((tbl) => tbl.name.toLowerCase().includes(tableFilter.toLowerCase())) - : tables; + const filtered = fuzzyFilter(tables, tableFilter, (tbl) => tbl.name); return filtered.length === 0 ? (
{tableFilter ? t("sidebar.noTablesMatch") : t("sidebar.noTables")} @@ -1642,9 +1641,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar
)} {(() => { - const filtered = triggerFilterFlat - ? triggers.filter((tr) => tr.name.toLowerCase().includes(triggerFilterFlat.toLowerCase())) - : triggers; + const filtered = fuzzyFilter(triggers, triggerFilterFlat, (tr) => tr.name); return filtered.length === 0 ? (
{triggerFilterFlat ? t("sidebar.noTriggersMatch") : t("sidebar.noTriggers")} diff --git a/src/components/layout/sidebar/SidebarDatabaseItem.tsx b/src/components/layout/sidebar/SidebarDatabaseItem.tsx index ea7e9d1e..f97ebcbe 100644 --- a/src/components/layout/sidebar/SidebarDatabaseItem.tsx +++ b/src/components/layout/sidebar/SidebarDatabaseItem.tsx @@ -25,6 +25,7 @@ import type { ContextMenuData } from "../../../types/sidebar"; import type { DriverCapabilities } from "../../../types/plugins"; import { groupRoutinesByType } from "../../../utils/routines"; import { formatObjectCount } from "../../../utils/schema"; +import { fuzzyFilter } from "../../../utils/fuzzy"; interface SidebarDatabaseItemProps { databaseName: string; @@ -109,15 +110,11 @@ export const SidebarDatabaseItem = ({ const [triggerFilter, setTriggerFilter] = useState(""); const tables = databaseData?.tables ?? []; - const filteredTables = tableFilter - ? tables.filter((t) => t.name.toLowerCase().includes(tableFilter.toLowerCase())) - : tables; + const filteredTables = fuzzyFilter(tables, tableFilter, (t) => t.name); const views = databaseData?.views ?? []; const routines = databaseData?.routines ?? []; const triggers = databaseData?.triggers ?? []; - const filteredTriggers = triggerFilter - ? triggers.filter((tr) => tr.name.toLowerCase().includes(triggerFilter.toLowerCase())) - : triggers; + const filteredTriggers = fuzzyFilter(triggers, triggerFilter, (tr) => tr.name); const isLoading = databaseData?.isLoading ?? false; const isLoaded = databaseData?.isLoaded ?? false; diff --git a/src/components/layout/sidebar/SidebarSchemaItem.tsx b/src/components/layout/sidebar/SidebarSchemaItem.tsx index bb1abd3c..77421210 100644 --- a/src/components/layout/sidebar/SidebarSchemaItem.tsx +++ b/src/components/layout/sidebar/SidebarSchemaItem.tsx @@ -20,6 +20,7 @@ import type { TableColumn } from "../../../types/schema"; import type { ContextMenuData } from "../../../types/sidebar"; import { groupRoutinesByType } from "../../../utils/routines"; import { formatObjectCount } from "../../../utils/schema"; +import { fuzzyFilter } from "../../../utils/fuzzy"; interface SidebarSchemaItemProps { schemaName: string; @@ -115,16 +116,12 @@ export const SidebarSchemaItem = ({ } const tables = schemaData?.tables ?? []; - const filteredTables = tableFilter - ? tables.filter((t) => t.name.toLowerCase().includes(tableFilter.toLowerCase())) - : tables; + const filteredTables = fuzzyFilter(tables, tableFilter, (t) => t.name); const views = schemaData?.views ?? []; const materializedViews = schemaData?.materializedViews ?? []; const routines = schemaData?.routines ?? []; const triggers = schemaData?.triggers ?? []; - const filteredTriggers = triggerFilter - ? triggers.filter((tr) => tr.name.toLowerCase().includes(triggerFilter.toLowerCase())) - : triggers; + const filteredTriggers = fuzzyFilter(triggers, triggerFilter, (tr) => tr.name); const isLoading = schemaData?.isLoading ?? false; const isLoaded = schemaData?.isLoaded ?? false; diff --git a/src/utils/fuzzy.ts b/src/utils/fuzzy.ts new file mode 100644 index 00000000..9955a5b7 --- /dev/null +++ b/src/utils/fuzzy.ts @@ -0,0 +1,18 @@ +import Fuse from "fuse.js"; + +export function fuzzyFilter( + items: T[], + query: string, + getText: (item: T) => string, +): T[] { + const trimmed = query.trim(); + if (trimmed === "") return items; + + const texts = items.map(getText); + const fuse = new Fuse(texts, { + threshold: 0.4, + ignoreLocation: true, + }); + + return fuse.search(trimmed).map((result) => items[result.refIndex]); +} diff --git a/tests/utils/fuzzy.test.ts b/tests/utils/fuzzy.test.ts new file mode 100644 index 00000000..defdbc36 --- /dev/null +++ b/tests/utils/fuzzy.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { fuzzyFilter } from "../../src/utils/fuzzy"; + +const tables = [ + { name: "users" }, + { name: "user_roles" }, + { name: "orders" }, + { name: "order_items" }, + { name: "audit_log" }, +]; +const byName = (t: { name: string }) => t.name; + +describe("fuzzyFilter", () => { + it("returns items unchanged for an empty query", () => { + expect(fuzzyFilter(tables, "", byName)).toEqual(tables); + expect(fuzzyFilter(tables, " ", byName)).toEqual(tables); + }); + + it("matches case-insensitively", () => { + expect(fuzzyFilter(tables, "USERS", byName).map(byName)).toContain("users"); + }); + + it("keeps relevant matches and ranks the closest first", () => { + const result = fuzzyFilter(tables, "order", byName).map(byName); + expect(result).toContain("orders"); + expect(result).toContain("order_items"); + expect(result[0]).toBe("orders"); + }); + + it("tolerates single-character typos", () => { + expect(fuzzyFilter(tables, "ordrs", byName).map(byName)).toContain("orders"); + expect(fuzzyFilter(tables, "userz", byName).map(byName)).toContain("users"); + }); + + it("excludes clearly unrelated names", () => { + expect(fuzzyFilter(tables, "audit", byName).map(byName)).not.toContain( + "orders", + ); + }); +});