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 52a4792a..396e48f2 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"; @@ -1471,9 +1472,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")} @@ -1658,9 +1657,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 ccd9081a..6f277413 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 69e44cbb..56a554c0 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", + ); + }); +});