tfrere HF Staff Cursor commited on
Commit
c28008c
·
1 Parent(s): 2fdc543

feat: highlight fuzzy search matches on app cards

Browse files

Show orange highlights on matching text in card name, description, and
author. Only highlight spans of 2+ characters to avoid noisy single
letter matches. Match data flows from the search worker.

Co-authored-by: Cursor <cursoragent@cursor.com>

Files changed (2) hide show
  1. src/pages/Apps.jsx +72 -5
  2. src/workers/searchWorker.js +15 -1
src/pages/Apps.jsx CHANGED
@@ -29,8 +29,59 @@ import { useApps } from '../context/AppsContext';
29
  import { useAuth } from '../context/AuthContext';
30
  import InstallModal from '../components/InstallModal';
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  // App Card Component (memoized to avoid re-renders when only search changes)
33
- const AppCard = memo(function AppCard({ app, onInstallClick, isLiked, onToggleLike, isLoggedIn }) {
34
  const isOfficial = app.isOfficial;
35
  const isPythonApp = app.extra?.isPythonApp !== false; // Default to true for backwards compatibility
36
  const cardData = app.extra?.cardData || {};
@@ -126,7 +177,7 @@ const AppCard = memo(function AppCard({ app, onInstallClick, isLiked, onToggleLi
126
  whiteSpace: 'nowrap',
127
  }}
128
  >
129
- {author}
130
  </Typography>
131
  </Box>
132
  )}
@@ -247,7 +298,10 @@ const AppCard = memo(function AppCard({ app, onInstallClick, isLiked, onToggleLi
247
  flex: 1,
248
  }}
249
  >
250
- {app.name || app.id?.split('/').pop()}
 
 
 
251
  </Typography>
252
 
253
  <Typography
@@ -277,7 +331,10 @@ const AppCard = memo(function AppCard({ app, onInstallClick, isLiked, onToggleLi
277
  flex: 1,
278
  }}
279
  >
280
- {cardData.short_description || app.description || 'No description'}
 
 
 
281
  </Typography>
282
 
283
  {/* Date + Install Button */}
@@ -535,7 +592,6 @@ export default function Apps() {
535
  const scoreMap = new Map(searchResults.map((r) => [r.id, r.score]));
536
  const matchedIds = new Set(searchResults.map((r) => r.id));
537
  result = result.filter((app) => matchedIds.has(app.id));
538
- // Sort by relevance (best score first)
539
  result.sort((a, b) => (scoreMap.get(a.id) || 1) - (scoreMap.get(b.id) || 1));
540
  return result;
541
  }
@@ -546,6 +602,16 @@ export default function Apps() {
546
  return result;
547
  }, [apps, officialOnly, selectedCategory, searchResults]);
548
 
 
 
 
 
 
 
 
 
 
 
549
  const isFiltered = searchResults !== null || officialOnly || selectedCategory;
550
 
551
  return (
@@ -955,6 +1021,7 @@ export default function Apps() {
955
  isLiked={isSpaceLiked(app.id)}
956
  onToggleLike={handleToggleLike}
957
  isLoggedIn={isLoggedIn}
 
958
  />
959
  ))}
960
  </Box>
 
29
  import { useAuth } from '../context/AuthContext';
30
  import InstallModal from '../components/InstallModal';
31
 
32
+ /**
33
+ * Render text with highlighted match ranges from Fuse.js.
34
+ * indices is an array of [start, end] pairs.
35
+ */
36
+ function HighlightText({ text, indices }) {
37
+ if (!text) return null;
38
+ if (!indices || indices.length === 0) return text;
39
+
40
+ // Only keep matches that span at least 2 characters
41
+ const significant = indices.filter(([start, end]) => end - start >= 1);
42
+ if (significant.length === 0) return text;
43
+
44
+ // Merge overlapping / adjacent ranges and sort
45
+ const sorted = [...significant].sort((a, b) => a[0] - b[0]);
46
+ const merged = [sorted[0]];
47
+ for (let i = 1; i < sorted.length; i++) {
48
+ const prev = merged[merged.length - 1];
49
+ if (sorted[i][0] <= prev[1] + 1) {
50
+ prev[1] = Math.max(prev[1], sorted[i][1]);
51
+ } else {
52
+ merged.push(sorted[i]);
53
+ }
54
+ }
55
+
56
+ const parts = [];
57
+ let cursor = 0;
58
+ for (const [start, end] of merged) {
59
+ if (cursor < start) {
60
+ parts.push(<span key={`t${cursor}`}>{text.slice(cursor, start)}</span>);
61
+ }
62
+ parts.push(
63
+ <span
64
+ key={`h${start}`}
65
+ style={{
66
+ backgroundColor: 'rgba(255, 149, 0, 0.18)',
67
+ color: '#b36b00',
68
+ borderRadius: 2,
69
+ padding: '0 1px',
70
+ }}
71
+ >
72
+ {text.slice(start, end + 1)}
73
+ </span>
74
+ );
75
+ cursor = end + 1;
76
+ }
77
+ if (cursor < text.length) {
78
+ parts.push(<span key={`t${cursor}`}>{text.slice(cursor)}</span>);
79
+ }
80
+ return <>{parts}</>;
81
+ }
82
+
83
  // App Card Component (memoized to avoid re-renders when only search changes)
84
+ const AppCard = memo(function AppCard({ app, onInstallClick, isLiked, onToggleLike, isLoggedIn, matchData }) {
85
  const isOfficial = app.isOfficial;
86
  const isPythonApp = app.extra?.isPythonApp !== false; // Default to true for backwards compatibility
87
  const cardData = app.extra?.cardData || {};
 
177
  whiteSpace: 'nowrap',
178
  }}
179
  >
180
+ <HighlightText text={author} indices={matchData?._searchAuthor} />
181
  </Typography>
182
  </Box>
183
  )}
 
298
  flex: 1,
299
  }}
300
  >
301
+ <HighlightText
302
+ text={app.name || app.id?.split('/').pop()}
303
+ indices={matchData?.name}
304
+ />
305
  </Typography>
306
 
307
  <Typography
 
331
  flex: 1,
332
  }}
333
  >
334
+ <HighlightText
335
+ text={cardData.short_description || app.description || 'No description'}
336
+ indices={matchData?._searchDescription}
337
+ />
338
  </Typography>
339
 
340
  {/* Date + Install Button */}
 
592
  const scoreMap = new Map(searchResults.map((r) => [r.id, r.score]));
593
  const matchedIds = new Set(searchResults.map((r) => r.id));
594
  result = result.filter((app) => matchedIds.has(app.id));
 
595
  result.sort((a, b) => (scoreMap.get(a.id) || 1) - (scoreMap.get(b.id) || 1));
596
  return result;
597
  }
 
602
  return result;
603
  }, [apps, officialOnly, selectedCategory, searchResults]);
604
 
605
+ // Build a map of app ID → match highlight data
606
+ const matchDataMap = useMemo(() => {
607
+ if (!searchResults) return null;
608
+ const map = new Map();
609
+ for (const r of searchResults) {
610
+ map.set(r.id, r.matches);
611
+ }
612
+ return map;
613
+ }, [searchResults]);
614
+
615
  const isFiltered = searchResults !== null || officialOnly || selectedCategory;
616
 
617
  return (
 
1021
  isLiked={isSpaceLiked(app.id)}
1022
  onToggleLike={handleToggleLike}
1023
  isLoggedIn={isLoggedIn}
1024
+ matchData={matchDataMap?.get(app.id) || null}
1025
  />
1026
  ))}
1027
  </Box>
src/workers/searchWorker.js CHANGED
@@ -13,6 +13,7 @@ const FUSE_OPTIONS = {
13
  threshold: 0.35,
14
  ignoreLocation: true,
15
  includeScore: true,
 
16
  };
17
 
18
  /**
@@ -37,10 +38,23 @@ function buildIndex(apps) {
37
  /**
38
  * Search and return ordered list of matching app IDs with scores.
39
  */
 
 
 
 
40
  function search(query) {
41
  if (!fuse || !query.trim()) return [];
42
  const results = fuse.search(query.trim());
43
- return results.map((r) => ({ id: r.item.id, score: r.score }));
 
 
 
 
 
 
 
 
 
44
  }
45
 
46
  // Handle messages from the main thread
 
13
  threshold: 0.35,
14
  ignoreLocation: true,
15
  includeScore: true,
16
+ includeMatches: true,
17
  };
18
 
19
  /**
 
38
  /**
39
  * Search and return ordered list of matching app IDs with scores.
40
  */
41
+ /**
42
+ * Search and return ordered list of matching app IDs with scores and match indices.
43
+ * Matches are keyed by field name for easy highlight rendering.
44
+ */
45
  function search(query) {
46
  if (!fuse || !query.trim()) return [];
47
  const results = fuse.search(query.trim());
48
+ return results.map((r) => {
49
+ // Convert Fuse matches array into a map: fieldName → [[start, end], ...]
50
+ const matches = {};
51
+ if (r.matches) {
52
+ for (const m of r.matches) {
53
+ matches[m.key] = m.indices;
54
+ }
55
+ }
56
+ return { id: r.item.id, score: r.score, matches };
57
+ });
58
  }
59
 
60
  // Handle messages from the main thread