diff --git a/packages/client-runtime/src/state/filesystem.test.ts b/packages/client-runtime/src/state/filesystem.test.ts index 038fc23cb9858..3234454bc7a8c 100644 --- a/packages/client-runtime/src/state/filesystem.test.ts +++ b/packages/client-runtime/src/state/filesystem.test.ts @@ -116,6 +116,7 @@ describe("filesystem browse model", () => { exactEntry: null, }); expect(filterFilesystemBrowseEntries([entry], "De").visibleEntries).toEqual([entry]); + expect(filterFilesystemBrowseEntries([entry], "dévpt").visibleEntries).toEqual([entry]); expect(filterFilesystemBrowseEntries([entry], "D\u00e9veloppement").exactEntry).toBe(entry); // Linux keeps both spellings as separate folders; the one typed exactly wins. const composed = { name: "D\u00e9veloppement", fullPath: "/home/me/D\u00e9veloppement" }; @@ -155,3 +156,42 @@ describe("browse navigation", () => { expect(commits).toEqual(["second"]); }); }); + +describe("browse entry filtering", () => { + const entries = ["my-t3code", "t3code", "cotton", "T3Code-old", ".t3", "docs"].map((name) => ({ + name, + fullPath: `~/code/${name}`, + })); + const names = (query: string) => + filterFilesystemBrowseEntries(entries, query).visibleEntries.map((entry) => entry.name); + + it("keeps every visible entry in order for an empty query", () => { + expect(names("")).toEqual(["my-t3code", "t3code", "cotton", "T3Code-old", "docs"]); + }); + + it("matches substrings and subsequences, best match first", () => { + expect(names("code")).toEqual(["t3code", "T3Code-old", "my-t3code"]); + expect(names("t3c")).toEqual(["t3code", "T3Code-old", "my-t3code"]); + expect(names("tcd")).toEqual(["t3code", "T3Code-old", "my-t3code"]); + expect(names("xyz")).toEqual([]); + }); + + it("ranks a word-start match in a long name above any substring match", () => { + const longName = `${"a".repeat(240)}-foo`; + const folders = [ + { name: "xfoo", fullPath: "~/xfoo" }, + { name: longName, fullPath: "~/long" }, + ]; + expect( + filterFilesystemBrowseEntries(folders, "foo").visibleEntries.map((entry) => entry.name), + ).toEqual([longName, "xfoo"]); + }); + + it("shows dot folders only when the query starts with a dot", () => { + expect(names("t3")).not.toContain(".t3"); + expect(filterFilesystemBrowseEntries(entries, ".t3")).toEqual({ + visibleEntries: [entries[4]], + exactEntry: entries[4], + }); + }); +}); diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts index 4f369d2ffde41..c5b3072f4b199 100644 --- a/packages/client-runtime/src/state/filesystem.ts +++ b/packages/client-runtime/src/state/filesystem.ts @@ -5,6 +5,7 @@ import { sessionGrantsScope, type SessionGrantInput, } from "@t3tools/contracts"; +import { scoreQueryMatch } from "@t3tools/shared/searchRanking"; import { Atom } from "effect/reactivity"; import type { @@ -79,12 +80,44 @@ export function filterFilesystemBrowseEntries( const lowerQuery = query.toLowerCase(); const lowerNormalizedQuery = normalizedQuery.toLowerCase(); const showHidden = query.startsWith("."); - const visibleEntries = entries.filter( - (entry) => - (entry.name.toLowerCase().startsWith(lowerQuery) || - entry.name.normalize("NFC").toLowerCase().startsWith(lowerNormalizedQuery)) && - (showHidden || !entry.name.startsWith(".")), - ); + const candidates = entries.filter((entry) => showHidden || !entry.name.startsWith(".")); + const visibleEntries = + lowerQuery.length === 0 + ? candidates + : candidates + .map((entry) => { + const options = { + // Position penalties stay under 1000 for 255-character names, so tiers never overlap. + query: lowerNormalizedQuery, + exactBase: 0, + prefixBase: 1000, + boundaryBase: 2000, + includesBase: 3000, + fuzzyBase: 4000, + boundaryMarkers: [" ", "-", "_", "."], + }; + const rawScore = scoreQueryMatch({ + ...options, + value: entry.name.toLowerCase(), + query: lowerQuery, + }); + const normalizedScore = scoreQueryMatch({ + ...options, + value: entry.name.normalize("NFC").toLowerCase(), + }); + const score = + rawScore === null + ? normalizedScore + : normalizedScore === null + ? rawScore + : Math.min(rawScore, normalizedScore); + return { entry, score }; + }) + .filter((match): match is { entry: FilesystemBrowseEntry; score: number } => { + return match.score !== null; + }) + .sort((left, right) => left.score - right.score) + .map((match) => match.entry); const normalizedMatches = visibleEntries.filter( (entry) => entry.name.normalize("NFC") === normalizedQuery, );