Skip to content

Commit fc143b1

Browse files
Fix search (#8764)
* 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 * loading state
1 parent 855c0ee commit fc143b1

File tree

2 files changed

+137
-130
lines changed

2 files changed

+137
-130
lines changed

packages/app/src/app/pages/Dashboard/Content/routes/Search/index.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const SearchComponent = () => {
1919
} = useAppState();
2020
const location = useLocation();
2121
const query = new URLSearchParams(location.search).get('query');
22-
const [items] = useGetItems({
22+
const [items, _, isLoadingQuery] = useGetItems({
2323
query,
2424
username: user?.username,
2525
getFilteredSandboxes,
@@ -48,8 +48,9 @@ export const SearchComponent = () => {
4848
) : (
4949
<Stack justify="center" align="center" marginTop={120}>
5050
<Text variant="muted">
51-
There are no sandboxes, branches or repositories that match your
52-
query
51+
{isLoadingQuery
52+
? 'Loading index...'
53+
: 'There are no sandboxes, branches or repositories that match your query'}
5354
</Text>
5455
</Stack>
5556
)}

packages/app/src/app/pages/Dashboard/Content/routes/Search/searchItems.ts

Lines changed: 133 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -9,78 +9,86 @@ import Fuse from 'fuse.js';
99
import React, { useEffect } from 'react';
1010
import { sandboxesTypes } from 'app/overmind/namespaces/dashboard/types';
1111

12-
const useSearchedSandboxes = (query: string) => {
13-
const state = useAppState();
14-
const actions = useActions();
15-
const [foundResults, setFoundResults] = React.useState<
16-
| (SandboxFragmentDashboardFragment | SidebarCollectionDashboardFragment)[]
17-
| null
18-
>(null);
19-
const [searchIndex, setSearchindex] = React.useState<Fuse<
20-
SandboxFragmentDashboardFragment | SidebarCollectionDashboardFragment,
21-
unknown
22-
> | null>(null);
23-
24-
useEffect(() => {
25-
actions.dashboard.getPage(sandboxesTypes.SEARCH);
26-
}, [actions.dashboard, state.activeTeam]);
27-
28-
useEffect(
29-
() => {
30-
setSearchindex(calculateSearchIndex(state.dashboard, state.activeTeam));
31-
},
32-
// eslint-disable-next-line react-hooks/exhaustive-deps
33-
[
34-
state.dashboard.sandboxes.SEARCH,
35-
state.dashboard.repositoriesByTeamId,
36-
state.activeTeam,
37-
]
38-
);
39-
40-
useEffect(() => {
41-
if (searchIndex) {
42-
setFoundResults(searchIndex.search(query));
43-
}
44-
// eslint-disable-next-line react-hooks/exhaustive-deps
45-
}, [query, searchIndex]);
46-
47-
return foundResults;
48-
};
49-
50-
const calculateSearchIndex = (dashboard: any, activeTeam: string) => {
51-
const sandboxes = dashboard.sandboxes.SEARCH || [];
52-
53-
const folders: Collection[] = (dashboard.allCollections || [])
54-
.map(collection => ({
55-
...collection,
56-
title: collection.name,
12+
type DashboardItem =
13+
| SandboxFragmentDashboardFragment
14+
| SidebarCollectionDashboardFragment;
15+
16+
// define which fields to search, with per-key thresholds & weights
17+
const SEARCH_KEYS = [
18+
{ name: 'title', threshold: 0.2, weight: 0.4 },
19+
{ name: 'description', threshold: 0.3, weight: 0.2 },
20+
{ name: 'alias', threshold: 0.3, weight: 0.2 },
21+
{ name: 'source.template', threshold: 0.4, weight: 0.1 },
22+
{ name: 'id', threshold: 0.0, weight: 0.1 }, // exact-only
23+
] as const;
24+
25+
interface SearchIndex {
26+
fuses: Record<string, Fuse<DashboardItem>>;
27+
weights: Record<string, number>;
28+
items: DashboardItem[];
29+
}
30+
31+
const buildSearchIndex = (dashboard: any, activeTeam: string): SearchIndex => {
32+
const sandboxes: DashboardItem[] = dashboard.sandboxes.SEARCH || [];
33+
34+
const folders: DashboardItem[] = (dashboard.allCollections || [])
35+
.map((c: Collection) => ({
36+
...c,
37+
title: c.name,
5738
}))
5839
.filter(f => f.title);
5940

60-
const teamRepos = dashboard.repositoriesByTeamId[activeTeam] ?? [];
61-
const repositories = (teamRepos || []).map((repo: Repository) => {
62-
return {
63-
title: repo.repository.name,
64-
/**
65-
* Due to the lack of description we add the owner so we can at least
66-
* include that in the search query.
67-
*/
68-
description: repo.repository.owner,
69-
...repo,
70-
};
71-
});
41+
const repos: DashboardItem[] = (
42+
dashboard.repositoriesByTeamId[activeTeam] || []
43+
).map((r: Repository) => ({
44+
title: r.repository.name,
45+
description: r.repository.owner,
46+
...r,
47+
}));
48+
49+
const items = [...sandboxes, ...folders, ...repos];
50+
51+
// build a Fuse instance per key
52+
const fuses: Record<string, Fuse<DashboardItem>> = {};
53+
const weights: Record<string, number> = {};
54+
55+
for (const { name, threshold, weight } of SEARCH_KEYS) {
56+
fuses[name] = new Fuse(items, {
57+
keys: [name],
58+
threshold: threshold,
59+
distance: 1000,
60+
});
61+
weights[name] = weight;
62+
}
63+
64+
return { fuses, weights, items };
65+
};
7266

73-
return new Fuse([...sandboxes, ...folders, ...repositories], {
74-
threshold: 0.1,
75-
distance: 1000,
76-
keys: [
77-
{ name: 'title', weight: 0.4 },
78-
{ name: 'description', weight: 0.2 },
79-
{ name: 'alias', weight: 0.2 },
80-
{ name: 'source.template', weight: 0.1 },
81-
{ name: 'id', weight: 0.1 },
82-
],
83-
});
67+
// merge+dedupe results from every key
68+
const mergeSearchResults = (
69+
index: SearchIndex,
70+
query: string
71+
): DashboardItem[] => {
72+
const hits: Array<DashboardItem> = [];
73+
74+
for (const key of Object.keys(index.fuses)) {
75+
const fuse = index.fuses[key];
76+
for (const item of fuse.search(query)) {
77+
hits.push(item);
78+
}
79+
}
80+
81+
// dedupe by item.id, keep the best (lowest) weighted score
82+
const byId: Record<string, DashboardItem> = {};
83+
for (const item of hits) {
84+
const id = (item as any).id as string;
85+
if (!byId[id]) {
86+
byId[id] = item;
87+
}
88+
}
89+
90+
// sort & return
91+
return Object.values(byId);
8492
};
8593

8694
export const useGetItems = ({
@@ -91,73 +99,71 @@ export const useGetItems = ({
9199
query: string;
92100
username: string;
93101
getFilteredSandboxes: (
94-
sandboxes: (
95-
| SandboxFragmentDashboardFragment
96-
| SidebarCollectionDashboardFragment
97-
)[]
102+
list: DashboardItem[]
98103
) => SandboxFragmentDashboardFragment[];
99104
}) => {
100-
const foundResults: Array<
101-
SandboxFragmentDashboardFragment | SidebarCollectionDashboardFragment
102-
> = useSearchedSandboxes(query) || [];
105+
const state = useAppState();
106+
const actions = useActions();
103107

104-
// @ts-ignore
105-
const sandboxesInSearch = foundResults.filter(s => !s.path);
106-
// @ts-ignore
107-
const foldersInSearch = foundResults.filter(s => s.path);
108+
// load page once
109+
useEffect(() => {
110+
actions.dashboard.getPage(sandboxesTypes.SEARCH);
111+
}, [actions.dashboard, state.activeTeam]);
108112

109-
const filteredSandboxes: SandboxFragmentDashboardFragment[] = getFilteredSandboxes(
110-
sandboxesInSearch
113+
// keep a SearchIndex in state
114+
const [searchIndex, setSearchIndex] = React.useState<SearchIndex | null>(
115+
null
111116
);
117+
useEffect(() => {
118+
if (!state.dashboard.sandboxes.SEARCH || !state.dashboard.allCollections)
119+
return;
120+
const idx = buildSearchIndex(state.dashboard, state.activeTeam);
121+
setSearchIndex(idx);
122+
}, [
123+
state.dashboard.sandboxes.SEARCH,
124+
state.dashboard.allCollections,
125+
state.dashboard.repositoriesByTeamId,
126+
state.activeTeam,
127+
]);
128+
129+
// run the merged search whenever query or index changes
130+
const [foundResults, setFoundResults] = React.useState<DashboardItem[]>([]);
131+
useEffect(() => {
132+
if (searchIndex && query) {
133+
setFoundResults(mergeSearchResults(searchIndex, query));
134+
} else {
135+
setFoundResults([]);
136+
}
137+
}, [query, searchIndex]);
112138

113-
const orderedSandboxes = [...foldersInSearch, ...filteredSandboxes].filter(
114-
item => {
115-
// @ts-ignore
116-
if (item.path || item.repository) {
117-
return true;
118-
}
139+
// then the rest is just your existing filtering / mapping logic:
140+
const sandboxesInSearch = foundResults.filter(s => !(s as any).path);
141+
const foldersInSearch = foundResults.filter(s => (s as any).path);
142+
const filteredSandboxes = getFilteredSandboxes(sandboxesInSearch);
143+
const isLoadingQuery = query && !searchIndex;
119144

120-
const sandbox = item as SandboxFragmentDashboardFragment;
145+
const ordered = [...foldersInSearch, ...filteredSandboxes].filter(item => {
146+
if ((item as any).path || (item as any).repository) return true;
147+
const sb = item as SandboxFragmentDashboardFragment;
148+
return !sb.draft || (sb.draft && sb.author.username === username);
149+
});
121150

122-
// Remove draft sandboxes from other authors
123-
return (
124-
!sandbox.draft ||
125-
(sandbox.draft && sandbox.author.username === username)
126-
);
151+
const items = ordered.map(found => {
152+
if ((found as any).path) {
153+
return { type: 'folder', ...(found as object) } as any;
127154
}
128-
);
155+
if ((found as any).repository) {
156+
const f = found as any;
157+
return {
158+
type: 'repository',
159+
repository: {
160+
branchCount: f.branchCount,
161+
repository: f.repository,
162+
},
163+
} as any;
164+
}
165+
return { type: 'sandbox', sandbox: found } as any;
166+
});
129167

130-
// @ts-ignore
131-
const items: DashboardGridItem[] =
132-
foundResults != null
133-
? orderedSandboxes.map(found => {
134-
// @ts-ignore
135-
if (found.path) {
136-
return {
137-
type: 'folder',
138-
...found,
139-
};
140-
}
141-
142-
// @ts-ignore
143-
if (found.repository) {
144-
return {
145-
type: 'repository',
146-
repository: {
147-
// @ts-ignore
148-
branchCount: found.branchCount,
149-
// @ts-ignore
150-
repository: found.repository,
151-
},
152-
};
153-
}
154-
155-
return {
156-
type: 'sandbox',
157-
sandbox: found,
158-
};
159-
})
160-
: [{ type: 'skeleton-row' }];
161-
162-
return [items, sandboxesInSearch];
168+
return [items, sandboxesInSearch, isLoadingQuery] as const;
163169
};

0 commit comments

Comments
 (0)