From eddd11de589b1def4746b06e7db7e3fa5d953056 Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Fri, 13 Jun 2025 11:09:05 +0000 Subject: [PATCH 1/2] Fix search Fuse does not search individual keys, but creates a score across all fields... that is why search has been so sucky. Vibe coded this thing --- .../Content/routes/Search/searchItems.ts | 274 +++++++++--------- 1 file changed, 144 insertions(+), 130 deletions(-) diff --git a/packages/app/src/app/pages/Dashboard/Content/routes/Search/searchItems.ts b/packages/app/src/app/pages/Dashboard/Content/routes/Search/searchItems.ts index 6831a6686e0..a5ccc6d8f63 100644 --- a/packages/app/src/app/pages/Dashboard/Content/routes/Search/searchItems.ts +++ b/packages/app/src/app/pages/Dashboard/Content/routes/Search/searchItems.ts @@ -9,78 +9,92 @@ import Fuse from 'fuse.js'; import React, { useEffect } from 'react'; import { sandboxesTypes } from 'app/overmind/namespaces/dashboard/types'; -const useSearchedSandboxes = (query: string) => { - const state = useAppState(); - const actions = useActions(); - const [foundResults, setFoundResults] = React.useState< - | (SandboxFragmentDashboardFragment | SidebarCollectionDashboardFragment)[] - | null - >(null); - const [searchIndex, setSearchindex] = React.useState | null>(null); - - useEffect(() => { - actions.dashboard.getPage(sandboxesTypes.SEARCH); - }, [actions.dashboard, state.activeTeam]); - - useEffect( - () => { - setSearchindex(calculateSearchIndex(state.dashboard, state.activeTeam)); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - state.dashboard.sandboxes.SEARCH, - state.dashboard.repositoriesByTeamId, - state.activeTeam, - ] - ); - - useEffect(() => { - if (searchIndex) { - setFoundResults(searchIndex.search(query)); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query, searchIndex]); - - return foundResults; +type DashboardItem = SandboxFragmentDashboardFragment | SidebarCollectionDashboardFragment; + +// define which fields to search, with per-key thresholds & weights +const SEARCH_KEYS = [ + { name: 'title', threshold: 0.2, weight: 0.4 }, + { name: 'description', threshold: 0.3, weight: 0.2 }, + { name: 'alias', threshold: 0.3, weight: 0.2 }, + { name: 'source.template', threshold: 0.4, weight: 0.1 }, + { name: 'id', threshold: 0.0, weight: 0.1 }, // exact-only +] as const; + +interface SearchIndex { + fuses: Record>; + weights: Record; + items: DashboardItem[]; +} + +const buildSearchIndex = ( + dashboard: any, + activeTeam: string +): SearchIndex => { + const sandboxes: DashboardItem[] = + dashboard.sandboxes.SEARCH || []; + + const folders: DashboardItem[] = (dashboard.allCollections || []) + .map((c: Collection) => ({ + ...c, + title: c.name, + })) + .filter((f) => f.title); + + const repos: DashboardItem[] = + (dashboard.repositoriesByTeamId[activeTeam] || []).map( + (r: Repository) => ({ + title: r.repository.name, + description: r.repository.owner, + ...r, + }) + ); + + const items = [...sandboxes, ...folders, ...repos]; + + // build a Fuse instance per key + const fuses: Record> = {}; + const weights: Record = {}; + + for (const { name, threshold, weight } of SEARCH_KEYS) { + fuses[name] = new Fuse(items, { + keys: [name], + threshold: threshold, + distance: 1000, + }); + weights[name] = weight; + } + + return { fuses, weights, items }; }; -const calculateSearchIndex = (dashboard: any, activeTeam: string) => { - const sandboxes = dashboard.sandboxes.SEARCH || []; - - const folders: Collection[] = (dashboard.allCollections || []) - .map(collection => ({ - ...collection, - title: collection.name, - })) - .filter(f => f.title); - - const teamRepos = dashboard.repositoriesByTeamId[activeTeam] ?? []; - const repositories = (teamRepos || []).map((repo: Repository) => { - return { - title: repo.repository.name, - /** - * Due to the lack of description we add the owner so we can at least - * include that in the search query. - */ - description: repo.repository.owner, - ...repo, - }; - }); +// merge+dedupe results from every key +const mergeSearchResults = ( + index: SearchIndex, + query: string +): DashboardItem[] => { + const hits: Array<{ item: DashboardItem; score: number }> = []; + + for (const key of Object.keys(index.fuses)) { + const fuse = index.fuses[key]; + const weight = index.weights[key]!; + for (const { item, score } of fuse.search(query)) { + hits.push({ item, score: score * weight }); + } + } + + // dedupe by item.id, keep the best (lowest) weighted score + const bestById: Record = {}; + for (const { item, score } of hits) { + const id = (item as any).id as string; + if (!bestById[id] || score < bestById[id].score) { + bestById[id] = { item, score }; + } + } - return new Fuse([...sandboxes, ...folders, ...repositories], { - threshold: 0.1, - distance: 1000, - keys: [ - { name: 'title', weight: 0.4 }, - { name: 'description', weight: 0.2 }, - { name: 'alias', weight: 0.2 }, - { name: 'source.template', weight: 0.1 }, - { name: 'id', weight: 0.1 }, - ], - }); + // sort & return + return Object.values(bestById) + .sort((a, b) => a.score - b.score) + .map((r) => r.item); }; export const useGetItems = ({ @@ -91,73 +105,73 @@ export const useGetItems = ({ query: string; username: string; getFilteredSandboxes: ( - sandboxes: ( - | SandboxFragmentDashboardFragment - | SidebarCollectionDashboardFragment - )[] + list: DashboardItem[] ) => SandboxFragmentDashboardFragment[]; }) => { - const foundResults: Array< - SandboxFragmentDashboardFragment | SidebarCollectionDashboardFragment - > = useSearchedSandboxes(query) || []; + const state = useAppState(); + const actions = useActions(); - // @ts-ignore - const sandboxesInSearch = foundResults.filter(s => !s.path); - // @ts-ignore - const foldersInSearch = foundResults.filter(s => s.path); + // load page once + useEffect(() => { + actions.dashboard.getPage(sandboxesTypes.SEARCH); + }, [actions.dashboard, state.activeTeam]); - const filteredSandboxes: SandboxFragmentDashboardFragment[] = getFilteredSandboxes( - sandboxesInSearch + // keep a SearchIndex in state + const [searchIndex, setSearchIndex] = React.useState( + null ); + useEffect(() => { + const idx = buildSearchIndex(state.dashboard, state.activeTeam); + setSearchIndex(idx); + }, [ + state.dashboard.sandboxes.SEARCH, + state.dashboard.allCollections, + state.dashboard.repositoriesByTeamId, + state.activeTeam, + ]); + + // run the merged search whenever query or index changes + const [foundResults, setFoundResults] = React.useState( + [] + ); + useEffect(() => { + if (searchIndex && query) { + setFoundResults(mergeSearchResults(searchIndex, query)); + } else { + setFoundResults([]); + } + }, [query, searchIndex]); - const orderedSandboxes = [...foldersInSearch, ...filteredSandboxes].filter( - item => { - // @ts-ignore - if (item.path || item.repository) { - return true; - } - - const sandbox = item as SandboxFragmentDashboardFragment; + // then the rest is just your existing filtering / mapping logic: + const sandboxesInSearch = foundResults.filter((s) => !(s as any).path); + const foldersInSearch = foundResults.filter((s) => (s as any).path); + const filteredSandboxes = getFilteredSandboxes(sandboxesInSearch); + + const ordered = [...foldersInSearch, ...filteredSandboxes].filter((item) => { + if ((item as any).path || (item as any).repository) return true; + const sb = item as SandboxFragmentDashboardFragment; + return ( + !sb.draft || + (sb.draft && sb.author.username === username) + ); + }); - // Remove draft sandboxes from other authors - return ( - !sandbox.draft || - (sandbox.draft && sandbox.author.username === username) - ); + const items = ordered.map((found) => { + if ((found as any).path) { + return { type: 'folder', ...(found as object) } as any; } - ); + if ((found as any).repository) { + const f = found as any; + return { + type: 'repository', + repository: { + branchCount: f.branchCount, + repository: f.repository, + }, + } as any; + } + return { type: 'sandbox', sandbox: found } as any; + }); - // @ts-ignore - const items: DashboardGridItem[] = - foundResults != null - ? orderedSandboxes.map(found => { - // @ts-ignore - if (found.path) { - return { - type: 'folder', - ...found, - }; - } - - // @ts-ignore - if (found.repository) { - return { - type: 'repository', - repository: { - // @ts-ignore - branchCount: found.branchCount, - // @ts-ignore - repository: found.repository, - }, - }; - } - - return { - type: 'sandbox', - sandbox: found, - }; - }) - : [{ type: 'skeleton-row' }]; - - return [items, sandboxesInSearch]; -}; + return [items, sandboxesInSearch] as const; +}; \ No newline at end of file From b2d32aeca24a2e854615729df5d0cc69c4da340e Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Tue, 17 Jun 2025 10:05:48 +0000 Subject: [PATCH 2/2] loading state --- .../Dashboard/Content/routes/Search/index.tsx | 7 +- .../Content/routes/Search/searchItems.ts | 88 +++++++++---------- 2 files changed, 44 insertions(+), 51 deletions(-) diff --git a/packages/app/src/app/pages/Dashboard/Content/routes/Search/index.tsx b/packages/app/src/app/pages/Dashboard/Content/routes/Search/index.tsx index 2a645fb01df..25d89a4e8ea 100644 --- a/packages/app/src/app/pages/Dashboard/Content/routes/Search/index.tsx +++ b/packages/app/src/app/pages/Dashboard/Content/routes/Search/index.tsx @@ -19,7 +19,7 @@ export const SearchComponent = () => { } = useAppState(); const location = useLocation(); const query = new URLSearchParams(location.search).get('query'); - const [items] = useGetItems({ + const [items, _, isLoadingQuery] = useGetItems({ query, username: user?.username, getFilteredSandboxes, @@ -48,8 +48,9 @@ export const SearchComponent = () => { ) : ( - There are no sandboxes, branches or repositories that match your - query + {isLoadingQuery + ? 'Loading index...' + : 'There are no sandboxes, branches or repositories that match your query'} )} diff --git a/packages/app/src/app/pages/Dashboard/Content/routes/Search/searchItems.ts b/packages/app/src/app/pages/Dashboard/Content/routes/Search/searchItems.ts index a5ccc6d8f63..6b3964d307a 100644 --- a/packages/app/src/app/pages/Dashboard/Content/routes/Search/searchItems.ts +++ b/packages/app/src/app/pages/Dashboard/Content/routes/Search/searchItems.ts @@ -9,15 +9,17 @@ import Fuse from 'fuse.js'; import React, { useEffect } from 'react'; import { sandboxesTypes } from 'app/overmind/namespaces/dashboard/types'; -type DashboardItem = SandboxFragmentDashboardFragment | SidebarCollectionDashboardFragment; +type DashboardItem = + | SandboxFragmentDashboardFragment + | SidebarCollectionDashboardFragment; // define which fields to search, with per-key thresholds & weights const SEARCH_KEYS = [ - { name: 'title', threshold: 0.2, weight: 0.4 }, - { name: 'description', threshold: 0.3, weight: 0.2 }, - { name: 'alias', threshold: 0.3, weight: 0.2 }, - { name: 'source.template', threshold: 0.4, weight: 0.1 }, - { name: 'id', threshold: 0.0, weight: 0.1 }, // exact-only + { name: 'title', threshold: 0.2, weight: 0.4 }, + { name: 'description', threshold: 0.3, weight: 0.2 }, + { name: 'alias', threshold: 0.3, weight: 0.2 }, + { name: 'source.template', threshold: 0.4, weight: 0.1 }, + { name: 'id', threshold: 0.0, weight: 0.1 }, // exact-only ] as const; interface SearchIndex { @@ -26,28 +28,23 @@ interface SearchIndex { items: DashboardItem[]; } -const buildSearchIndex = ( - dashboard: any, - activeTeam: string -): SearchIndex => { - const sandboxes: DashboardItem[] = - dashboard.sandboxes.SEARCH || []; +const buildSearchIndex = (dashboard: any, activeTeam: string): SearchIndex => { + const sandboxes: DashboardItem[] = dashboard.sandboxes.SEARCH || []; const folders: DashboardItem[] = (dashboard.allCollections || []) .map((c: Collection) => ({ ...c, title: c.name, })) - .filter((f) => f.title); + .filter(f => f.title); - const repos: DashboardItem[] = - (dashboard.repositoriesByTeamId[activeTeam] || []).map( - (r: Repository) => ({ - title: r.repository.name, - description: r.repository.owner, - ...r, - }) - ); + const repos: DashboardItem[] = ( + dashboard.repositoriesByTeamId[activeTeam] || [] + ).map((r: Repository) => ({ + title: r.repository.name, + description: r.repository.owner, + ...r, + })); const items = [...sandboxes, ...folders, ...repos]; @@ -57,9 +54,9 @@ const buildSearchIndex = ( for (const { name, threshold, weight } of SEARCH_KEYS) { fuses[name] = new Fuse(items, { - keys: [name], + keys: [name], threshold: threshold, - distance: 1000, + distance: 1000, }); weights[name] = weight; } @@ -72,29 +69,26 @@ const mergeSearchResults = ( index: SearchIndex, query: string ): DashboardItem[] => { - const hits: Array<{ item: DashboardItem; score: number }> = []; + const hits: Array = []; for (const key of Object.keys(index.fuses)) { const fuse = index.fuses[key]; - const weight = index.weights[key]!; - for (const { item, score } of fuse.search(query)) { - hits.push({ item, score: score * weight }); + for (const item of fuse.search(query)) { + hits.push(item); } } // dedupe by item.id, keep the best (lowest) weighted score - const bestById: Record = {}; - for (const { item, score } of hits) { + const byId: Record = {}; + for (const item of hits) { const id = (item as any).id as string; - if (!bestById[id] || score < bestById[id].score) { - bestById[id] = { item, score }; + if (!byId[id]) { + byId[id] = item; } } // sort & return - return Object.values(bestById) - .sort((a, b) => a.score - b.score) - .map((r) => r.item); + return Object.values(byId); }; export const useGetItems = ({ @@ -121,6 +115,8 @@ export const useGetItems = ({ null ); useEffect(() => { + if (!state.dashboard.sandboxes.SEARCH || !state.dashboard.allCollections) + return; const idx = buildSearchIndex(state.dashboard, state.activeTeam); setSearchIndex(idx); }, [ @@ -131,9 +127,7 @@ export const useGetItems = ({ ]); // run the merged search whenever query or index changes - const [foundResults, setFoundResults] = React.useState( - [] - ); + const [foundResults, setFoundResults] = React.useState([]); useEffect(() => { if (searchIndex && query) { setFoundResults(mergeSearchResults(searchIndex, query)); @@ -143,20 +137,18 @@ export const useGetItems = ({ }, [query, searchIndex]); // then the rest is just your existing filtering / mapping logic: - const sandboxesInSearch = foundResults.filter((s) => !(s as any).path); - const foldersInSearch = foundResults.filter((s) => (s as any).path); + const sandboxesInSearch = foundResults.filter(s => !(s as any).path); + const foldersInSearch = foundResults.filter(s => (s as any).path); const filteredSandboxes = getFilteredSandboxes(sandboxesInSearch); + const isLoadingQuery = query && !searchIndex; - const ordered = [...foldersInSearch, ...filteredSandboxes].filter((item) => { + const ordered = [...foldersInSearch, ...filteredSandboxes].filter(item => { if ((item as any).path || (item as any).repository) return true; const sb = item as SandboxFragmentDashboardFragment; - return ( - !sb.draft || - (sb.draft && sb.author.username === username) - ); + return !sb.draft || (sb.draft && sb.author.username === username); }); - const items = ordered.map((found) => { + const items = ordered.map(found => { if ((found as any).path) { return { type: 'folder', ...(found as object) } as any; } @@ -166,12 +158,12 @@ export const useGetItems = ({ type: 'repository', repository: { branchCount: f.branchCount, - repository: f.repository, + repository: f.repository, }, } as any; } return { type: 'sandbox', sandbox: found } as any; }); - return [items, sandboxesInSearch] as const; -}; \ No newline at end of file + return [items, sandboxesInSearch, isLoadingQuery] as const; +};