Skip to content
Source
Sign inSign up

commandPaletteItems.ts

108 lines4.1 KB

1import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
2import {
3 foldSearchQuery,
4 PALETTE_SECONDARY_LIMIT,
5 PALETTE_THREAD_LIMIT,
6 rankSearchGroups,
7 scoreSearchTerms,
8 scoreThreadSearch,
9 type SearchMatchHint,
10 type SearchRange,
11 type SearchTermsMatch,
12 type ThreadSearchSubject,
13} from "@t3tools/shared/paletteSearch";
14​
15export interface CommandPaletteItem {
16 readonly key: string;
17 readonly kind: "action" | "project" | "thread";
18 readonly project?: EnvironmentProject;
19 readonly title: string;
20 readonly detail?: string;
21 readonly searchTerms: ReadonlyArray<string>;
22 /** Thread rows rank by match quality and use instead of by `searchTerms`. */
23 readonly threadSearch?: ThreadSearchSubject;
24 /** Listed last in its group, as the thread already open. */
25 readonly secondary?: boolean;
26 readonly run: () => void;
28​
29export interface CommandPaletteResult extends CommandPaletteItem {
30 /** Listed because its messages match; the row shows the message snippet. */
31 readonly fromMessages?: boolean;
32 /** Title ranges the query matched, for highlighting. */
33 readonly titleRanges?: ReadonlyArray<SearchRange>;
34 /** Shown in place of the detail when the match is not in the title. */
35 readonly matchHint?: SearchMatchHint;
37​
38/**
39 * Ranks like the web palette: actions, projects, and threads order by their
40 * best match, threads by match quality and use. Threads whose messages match
41 * come last, in `messageMatchKeys` order, so late server results never move
42 * the rows above them.
43 */
44export function filterCommandPaletteItems(
45 items: ReadonlyArray<CommandPaletteItem>,
46 query: string,
47 messageMatchKeys: ReadonlyArray<string>,
48 now = Date.now(),
49): CommandPaletteResult[] {
50 const actionsOnly = query.startsWith(">");
51 const normalizedQuery = foldSearchQuery(actionsOnly ? query.slice(1) : query);
52 const candidates = actionsOnly ? items.filter((item) => item.kind === "action") : items;
53 if (!normalizedQuery) return candidates.filter((item) => item.kind !== "project");
54​
55 // On equal match classes, groups keep this order, as on web.
56 const kinds = ["action", "thread", "project"] as const;
57 const groups = rankSearchGroups(
58 kinds.map((kind) => ({ group: kind, items: candidates.filter((item) => item.kind === kind) })),
59 (
60 item,
61 options,
62 ): (SearchTermsMatch & { explain?: () => SearchMatchHint | undefined }) | null => {
63 const match = item.threadSearch
64 ? scoreThreadSearch(normalizedQuery, item.threadSearch, now, options)
65 : scoreSearchTerms(normalizedQuery, [item.title, ...item.searchTerms], options);
66 // The open thread, listed last, does not decide where its group sits.
67 return match && item.secondary ? { ...match, groupQuality: 0 } : match;
68 },
69 );
70 const threadsMatch = groups.some(({ group }) => group === "thread");
71 const ranked = groups.flatMap(({ group, ranked: entries }) =>
72 [...entries]
73 // Stable, so rank order holds otherwise.
74 .sort(
75 (left, right) =>
76 Number(left.item.secondary ?? false) - Number(right.item.secondary ?? false),
77 )
78 .slice(
79 0,
80 group === "thread"
81 ? PALETTE_THREAD_LIMIT
82 : threadsMatch && !actionsOnly
83 ? PALETTE_SECONDARY_LIMIT
84 : entries.length,
85 )
86 .map(({ item, match }): CommandPaletteResult => {
87 // Built only for the rows shown.
88 const matchHint = match.explain?.();
89 return { ...item, titleRanges: match.ranges, ...(matchHint ? { matchHint } : {}) };
90 }),
91 );
92 if (actionsOnly) return ranked;
93​
94 const listed = new Set(ranked.map((item) => item.key));
95 const threadByKey = new Map(
96 items.filter((item) => item.kind === "thread").map((item) => [item.key, item]),
97 );
98 // `messageMatchKeys` arrive cut to what the palette shows; see `nextShownOrder`.
99 const fromMessages = messageMatchKeys.flatMap((key) => {
100 const item = threadByKey.get(key);
101 return item && !listed.has(key) ? [{ ...item, fromMessages: true }] : [];
102 });
103 return [...ranked, ...fromMessages];
105​
106export function nextPaletteIndex(index: number, direction: -1 | 1, count: number) {
107 return count === 0 ? 0 : (index + direction + count) % count;