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",
+ );
+ });
+});