1
14
15export interface CommandItem {
16 id: string;
17
18 title: string;
19
20 hint?: string;
21}
22
23export interface ScoredCommand {
24 item: CommandItem;
25 score: number;
26}
27
28const BONUS_CONTIGUOUS = 4;
29const BONUS_WORD_START = 2;
30
31
40export function scoreCommand(query: string, target: string): number {
41 const q = query.trim().toLowerCase();
42 const t = target.toLowerCase();
43 if (q.length === 0) return 0;
44 let score = 0;
45 let ti = 0;
46 let prevMatch = -2;
47 for (let qi = 0; qi < q.length; qi += 1) {
48 const ch = q[qi];
49 let found = -1;
50 while (ti < t.length) {
51 if (t[ti] === ch) {
52 found = ti;
53 break;
54 }
55 ti += 1;
56 }
57 if (found < 0) return -1;
58 score += 1;
59 if (found === prevMatch + 1) score += BONUS_CONTIGUOUS;
60 if (found === 0 || t[found - 1] === " ") score += BONUS_WORD_START;
61 if (found > prevMatch + 1) score -= (found - prevMatch - 1) * 0.1;
62 prevMatch = found;
63 ti = found + 1;
64 }
65 return score;
66}
67
68
72export function filterCommands(items: readonly CommandItem[], query: string): ScoredCommand[] {
73 const scored: Array<ScoredCommand & { index: number }> = [];
74 for (let i = 0; i < items.length; i += 1) {
75 const item = items[i];
76 if (item === undefined) continue;
77 const score = scoreCommand(query, item.title);
78 if (score >= 0) scored.push({ item, score, index: i });
79 }
80
81 scored.sort((a, b) => b.score - a.score || a.index - b.index);
82 return scored.map(({ index: _index, ...rest }) => rest);
83}
84
85export interface CommandNavState {
86
87 active: number;
88
89 count: number;
90 open: boolean;
91}
92
93export type CommandNavKey = "ArrowUp" | "ArrowDown" | "Enter" | "Escape" | "Home" | "End";
94
95export type CommandNavAction =
96 | { type: "key"; key: CommandNavKey }
97 | { type: "open" }
98 | { type: "close" }
99 | { type: "results"; count: number };
100
101
111export function commandNav(state: CommandNavState, action: CommandNavAction): CommandNavState {
112 switch (action.type) {
113 case "open":
114 return { ...state, open: true, active: state.active >= 0 ? state.active : 0 };
115 case "close":
116 return { ...state, open: false, active: -1 };
117 case "results": {
118 const count = Math.max(0, action.count);
119 const active = state.active >= 0 ? Math.min(state.active, Math.max(0, count - 1)) : -1;
120 return { ...state, count, active };
121 }
122 case "key": {
123 if (!state.open) return state;
124 if (state.count === 0) return state;
125 const n = state.count;
126 switch (action.key) {
127 case "ArrowDown": {
128 const next = state.active < 0 ? 0 : (state.active + 1) % n;
129 return { ...state, active: next };
130 }
131 case "ArrowUp": {
132 const next = state.active < 0 ? n - 1 : (state.active - 1 + n) % n;
133 return { ...state, active: next };
134 }
135 case "Home":
136 return { ...state, active: 0 };
137 case "End":
138 return { ...state, active: n - 1 };
139 case "Enter":
140 return state;
141 case "Escape":
142 return { ...state, open: false, active: -1 };
143 default:
144 return state;
145 }
146 }
147 default:
148 return state;
149 }
150}
151