modern-cmdk 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codemod/cli.mjs +1 -3
- package/dist/codemod/transforms/data-attrs.d.mts +1 -2
- package/dist/codemod/transforms/data-attrs.mjs +0 -2
- package/dist/codemod/transforms/forward-ref.d.mts +1 -2
- package/dist/codemod/transforms/forward-ref.mjs +0 -2
- package/dist/codemod/transforms/import-rewrite.d.mts +1 -2
- package/dist/codemod/transforms/import-rewrite.mjs +0 -2
- package/dist/codemod/transforms/should-filter.d.mts +1 -2
- package/dist/codemod/transforms/should-filter.mjs +0 -2
- package/dist/core/index.d.mts +568 -2
- package/dist/core/index.mjs +1268 -1
- package/dist/react/index.d.mts +349 -3
- package/dist/react/index.mjs +1292 -209
- package/package.json +26 -12
- package/dist/codemod/cli.mjs.map +0 -1
- package/dist/codemod/transforms/data-attrs.d.mts.map +0 -1
- package/dist/codemod/transforms/data-attrs.mjs.map +0 -1
- package/dist/codemod/transforms/forward-ref.d.mts.map +0 -1
- package/dist/codemod/transforms/forward-ref.mjs.map +0 -1
- package/dist/codemod/transforms/import-rewrite.d.mts.map +0 -1
- package/dist/codemod/transforms/import-rewrite.mjs.map +0 -1
- package/dist/codemod/transforms/should-filter.d.mts.map +0 -1
- package/dist/codemod/transforms/should-filter.mjs.map +0 -1
- package/dist/core-CgQYpHK3.mjs +0 -1235
- package/dist/core-CgQYpHK3.mjs.map +0 -1
- package/dist/index-C6g94P7V.d.mts +0 -563
- package/dist/index-C6g94P7V.d.mts.map +0 -1
- package/dist/react/index.d.mts.map +0 -1
- package/dist/react/index.mjs.map +0 -1
- /package/{src → dist}/react/styles.css +0 -0
package/dist/core-CgQYpHK3.mjs
DELETED
|
@@ -1,1235 +0,0 @@
|
|
|
1
|
-
import { createStore, del, get, set } from "idb-keyval";
|
|
2
|
-
//#region src/core/frecency/idb-storage.ts
|
|
3
|
-
/** Serialize a FrecencyRecord to a JSON-safe format */
|
|
4
|
-
function serializeRecord(record) {
|
|
5
|
-
return {
|
|
6
|
-
frequency: record.frequency,
|
|
7
|
-
lastUsedNs: record.lastUsed.epochNanoseconds.toString()
|
|
8
|
-
};
|
|
9
|
-
}
|
|
10
|
-
/** Deserialize a FrecencyRecord from its JSON-safe form */
|
|
11
|
-
function deserializeRecord(serialized) {
|
|
12
|
-
return {
|
|
13
|
-
frequency: serialized.frequency,
|
|
14
|
-
lastUsed: Temporal.Instant.fromEpochNanoseconds(BigInt(serialized.lastUsedNs))
|
|
15
|
-
};
|
|
16
|
-
}
|
|
17
|
-
/** Serialize FrecencyData for IndexedDB storage — uses Iterator Helpers */
|
|
18
|
-
function serializeData(data) {
|
|
19
|
-
return { records: data.records.entries().map(([id, record]) => [id, serializeRecord(record)]).toArray() };
|
|
20
|
-
}
|
|
21
|
-
/** Deserialize FrecencyData from IndexedDB storage — uses Iterator Helpers */
|
|
22
|
-
function deserializeData(serialized) {
|
|
23
|
-
const entries = serialized.records.values().map(([id, record]) => [id, deserializeRecord(record)]).toArray();
|
|
24
|
-
return { records: new Map(entries) };
|
|
25
|
-
}
|
|
26
|
-
/** Build the namespaced IDB key — String.toWellFormed() (ES2026) ensures valid Unicode in key */
|
|
27
|
-
function storageKey(namespace) {
|
|
28
|
-
return `frecency:${namespace.isWellFormed() ? namespace : namespace.toWellFormed()}`;
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* IndexedDB-backed frecency storage using idb-keyval 6.2.2.
|
|
32
|
-
*
|
|
33
|
-
* Persists frecency data across browser sessions. Implements both
|
|
34
|
-
* `Disposable` and `AsyncDisposable` for use with `using` and `await using`.
|
|
35
|
-
*
|
|
36
|
-
* @example
|
|
37
|
-
* ```ts
|
|
38
|
-
* await using storage = new IdbFrecencyStorage();
|
|
39
|
-
* const data = await storage.load('my-app');
|
|
40
|
-
* ```
|
|
41
|
-
*/
|
|
42
|
-
var IdbFrecencyStorage = class {
|
|
43
|
-
#store;
|
|
44
|
-
#disposed = false;
|
|
45
|
-
/**
|
|
46
|
-
* Create an IDB-backed frecency storage.
|
|
47
|
-
*
|
|
48
|
-
* @param dbName — IndexedDB database name (default: 'modern-cmdk')
|
|
49
|
-
* @param storeName — IndexedDB object store name (default: 'frecency')
|
|
50
|
-
*/
|
|
51
|
-
constructor(dbName = "modern-cmdk", storeName = "frecency") {
|
|
52
|
-
this.#store = createStore(dbName, storeName);
|
|
53
|
-
}
|
|
54
|
-
/** Load frecency data from IndexedDB for the given namespace */
|
|
55
|
-
async load(namespace) {
|
|
56
|
-
if (this.#disposed) return { records: /* @__PURE__ */ new Map() };
|
|
57
|
-
const serialized = await get(storageKey(namespace), this.#store);
|
|
58
|
-
if (!serialized) return { records: /* @__PURE__ */ new Map() };
|
|
59
|
-
return deserializeData(serialized);
|
|
60
|
-
}
|
|
61
|
-
/** Save frecency data to IndexedDB for the given namespace */
|
|
62
|
-
async save(namespace, data) {
|
|
63
|
-
if (this.#disposed) return;
|
|
64
|
-
await set(storageKey(namespace), serializeData(data), this.#store);
|
|
65
|
-
}
|
|
66
|
-
/** Delete frecency data for a namespace from IndexedDB */
|
|
67
|
-
async delete(namespace) {
|
|
68
|
-
if (this.#disposed) return;
|
|
69
|
-
await del(storageKey(namespace), this.#store);
|
|
70
|
-
}
|
|
71
|
-
/** Synchronous dispose — marks as disposed (best-effort cleanup) */
|
|
72
|
-
[Symbol.dispose]() {
|
|
73
|
-
this.#disposed = true;
|
|
74
|
-
}
|
|
75
|
-
/** Async dispose — marks as disposed, suitable for `await using` */
|
|
76
|
-
async [Symbol.asyncDispose]() {
|
|
77
|
-
this.#disposed = true;
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
//#endregion
|
|
81
|
-
//#region src/core/types.ts
|
|
82
|
-
/** Create a branded {@link ItemId} from a plain string. Ensures well-formed Unicode (ES2026). */
|
|
83
|
-
function itemId(id) {
|
|
84
|
-
return id.isWellFormed() ? id : id.toWellFormed();
|
|
85
|
-
}
|
|
86
|
-
/** Create a branded {@link GroupId} from a plain string. Ensures well-formed Unicode (ES2026). */
|
|
87
|
-
function groupId(id) {
|
|
88
|
-
return id.isWellFormed() ? id : id.toWellFormed();
|
|
89
|
-
}
|
|
90
|
-
/** Default frecency decay configuration — satisfies on value, not interface */
|
|
91
|
-
const DEFAULT_FRECENCY_DECAY = {
|
|
92
|
-
hourWeight: 4,
|
|
93
|
-
dayWeight: 2,
|
|
94
|
-
weekWeight: 1.5,
|
|
95
|
-
monthWeight: 1,
|
|
96
|
-
olderWeight: .5
|
|
97
|
-
};
|
|
98
|
-
/** Default machine options — satisfies on value */
|
|
99
|
-
const DEFAULT_MACHINE_OPTIONS = {
|
|
100
|
-
loop: true,
|
|
101
|
-
virtualizeThreshold: 100,
|
|
102
|
-
open: false
|
|
103
|
-
};
|
|
104
|
-
/** Initial state factory */
|
|
105
|
-
function createInitialState(options) {
|
|
106
|
-
return {
|
|
107
|
-
search: "",
|
|
108
|
-
activeId: null,
|
|
109
|
-
filteredIds: [],
|
|
110
|
-
groupedIds: /* @__PURE__ */ new Map(),
|
|
111
|
-
filteredCount: 0,
|
|
112
|
-
loading: false,
|
|
113
|
-
page: "root",
|
|
114
|
-
pageStack: [],
|
|
115
|
-
open: options?.open ?? false,
|
|
116
|
-
lastUpdated: Temporal.Now.instant()
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
//#endregion
|
|
120
|
-
//#region src/core/frecency/memory-storage.ts
|
|
121
|
-
/**
|
|
122
|
-
* In-memory implementation of FrecencyStorage using a Map.
|
|
123
|
-
* Data does not persist across page reloads. Suitable for ephemeral
|
|
124
|
-
* sessions or as the default when no persistent storage is configured.
|
|
125
|
-
*/
|
|
126
|
-
var MemoryFrecencyStorage = class {
|
|
127
|
-
#store = /* @__PURE__ */ new Map();
|
|
128
|
-
load(namespace) {
|
|
129
|
-
return this.#store.get(namespace) ?? { records: /* @__PURE__ */ new Map() };
|
|
130
|
-
}
|
|
131
|
-
save(namespace, data) {
|
|
132
|
-
this.#store.set(namespace, data);
|
|
133
|
-
}
|
|
134
|
-
/** Serialize all stored data — Iterator Helpers pipeline (ES2026) */
|
|
135
|
-
toJSON() {
|
|
136
|
-
return Object.fromEntries(this.#store.entries().map(([namespace, data]) => [namespace, { records: data.records.entries().toArray() }]));
|
|
137
|
-
}
|
|
138
|
-
[Symbol.dispose]() {
|
|
139
|
-
this.#store.clear();
|
|
140
|
-
}
|
|
141
|
-
};
|
|
142
|
-
//#endregion
|
|
143
|
-
//#region src/core/frecency/index.ts
|
|
144
|
-
/** Shared empty bonus map — avoids allocation when no frecency records exist */
|
|
145
|
-
const EMPTY_BONUS_MAP = /* @__PURE__ */ new Map();
|
|
146
|
-
/** Pre-computed bucket boundaries in hours — avoids Duration.total() per call */
|
|
147
|
-
const BUCKET_HOURS = {
|
|
148
|
-
hour: 1,
|
|
149
|
-
day: 24,
|
|
150
|
-
week: 168,
|
|
151
|
-
month: 720
|
|
152
|
-
};
|
|
153
|
-
/**
|
|
154
|
-
* Computes a frecency bonus for an item based on usage frequency and recency.
|
|
155
|
-
* Uses Temporal.Duration bucket boundaries (hour/day/week/month) with
|
|
156
|
-
* configurable exponential decay weights per time bucket.
|
|
157
|
-
*/
|
|
158
|
-
function computeFrecencyBonus(history, now = Temporal.Now.instant(), config = DEFAULT_FRECENCY_DECAY) {
|
|
159
|
-
const hours = now.since(history.lastUsed).total("hours");
|
|
160
|
-
const { hourWeight = 4, dayWeight = 2, weekWeight = 1.5, monthWeight = 1, olderWeight = .5 } = config;
|
|
161
|
-
const recencyWeight = hours < BUCKET_HOURS.hour ? hourWeight : hours < BUCKET_HOURS.day ? dayWeight : hours < BUCKET_HOURS.week ? weekWeight : hours < BUCKET_HOURS.month ? monthWeight : olderWeight;
|
|
162
|
-
return history.frequency * recencyWeight;
|
|
163
|
-
}
|
|
164
|
-
/**
|
|
165
|
-
* Stateful frecency engine that tracks item selection history and provides
|
|
166
|
-
* ranking boosts for search results. Supports pluggable storage backends
|
|
167
|
-
* and automatic flush on dispose. Uses Temporal.Now.instant() for timestamps.
|
|
168
|
-
*/
|
|
169
|
-
var FrecencyEngine = class {
|
|
170
|
-
#storage;
|
|
171
|
-
#namespace;
|
|
172
|
-
#data;
|
|
173
|
-
#config;
|
|
174
|
-
#dirty = false;
|
|
175
|
-
constructor(options) {
|
|
176
|
-
this.#storage = options.storage ?? new MemoryFrecencyStorage();
|
|
177
|
-
this.#namespace = options.namespace ?? "default";
|
|
178
|
-
this.#config = options.decayConfig ?? DEFAULT_FRECENCY_DECAY;
|
|
179
|
-
this.#data = { records: /* @__PURE__ */ new Map() };
|
|
180
|
-
}
|
|
181
|
-
/** Load frecency data from storage */
|
|
182
|
-
async load() {
|
|
183
|
-
this.#data = await this.#storage.load(this.#namespace);
|
|
184
|
-
}
|
|
185
|
-
/** Record a selection event for an item */
|
|
186
|
-
recordSelection(itemId) {
|
|
187
|
-
const now = Temporal.Now.instant();
|
|
188
|
-
const updated = {
|
|
189
|
-
frequency: (this.#data.records.get(itemId)?.frequency ?? 0) + 1,
|
|
190
|
-
lastUsed: now
|
|
191
|
-
};
|
|
192
|
-
this.#data.records.set(itemId, updated);
|
|
193
|
-
this.#dirty = true;
|
|
194
|
-
}
|
|
195
|
-
/** Get frecency bonus for an item */
|
|
196
|
-
getBonus(itemId, now) {
|
|
197
|
-
const record = this.#data.records.get(itemId);
|
|
198
|
-
if (!record) return 0;
|
|
199
|
-
return computeFrecencyBonus(record, now, this.#config);
|
|
200
|
-
}
|
|
201
|
-
/** Get all frecency bonuses as a map — for...of for hot ranking path (no closure overhead) */
|
|
202
|
-
getAllBonuses(now = Temporal.Now.instant()) {
|
|
203
|
-
if (this.#data.records.size === 0) return EMPTY_BONUS_MAP;
|
|
204
|
-
const bonuses = /* @__PURE__ */ new Map();
|
|
205
|
-
for (const [id, record] of this.#data.records) bonuses.set(id, computeFrecencyBonus(record, now, this.#config));
|
|
206
|
-
return bonuses;
|
|
207
|
-
}
|
|
208
|
-
/** Get the decay configuration */
|
|
209
|
-
getConfig() {
|
|
210
|
-
return this.#config;
|
|
211
|
-
}
|
|
212
|
-
/** Update decay configuration at runtime */
|
|
213
|
-
setConfig(config) {
|
|
214
|
-
this.#config = {
|
|
215
|
-
...this.#config,
|
|
216
|
-
...config
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
/** Flush dirty data to storage */
|
|
220
|
-
async flush() {
|
|
221
|
-
if (!this.#dirty) return;
|
|
222
|
-
await this.#storage.save(this.#namespace, this.#data);
|
|
223
|
-
this.#dirty = false;
|
|
224
|
-
}
|
|
225
|
-
/** Get the raw frecency data */
|
|
226
|
-
getData() {
|
|
227
|
-
return this.#data;
|
|
228
|
-
}
|
|
229
|
-
[Symbol.dispose]() {
|
|
230
|
-
if (this.#dirty) {
|
|
231
|
-
const result = this.#storage.save(this.#namespace, this.#data);
|
|
232
|
-
if (result instanceof Promise) result.catch(() => {});
|
|
233
|
-
}
|
|
234
|
-
this.#storage[Symbol.dispose]();
|
|
235
|
-
}
|
|
236
|
-
};
|
|
237
|
-
//#endregion
|
|
238
|
-
//#region src/core/keyboard/matcher.ts
|
|
239
|
-
/**
|
|
240
|
-
* Tests whether a KeyboardEvent exactly matches a ParsedShortcut,
|
|
241
|
-
* comparing all four modifier keys (meta, ctrl, shift, alt) and the primary key.
|
|
242
|
-
*/
|
|
243
|
-
function matchesShortcut(event, shortcut) {
|
|
244
|
-
if (shortcut.meta !== event.metaKey) return false;
|
|
245
|
-
if (shortcut.ctrl !== event.ctrlKey) return false;
|
|
246
|
-
if (shortcut.shift !== event.shiftKey) return false;
|
|
247
|
-
if (shortcut.alt !== event.altKey) return false;
|
|
248
|
-
return event.key.toLowerCase() === shortcut.key;
|
|
249
|
-
}
|
|
250
|
-
/** Match a KeyboardEvent against a list of shortcuts — Iterator Helpers .find() */
|
|
251
|
-
function findMatchingShortcut(event, shortcuts) {
|
|
252
|
-
return shortcuts.values().find((shortcut) => matchesShortcut(event, shortcut)) ?? null;
|
|
253
|
-
}
|
|
254
|
-
/**
|
|
255
|
-
* Detects conflicting shortcut bindings by grouping on their normalized form
|
|
256
|
-
* via Map.groupBy (ES2026). Returns only groups with two or more colliding shortcuts.
|
|
257
|
-
* Map.groupBy returns a Map directly — no Object.entries conversion needed.
|
|
258
|
-
*/
|
|
259
|
-
function detectConflicts(shortcuts) {
|
|
260
|
-
const grouped = Map.groupBy(shortcuts, (s) => s.normalized);
|
|
261
|
-
return new Map(grouped.entries().filter((entry) => entry[1].length > 1));
|
|
262
|
-
}
|
|
263
|
-
//#endregion
|
|
264
|
-
//#region src/core/keyboard/parser.ts
|
|
265
|
-
const isMac = typeof navigator !== "undefined" && (navigator.userAgentData?.platform === "macOS" || /Mac|iPod|iPhone|iPad/.test(navigator.platform));
|
|
266
|
-
/** Modifier aliases — cross-platform Mod resolution */
|
|
267
|
-
const MODIFIER_MAP = {
|
|
268
|
-
mod: isMac ? "meta" : "ctrl",
|
|
269
|
-
meta: "meta",
|
|
270
|
-
cmd: "meta",
|
|
271
|
-
command: "meta",
|
|
272
|
-
ctrl: "ctrl",
|
|
273
|
-
control: "ctrl",
|
|
274
|
-
shift: "shift",
|
|
275
|
-
alt: "alt",
|
|
276
|
-
option: "alt",
|
|
277
|
-
opt: "alt"
|
|
278
|
-
};
|
|
279
|
-
/**
|
|
280
|
-
* Parse a shortcut string like "Mod+Shift+K" into a structured ParsedShortcut.
|
|
281
|
-
* Uses String.isWellFormed() (ES2026) to ensure valid Unicode input.
|
|
282
|
-
*/
|
|
283
|
-
function parseShortcut(shortcut) {
|
|
284
|
-
const safeShortcut = shortcut.isWellFormed() ? shortcut : shortcut.toWellFormed();
|
|
285
|
-
const parts = safeShortcut.split("+").map((p) => p.trim().toLowerCase());
|
|
286
|
-
let meta = false;
|
|
287
|
-
let ctrl = false;
|
|
288
|
-
let shift = false;
|
|
289
|
-
let alt = false;
|
|
290
|
-
let key = "";
|
|
291
|
-
for (const part of parts) {
|
|
292
|
-
const modifier = MODIFIER_MAP[part];
|
|
293
|
-
if (modifier) switch (modifier) {
|
|
294
|
-
case "meta":
|
|
295
|
-
meta = true;
|
|
296
|
-
break;
|
|
297
|
-
case "ctrl":
|
|
298
|
-
ctrl = true;
|
|
299
|
-
break;
|
|
300
|
-
case "shift":
|
|
301
|
-
shift = true;
|
|
302
|
-
break;
|
|
303
|
-
case "alt":
|
|
304
|
-
alt = true;
|
|
305
|
-
break;
|
|
306
|
-
}
|
|
307
|
-
else key = part;
|
|
308
|
-
}
|
|
309
|
-
const modifiers = [];
|
|
310
|
-
if (meta) modifiers.push("meta");
|
|
311
|
-
if (ctrl) modifiers.push("ctrl");
|
|
312
|
-
if (shift) modifiers.push("shift");
|
|
313
|
-
if (alt) modifiers.push("alt");
|
|
314
|
-
modifiers.push(key);
|
|
315
|
-
return {
|
|
316
|
-
key,
|
|
317
|
-
meta,
|
|
318
|
-
ctrl,
|
|
319
|
-
shift,
|
|
320
|
-
alt,
|
|
321
|
-
raw: safeShortcut,
|
|
322
|
-
normalized: modifiers.join("+")
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
/**
|
|
326
|
-
* Formats a ParsedShortcut for display using platform-appropriate symbols.
|
|
327
|
-
* On macOS: renders glyphs (⌘, ⇧, ⌥, ⌃). On other platforms: text labels joined by "+".
|
|
328
|
-
*/
|
|
329
|
-
function formatShortcut(parsed) {
|
|
330
|
-
const parts = [];
|
|
331
|
-
if (parsed.meta) parts.push(isMac ? "⌘" : "Ctrl");
|
|
332
|
-
if (parsed.ctrl && !(!isMac && parsed.meta)) parts.push(isMac ? "⌃" : "Ctrl");
|
|
333
|
-
if (parsed.shift) parts.push(isMac ? "⇧" : "Shift");
|
|
334
|
-
if (parsed.alt) parts.push(isMac ? "⌥" : "Alt");
|
|
335
|
-
const displayKey = parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key.charAt(0).toUpperCase() + parsed.key.slice(1);
|
|
336
|
-
parts.push(displayKey);
|
|
337
|
-
return isMac ? parts.join("") : parts.join("+");
|
|
338
|
-
}
|
|
339
|
-
//#endregion
|
|
340
|
-
//#region src/core/keyboard/index.ts
|
|
341
|
-
/**
|
|
342
|
-
* Manages keyboard shortcut bindings with global keydown listening.
|
|
343
|
-
* Supports `using` for automatic cleanup of both individual bindings and the
|
|
344
|
-
* global document listener. Detects conflicts via Map.groupBy (ES2026).
|
|
345
|
-
*/
|
|
346
|
-
var KeyboardShortcutRegistry = class {
|
|
347
|
-
#bindings = /* @__PURE__ */ new Map();
|
|
348
|
-
#globalListener = null;
|
|
349
|
-
#enabled = true;
|
|
350
|
-
constructor(options) {
|
|
351
|
-
this.#enabled = options?.enabled ?? true;
|
|
352
|
-
this.#attachGlobalListener();
|
|
353
|
-
}
|
|
354
|
-
/** Register a shortcut binding — returns Disposable for `using` auto-deregister */
|
|
355
|
-
register(shortcutStr, itemId, handler) {
|
|
356
|
-
const shortcut = parseShortcut(shortcutStr);
|
|
357
|
-
this.#bindings.set(shortcut.normalized, {
|
|
358
|
-
shortcut,
|
|
359
|
-
itemId,
|
|
360
|
-
handler
|
|
361
|
-
});
|
|
362
|
-
return { [Symbol.dispose]: () => {
|
|
363
|
-
this.#bindings.delete(shortcut.normalized);
|
|
364
|
-
} };
|
|
365
|
-
}
|
|
366
|
-
/** Unregister a shortcut by its string representation */
|
|
367
|
-
unregister(shortcutStr) {
|
|
368
|
-
const shortcut = parseShortcut(shortcutStr);
|
|
369
|
-
this.#bindings.delete(shortcut.normalized);
|
|
370
|
-
}
|
|
371
|
-
/** Unregister all shortcuts for a given item — Map spec allows deletion during iteration */
|
|
372
|
-
unregisterByItem(itemId) {
|
|
373
|
-
for (const [key, binding] of this.#bindings) if (binding.itemId === itemId) this.#bindings.delete(key);
|
|
374
|
-
}
|
|
375
|
-
/** Check for conflicting shortcuts */
|
|
376
|
-
getConflicts() {
|
|
377
|
-
return detectConflicts(this.#bindings.values().map((b) => b.shortcut).toArray());
|
|
378
|
-
}
|
|
379
|
-
/** Enable or disable global shortcut listening */
|
|
380
|
-
setEnabled(enabled) {
|
|
381
|
-
this.#enabled = enabled;
|
|
382
|
-
}
|
|
383
|
-
/** Get all registered bindings */
|
|
384
|
-
getBindings() {
|
|
385
|
-
return this.#bindings;
|
|
386
|
-
}
|
|
387
|
-
#attachGlobalListener() {
|
|
388
|
-
if (typeof document === "undefined") return;
|
|
389
|
-
this.#globalListener = (event) => {
|
|
390
|
-
if (!this.#enabled) return;
|
|
391
|
-
for (const binding of this.#bindings.values()) if (matchesShortcut(event, binding.shortcut)) {
|
|
392
|
-
event.preventDefault();
|
|
393
|
-
event.stopPropagation();
|
|
394
|
-
binding.handler();
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
};
|
|
398
|
-
document.addEventListener("keydown", this.#globalListener);
|
|
399
|
-
}
|
|
400
|
-
#detachGlobalListener() {
|
|
401
|
-
if (this.#globalListener && typeof document !== "undefined") {
|
|
402
|
-
document.removeEventListener("keydown", this.#globalListener);
|
|
403
|
-
this.#globalListener = null;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
[Symbol.dispose]() {
|
|
407
|
-
this.#detachGlobalListener();
|
|
408
|
-
this.#bindings.clear();
|
|
409
|
-
}
|
|
410
|
-
};
|
|
411
|
-
//#endregion
|
|
412
|
-
//#region src/core/registry.ts
|
|
413
|
-
/**
|
|
414
|
-
* Manages registration and lookup of command items and groups.
|
|
415
|
-
* Uses ES2026 Set methods (union/intersection/difference) for efficient ID operations.
|
|
416
|
-
* All registration methods return Disposable for automatic cleanup via `using`.
|
|
417
|
-
*/
|
|
418
|
-
var CommandRegistry = class {
|
|
419
|
-
#items = /* @__PURE__ */ new Map();
|
|
420
|
-
#groups = /* @__PURE__ */ new Map();
|
|
421
|
-
#itemOrder = [];
|
|
422
|
-
#itemOrderSet = /* @__PURE__ */ new Set();
|
|
423
|
-
#cachedItems = null;
|
|
424
|
-
#cachedGroups = null;
|
|
425
|
-
#invalidateCache() {
|
|
426
|
-
this.#cachedItems = null;
|
|
427
|
-
}
|
|
428
|
-
#invalidateGroupCache() {
|
|
429
|
-
this.#cachedGroups = null;
|
|
430
|
-
}
|
|
431
|
-
/** Register an item — returns Disposable for `using` auto-deregister */
|
|
432
|
-
registerItem(item) {
|
|
433
|
-
this.#items.set(item.id, item);
|
|
434
|
-
if (!this.#itemOrderSet.has(item.id)) {
|
|
435
|
-
this.#itemOrder.push(item.id);
|
|
436
|
-
this.#itemOrderSet.add(item.id);
|
|
437
|
-
}
|
|
438
|
-
this.#invalidateCache();
|
|
439
|
-
return { [Symbol.dispose]: () => {
|
|
440
|
-
this.unregisterItem(item.id);
|
|
441
|
-
} };
|
|
442
|
-
}
|
|
443
|
-
/** Register multiple items at once — single Disposable for batch cleanup. for...of for hot path. */
|
|
444
|
-
registerItems(items) {
|
|
445
|
-
for (const item of items) {
|
|
446
|
-
this.#items.set(item.id, item);
|
|
447
|
-
if (!this.#itemOrderSet.has(item.id)) {
|
|
448
|
-
this.#itemOrder.push(item.id);
|
|
449
|
-
this.#itemOrderSet.add(item.id);
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
this.#invalidateCache();
|
|
453
|
-
const ids = new Set(items.values().map((i) => i.id));
|
|
454
|
-
return { [Symbol.dispose]: () => {
|
|
455
|
-
for (const id of ids) this.unregisterItem(id);
|
|
456
|
-
} };
|
|
457
|
-
}
|
|
458
|
-
/** Unregister a single item — O(1) Map delete + Set delete, O(n) order rebuild */
|
|
459
|
-
unregisterItem(id) {
|
|
460
|
-
if (!this.#items.has(id)) return;
|
|
461
|
-
this.#items.delete(id);
|
|
462
|
-
this.#itemOrderSet.delete(id);
|
|
463
|
-
this.#itemOrder = this.#itemOrder.filter((orderId) => orderId !== id);
|
|
464
|
-
this.#invalidateCache();
|
|
465
|
-
}
|
|
466
|
-
/** Bulk unregister using Set.difference (ES2026) — for...of for hot path */
|
|
467
|
-
unregisterItems(ids) {
|
|
468
|
-
for (const id of ids) this.#items.delete(id);
|
|
469
|
-
this.#itemOrderSet = this.#itemOrderSet.difference(ids);
|
|
470
|
-
this.#itemOrder = this.#itemOrder.filter((id) => !ids.has(id));
|
|
471
|
-
this.#invalidateCache();
|
|
472
|
-
}
|
|
473
|
-
/** Register a group */
|
|
474
|
-
registerGroup(group) {
|
|
475
|
-
this.#groups.set(group.id, group);
|
|
476
|
-
this.#invalidateGroupCache();
|
|
477
|
-
return { [Symbol.dispose]: () => {
|
|
478
|
-
this.#groups.delete(group.id);
|
|
479
|
-
this.#invalidateGroupCache();
|
|
480
|
-
} };
|
|
481
|
-
}
|
|
482
|
-
/** Unregister a group */
|
|
483
|
-
unregisterGroup(id) {
|
|
484
|
-
this.#groups.delete(id);
|
|
485
|
-
this.#invalidateGroupCache();
|
|
486
|
-
}
|
|
487
|
-
/** Get an item by ID — O(1) */
|
|
488
|
-
getItem(id) {
|
|
489
|
-
return this.#items.get(id);
|
|
490
|
-
}
|
|
491
|
-
/** Get a group by ID — O(1) */
|
|
492
|
-
getGroup(id) {
|
|
493
|
-
return this.#groups.get(id);
|
|
494
|
-
}
|
|
495
|
-
/** Get all registered items in insertion order — cached between mutations */
|
|
496
|
-
getItems() {
|
|
497
|
-
if (this.#cachedItems !== null) return this.#cachedItems;
|
|
498
|
-
this.#cachedItems = this.#itemOrder.values().map((id) => this.#items.get(id)).filter((item) => item != null).toArray();
|
|
499
|
-
return this.#cachedItems;
|
|
500
|
-
}
|
|
501
|
-
/** Get all registered groups sorted by priority — cached between group mutations */
|
|
502
|
-
getGroups() {
|
|
503
|
-
if (this.#cachedGroups !== null) return this.#cachedGroups;
|
|
504
|
-
this.#cachedGroups = this.#groups.values().toArray().sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
|
|
505
|
-
return this.#cachedGroups;
|
|
506
|
-
}
|
|
507
|
-
/** Get items grouped by groupId using Map.groupBy (ES2026) — direct Map, no Object.entries conversion */
|
|
508
|
-
getGroupedItems() {
|
|
509
|
-
const items = this.getItems();
|
|
510
|
-
const grouped = Map.groupBy(items, (item) => item.groupId ?? "__ungrouped");
|
|
511
|
-
const result = /* @__PURE__ */ new Map();
|
|
512
|
-
for (const group of this.getGroups()) {
|
|
513
|
-
const groupItems = grouped.get(group.id);
|
|
514
|
-
if (groupItems != null) result.set(group.id, groupItems);
|
|
515
|
-
}
|
|
516
|
-
const ungrouped = grouped.get("__ungrouped");
|
|
517
|
-
if (ungrouped) result.set("__ungrouped", ungrouped);
|
|
518
|
-
return result;
|
|
519
|
-
}
|
|
520
|
-
/** Get item IDs as a Set — returns internal set (O(1), no allocation) */
|
|
521
|
-
getItemIds() {
|
|
522
|
-
return this.#itemOrderSet;
|
|
523
|
-
}
|
|
524
|
-
/** Find intersection of registered items with a candidate set (ES2026 Set.intersection) */
|
|
525
|
-
intersectWith(candidateIds) {
|
|
526
|
-
return this.#itemOrderSet.intersection(candidateIds);
|
|
527
|
-
}
|
|
528
|
-
/** Find items registered but not in a given set (ES2026 Set.difference) */
|
|
529
|
-
differenceFrom(otherIds) {
|
|
530
|
-
return this.#itemOrderSet.difference(otherIds);
|
|
531
|
-
}
|
|
532
|
-
/** Union of registered IDs with another set (ES2026 Set.union) */
|
|
533
|
-
unionWith(otherIds) {
|
|
534
|
-
return this.#itemOrderSet.union(otherIds);
|
|
535
|
-
}
|
|
536
|
-
/** Check if registered items are a subset of the given set (ES2026 Set.isSubsetOf) */
|
|
537
|
-
isSubsetOf(otherIds) {
|
|
538
|
-
return this.#itemOrderSet.isSubsetOf(otherIds);
|
|
539
|
-
}
|
|
540
|
-
/** Check if registered items are a superset of the given set (ES2026 Set.isSupersetOf) */
|
|
541
|
-
isSupersetOf(otherIds) {
|
|
542
|
-
return this.#itemOrderSet.isSupersetOf(otherIds);
|
|
543
|
-
}
|
|
544
|
-
/** Check if registered items share no IDs with the given set (ES2026 Set.isDisjointFrom) */
|
|
545
|
-
isDisjointFrom(otherIds) {
|
|
546
|
-
return this.#itemOrderSet.isDisjointFrom(otherIds);
|
|
547
|
-
}
|
|
548
|
-
/** Symmetric difference between registered IDs and another set (ES2026 Set.symmetricDifference) */
|
|
549
|
-
symmetricDifferenceWith(otherIds) {
|
|
550
|
-
return this.#itemOrderSet.symmetricDifference(otherIds);
|
|
551
|
-
}
|
|
552
|
-
/** Total number of registered items */
|
|
553
|
-
get size() {
|
|
554
|
-
return this.#items.size;
|
|
555
|
-
}
|
|
556
|
-
/** Total number of registered groups */
|
|
557
|
-
get groupCount() {
|
|
558
|
-
return this.#groups.size;
|
|
559
|
-
}
|
|
560
|
-
/** Clear all items and groups */
|
|
561
|
-
clear() {
|
|
562
|
-
this.#items.clear();
|
|
563
|
-
this.#groups.clear();
|
|
564
|
-
this.#itemOrder = [];
|
|
565
|
-
this.#itemOrderSet.clear();
|
|
566
|
-
this.#invalidateCache();
|
|
567
|
-
this.#invalidateGroupCache();
|
|
568
|
-
}
|
|
569
|
-
[Symbol.dispose]() {
|
|
570
|
-
this.clear();
|
|
571
|
-
}
|
|
572
|
-
};
|
|
573
|
-
//#endregion
|
|
574
|
-
//#region src/core/search/default-scorer.ts
|
|
575
|
-
function isWordSeparator(code) {
|
|
576
|
-
return code === 32 || code === 9 || code === 45 || code === 95 || code === 46 || code === 47 || code === 92;
|
|
577
|
-
}
|
|
578
|
-
/**
|
|
579
|
-
* Public API: scores a command item against a search query.
|
|
580
|
-
* Multi-strategy: exact → prefix → substring → word-boundary → fuzzy.
|
|
581
|
-
* Returns null if no match. For batch use, prefer the search engine
|
|
582
|
-
* which pre-caches lowercase and normalizes query once.
|
|
583
|
-
*/
|
|
584
|
-
function scoreItem(query, item) {
|
|
585
|
-
if (query === "") return {
|
|
586
|
-
id: item.id,
|
|
587
|
-
score: 1,
|
|
588
|
-
matches: []
|
|
589
|
-
};
|
|
590
|
-
const lowerQuery = query.toLowerCase();
|
|
591
|
-
let bestScore = 0;
|
|
592
|
-
let bestMatches = null;
|
|
593
|
-
const valueResult = scoreTarget(lowerQuery, item.value.toLowerCase());
|
|
594
|
-
if (valueResult !== null) {
|
|
595
|
-
bestScore = valueResult.score;
|
|
596
|
-
bestMatches = valueResult.matches;
|
|
597
|
-
if (bestScore >= 1) return {
|
|
598
|
-
id: item.id,
|
|
599
|
-
score: 1,
|
|
600
|
-
matches: bestMatches
|
|
601
|
-
};
|
|
602
|
-
}
|
|
603
|
-
const kw = item.keywords;
|
|
604
|
-
if (kw !== void 0) for (let i = 0; i < kw.length; i++) {
|
|
605
|
-
const result = scoreTarget(lowerQuery, kw[i].toLowerCase());
|
|
606
|
-
if (result !== null && result.score > bestScore) {
|
|
607
|
-
bestScore = result.score;
|
|
608
|
-
bestMatches = result.matches;
|
|
609
|
-
if (bestScore >= 1) break;
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
return bestMatches !== null ? {
|
|
613
|
-
id: item.id,
|
|
614
|
-
score: bestScore,
|
|
615
|
-
matches: bestMatches
|
|
616
|
-
} : null;
|
|
617
|
-
}
|
|
618
|
-
/**
|
|
619
|
-
* Internal fast path: called by search engine with pre-lowered query and targets.
|
|
620
|
-
* Eliminates all toLowerCase() calls on the hot path.
|
|
621
|
-
*/
|
|
622
|
-
function scoreItemPreLowered(lowerQuery, id, lowerValue, lowerKeywords) {
|
|
623
|
-
let bestScore = 0;
|
|
624
|
-
let bestMatches = null;
|
|
625
|
-
const valueResult = scoreTarget(lowerQuery, lowerValue);
|
|
626
|
-
if (valueResult !== null) {
|
|
627
|
-
bestScore = valueResult.score;
|
|
628
|
-
bestMatches = valueResult.matches;
|
|
629
|
-
if (bestScore >= 1) return {
|
|
630
|
-
id,
|
|
631
|
-
score: 1,
|
|
632
|
-
matches: bestMatches
|
|
633
|
-
};
|
|
634
|
-
}
|
|
635
|
-
if (lowerKeywords !== void 0) for (let i = 0; i < lowerKeywords.length; i++) {
|
|
636
|
-
const result = scoreTarget(lowerQuery, lowerKeywords[i]);
|
|
637
|
-
if (result !== null && result.score > bestScore) {
|
|
638
|
-
bestScore = result.score;
|
|
639
|
-
bestMatches = result.matches;
|
|
640
|
-
if (bestScore >= 1) break;
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
return bestMatches !== null ? {
|
|
644
|
-
id,
|
|
645
|
-
score: bestScore,
|
|
646
|
-
matches: bestMatches
|
|
647
|
-
} : null;
|
|
648
|
-
}
|
|
649
|
-
function scoreTarget(query, lowerTarget) {
|
|
650
|
-
const tLen = lowerTarget.length;
|
|
651
|
-
if (tLen === 0) return null;
|
|
652
|
-
const qLen = query.length;
|
|
653
|
-
if (qLen === 0) return {
|
|
654
|
-
score: 1,
|
|
655
|
-
matches: []
|
|
656
|
-
};
|
|
657
|
-
if (qLen > tLen) return scoreFuzzy(query, qLen, lowerTarget, tLen);
|
|
658
|
-
if (lowerTarget === query) return {
|
|
659
|
-
score: 1,
|
|
660
|
-
matches: [[0, qLen]]
|
|
661
|
-
};
|
|
662
|
-
if (lowerTarget.startsWith(query)) return {
|
|
663
|
-
score: .9 + .1 * (qLen / tLen),
|
|
664
|
-
matches: [[0, qLen]]
|
|
665
|
-
};
|
|
666
|
-
const substringIdx = lowerTarget.indexOf(query);
|
|
667
|
-
if (substringIdx !== -1) {
|
|
668
|
-
const positionBonus = 1 - substringIdx / tLen;
|
|
669
|
-
const lengthRatio = qLen / tLen;
|
|
670
|
-
return {
|
|
671
|
-
score: .5 + .3 * positionBonus + .2 * lengthRatio,
|
|
672
|
-
matches: [[substringIdx, substringIdx + qLen]]
|
|
673
|
-
};
|
|
674
|
-
}
|
|
675
|
-
const wordBoundaryResult = scoreWordBoundary(query, qLen, lowerTarget, tLen);
|
|
676
|
-
if (wordBoundaryResult) return wordBoundaryResult;
|
|
677
|
-
return scoreFuzzy(query, qLen, lowerTarget, tLen);
|
|
678
|
-
}
|
|
679
|
-
function scoreWordBoundary(query, qLen, lowerTarget, tLen) {
|
|
680
|
-
let queryIdx = 0;
|
|
681
|
-
const matches = [];
|
|
682
|
-
let totalWeightedLen = 0;
|
|
683
|
-
let i = 0;
|
|
684
|
-
while (i < tLen && queryIdx < qLen) if ((i === 0 || isWordSeparator(lowerTarget.charCodeAt(i - 1))) && lowerTarget.charCodeAt(i) === query.charCodeAt(queryIdx)) {
|
|
685
|
-
const matchStart = i;
|
|
686
|
-
let matchLen = 0;
|
|
687
|
-
while (queryIdx < qLen && i < tLen && !isWordSeparator(lowerTarget.charCodeAt(i)) && lowerTarget.charCodeAt(i) === query.charCodeAt(queryIdx)) {
|
|
688
|
-
queryIdx++;
|
|
689
|
-
i++;
|
|
690
|
-
matchLen++;
|
|
691
|
-
}
|
|
692
|
-
matches.push([matchStart, matchStart + matchLen]);
|
|
693
|
-
totalWeightedLen += matchLen * 2;
|
|
694
|
-
} else i++;
|
|
695
|
-
if (queryIdx !== qLen) return null;
|
|
696
|
-
const maxPossible = qLen * 2;
|
|
697
|
-
return {
|
|
698
|
-
score: .3 + .4 * (totalWeightedLen / maxPossible),
|
|
699
|
-
matches
|
|
700
|
-
};
|
|
701
|
-
}
|
|
702
|
-
function scoreFuzzy(query, qLen, lowerTarget, tLen) {
|
|
703
|
-
let queryIdx = 0;
|
|
704
|
-
let targetIdx = 0;
|
|
705
|
-
const matches = [];
|
|
706
|
-
let currentMatchStart = -1;
|
|
707
|
-
let contiguityScore = 0;
|
|
708
|
-
while (queryIdx < qLen && targetIdx < tLen) if (query.charCodeAt(queryIdx) === lowerTarget.charCodeAt(targetIdx)) {
|
|
709
|
-
if (currentMatchStart === -1) currentMatchStart = targetIdx;
|
|
710
|
-
queryIdx++;
|
|
711
|
-
targetIdx++;
|
|
712
|
-
} else {
|
|
713
|
-
if (currentMatchStart !== -1) {
|
|
714
|
-
const segmentLen = targetIdx - currentMatchStart;
|
|
715
|
-
matches.push([currentMatchStart, targetIdx]);
|
|
716
|
-
contiguityScore += segmentLen * segmentLen;
|
|
717
|
-
currentMatchStart = -1;
|
|
718
|
-
}
|
|
719
|
-
targetIdx++;
|
|
720
|
-
}
|
|
721
|
-
if (currentMatchStart !== -1) {
|
|
722
|
-
const segmentLen = targetIdx - currentMatchStart;
|
|
723
|
-
matches.push([currentMatchStart, targetIdx]);
|
|
724
|
-
contiguityScore += segmentLen * segmentLen;
|
|
725
|
-
}
|
|
726
|
-
if (queryIdx !== qLen) return null;
|
|
727
|
-
const maxContiguity = qLen * qLen;
|
|
728
|
-
const contiguityRatio = contiguityScore / maxContiguity;
|
|
729
|
-
const positionBonus = 1 - (matches[0]?.[0] ?? 0) / tLen;
|
|
730
|
-
const lengthRatio = qLen / tLen;
|
|
731
|
-
return {
|
|
732
|
-
score: .1 + .3 * contiguityRatio + .15 * positionBonus + .1 * lengthRatio,
|
|
733
|
-
matches
|
|
734
|
-
};
|
|
735
|
-
}
|
|
736
|
-
//#endregion
|
|
737
|
-
//#region src/core/search/index.ts
|
|
738
|
-
const __DEV__ = globalThis.process ? globalThis.process.env.NODE_ENV !== "production" : false;
|
|
739
|
-
let leakRegistry;
|
|
740
|
-
if (__DEV__) leakRegistry = new FinalizationRegistry((_label) => {});
|
|
741
|
-
/**
|
|
742
|
-
* Creates a search engine with pluggable scorer and incremental filtering.
|
|
743
|
-
* When the query extends the previous one, only prior matches are re-scored.
|
|
744
|
-
* Pre-caches lowercase at index time — zero toLowerCase() calls during search.
|
|
745
|
-
* Uses Iterator Helpers (ES2026) for result pipelines and Set.difference for pruning.
|
|
746
|
-
*/
|
|
747
|
-
function createSearchEngine(options) {
|
|
748
|
-
const customScorer = options?.scorer;
|
|
749
|
-
const useDefaultScorer = customScorer === void 0;
|
|
750
|
-
const indexedEntries = /* @__PURE__ */ new Map();
|
|
751
|
-
let previousQuery = "";
|
|
752
|
-
let previousResults = /* @__PURE__ */ new Set();
|
|
753
|
-
const sentinel = {};
|
|
754
|
-
if (__DEV__ && leakRegistry) leakRegistry.register(sentinel, "SearchEngine", sentinel);
|
|
755
|
-
return {
|
|
756
|
-
index(items) {
|
|
757
|
-
for (const item of items) indexedEntries.set(item.id, {
|
|
758
|
-
item,
|
|
759
|
-
lowerValue: item.value.toLowerCase(),
|
|
760
|
-
lowerKeywords: item.keywords?.map((k) => k.toLowerCase())
|
|
761
|
-
});
|
|
762
|
-
},
|
|
763
|
-
search(query, items) {
|
|
764
|
-
if (query === "") {
|
|
765
|
-
previousQuery = "";
|
|
766
|
-
previousResults = new Set(items.values().map((i) => i.id));
|
|
767
|
-
return items.values().map((item) => ({
|
|
768
|
-
id: item.id,
|
|
769
|
-
score: 1,
|
|
770
|
-
matches: []
|
|
771
|
-
}));
|
|
772
|
-
}
|
|
773
|
-
const lowerQuery = query.toLowerCase();
|
|
774
|
-
const candidateItems = query.startsWith(previousQuery) && previousQuery.length > 0 ? items.values().filter((item) => previousResults.has(item.id)) : items;
|
|
775
|
-
const results = [];
|
|
776
|
-
if (useDefaultScorer) for (const item of candidateItems) {
|
|
777
|
-
const entry = indexedEntries.get(item.id);
|
|
778
|
-
const result = entry ? scoreItemPreLowered(lowerQuery, item.id, entry.lowerValue, entry.lowerKeywords) : scoreItemPreLowered(lowerQuery, item.id, item.value.toLowerCase(), item.keywords?.map((k) => k.toLowerCase()));
|
|
779
|
-
if (result !== null) results.push(result);
|
|
780
|
-
}
|
|
781
|
-
else for (const item of candidateItems) {
|
|
782
|
-
const result = customScorer(query, item);
|
|
783
|
-
if (result != null) results.push(result);
|
|
784
|
-
}
|
|
785
|
-
results.sort((a, b) => b.score - a.score);
|
|
786
|
-
previousQuery = query;
|
|
787
|
-
previousResults = new Set(results.values().map((r) => r.id));
|
|
788
|
-
return results.values();
|
|
789
|
-
},
|
|
790
|
-
remove(ids) {
|
|
791
|
-
for (const id of ids) indexedEntries.delete(id);
|
|
792
|
-
previousResults = previousResults.difference(ids);
|
|
793
|
-
},
|
|
794
|
-
clear() {
|
|
795
|
-
indexedEntries.clear();
|
|
796
|
-
previousQuery = "";
|
|
797
|
-
previousResults.clear();
|
|
798
|
-
},
|
|
799
|
-
[Symbol.dispose]() {
|
|
800
|
-
indexedEntries.clear();
|
|
801
|
-
previousResults.clear();
|
|
802
|
-
if (__DEV__ && leakRegistry) leakRegistry.unregister(sentinel);
|
|
803
|
-
}
|
|
804
|
-
};
|
|
805
|
-
}
|
|
806
|
-
//#endregion
|
|
807
|
-
//#region src/core/utils/event-emitter.ts
|
|
808
|
-
/**
|
|
809
|
-
* Type-safe event emitter with strong references and Disposable subscriptions.
|
|
810
|
-
* Returns Disposable from `on()` for automatic unsubscription via `using`.
|
|
811
|
-
*/
|
|
812
|
-
var TypedEmitter = class {
|
|
813
|
-
#listeners = /* @__PURE__ */ new Map();
|
|
814
|
-
/** Subscribe to an event. Returns a Disposable that removes the listener when disposed. */
|
|
815
|
-
on(event, listener) {
|
|
816
|
-
const set = this.#listeners.get(event) ?? /* @__PURE__ */ new Set();
|
|
817
|
-
const fn = listener;
|
|
818
|
-
set.add(fn);
|
|
819
|
-
this.#listeners.set(event, set);
|
|
820
|
-
return { [Symbol.dispose]: () => {
|
|
821
|
-
set.delete(fn);
|
|
822
|
-
} };
|
|
823
|
-
}
|
|
824
|
-
/** Emit an event to all listeners. Hot path — for...of, zero allocation. */
|
|
825
|
-
emit(event, data) {
|
|
826
|
-
const set = this.#listeners.get(event);
|
|
827
|
-
if (!set) return;
|
|
828
|
-
for (const fn of set) fn(data);
|
|
829
|
-
}
|
|
830
|
-
/** Check if any listeners exist for an event */
|
|
831
|
-
has(event) {
|
|
832
|
-
const set = this.#listeners.get(event);
|
|
833
|
-
return set !== void 0 && set.size > 0;
|
|
834
|
-
}
|
|
835
|
-
/** Get count of listeners for an event */
|
|
836
|
-
listenerCount(event) {
|
|
837
|
-
return this.#listeners.get(event)?.size ?? 0;
|
|
838
|
-
}
|
|
839
|
-
/** Remove all listeners */
|
|
840
|
-
removeAll() {
|
|
841
|
-
this.#listeners.clear();
|
|
842
|
-
}
|
|
843
|
-
[Symbol.dispose]() {
|
|
844
|
-
this.#listeners.clear();
|
|
845
|
-
}
|
|
846
|
-
};
|
|
847
|
-
//#endregion
|
|
848
|
-
//#region src/core/utils/scheduler.ts
|
|
849
|
-
/** Whether we're in a browser environment with rAF available */
|
|
850
|
-
const hasRAF = typeof requestAnimationFrame === "function";
|
|
851
|
-
/** Feature detection for cutting-edge scheduling APIs */
|
|
852
|
-
const canYield = typeof globalThis.scheduler?.yield === "function";
|
|
853
|
-
const hasInputPending = typeof navigator?.scheduling?.isInputPending === "function";
|
|
854
|
-
/**
|
|
855
|
-
* Creates a batched update scheduler. Coalesces rapid state updates into a single
|
|
856
|
-
* execution pass using rAF (browser) or queueMicrotask (Node.js).
|
|
857
|
-
*/
|
|
858
|
-
function createScheduler() {
|
|
859
|
-
let pending = [];
|
|
860
|
-
let rafId = null;
|
|
861
|
-
let microtaskScheduled = false;
|
|
862
|
-
function executeBatch() {
|
|
863
|
-
const batch = pending;
|
|
864
|
-
pending = [];
|
|
865
|
-
rafId = null;
|
|
866
|
-
microtaskScheduled = false;
|
|
867
|
-
if (batch.length > 10 && hasInputPending && navigator.scheduling?.isInputPending?.()) {
|
|
868
|
-
pending = [...batch, ...pending];
|
|
869
|
-
if (canYield) globalThis.scheduler?.yield?.().then(() => void executeBatch());
|
|
870
|
-
else rafId ??= requestAnimationFrame(() => void executeBatch());
|
|
871
|
-
return;
|
|
872
|
-
}
|
|
873
|
-
for (const update of batch) try {
|
|
874
|
-
update();
|
|
875
|
-
} catch (error) {
|
|
876
|
-
queueMicrotask(() => {
|
|
877
|
-
throw error;
|
|
878
|
-
});
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
function flush() {
|
|
882
|
-
executeBatch();
|
|
883
|
-
}
|
|
884
|
-
return {
|
|
885
|
-
schedule(update) {
|
|
886
|
-
pending.push(update);
|
|
887
|
-
if (hasRAF) rafId ??= requestAnimationFrame(() => void executeBatch());
|
|
888
|
-
else if (!microtaskScheduled) {
|
|
889
|
-
microtaskScheduled = true;
|
|
890
|
-
queueMicrotask(executeBatch);
|
|
891
|
-
}
|
|
892
|
-
},
|
|
893
|
-
flush,
|
|
894
|
-
[Symbol.dispose]() {
|
|
895
|
-
if (rafId !== null) {
|
|
896
|
-
cancelAnimationFrame(rafId);
|
|
897
|
-
rafId = null;
|
|
898
|
-
}
|
|
899
|
-
microtaskScheduled = false;
|
|
900
|
-
pending = [];
|
|
901
|
-
}
|
|
902
|
-
};
|
|
903
|
-
}
|
|
904
|
-
//#endregion
|
|
905
|
-
//#region src/core/machine.ts
|
|
906
|
-
/**
|
|
907
|
-
* Creates a headless command palette state machine with search, frecency,
|
|
908
|
-
* and keyboard shortcut support. Implements Disposable for `using` cleanup.
|
|
909
|
-
*
|
|
910
|
-
* @example
|
|
911
|
-
* ```ts
|
|
912
|
-
* using machine = createCommandMachine({ items, loop: true });
|
|
913
|
-
* machine.subscribe(() => console.log(machine.getState()));
|
|
914
|
-
* ```
|
|
915
|
-
*/
|
|
916
|
-
function createCommandMachine(options = {}) {
|
|
917
|
-
let state = createInitialState(options);
|
|
918
|
-
let filteredIdSet = /* @__PURE__ */ new Set();
|
|
919
|
-
let filteredIdIndex = /* @__PURE__ */ new Map();
|
|
920
|
-
const registry = new CommandRegistry();
|
|
921
|
-
const searchEngine = options.search ?? createSearchEngine(typeof options.filter === "function" ? { scorer: (query, item) => {
|
|
922
|
-
const score = options.filter(item, query);
|
|
923
|
-
if (score === false) return null;
|
|
924
|
-
return {
|
|
925
|
-
id: item.id,
|
|
926
|
-
score,
|
|
927
|
-
matches: []
|
|
928
|
-
};
|
|
929
|
-
} } : void 0);
|
|
930
|
-
const scheduler = createScheduler();
|
|
931
|
-
const emitter = new TypedEmitter();
|
|
932
|
-
const keyboardRegistry = new KeyboardShortcutRegistry();
|
|
933
|
-
let frecencyEngine = null;
|
|
934
|
-
if (options.frecency?.enabled) frecencyEngine = new FrecencyEngine(options.frecency);
|
|
935
|
-
if (options.items) {
|
|
936
|
-
registry.registerItems(options.items);
|
|
937
|
-
searchEngine.index(options.items);
|
|
938
|
-
for (const item of options.items) if (item.shortcut != null && item.onSelect != null) keyboardRegistry.register(item.shortcut, item.id, item.onSelect);
|
|
939
|
-
}
|
|
940
|
-
if (options.groups) for (const group of options.groups) registry.registerGroup(group);
|
|
941
|
-
refilter("");
|
|
942
|
-
function setState(newState) {
|
|
943
|
-
state = newState;
|
|
944
|
-
emitter.emit("stateChange", state);
|
|
945
|
-
}
|
|
946
|
-
function refilter(query) {
|
|
947
|
-
const items = registry.getItems();
|
|
948
|
-
const disableFilter = options.filter === false;
|
|
949
|
-
let filteredIds;
|
|
950
|
-
if (disableFilter || query === "") {
|
|
951
|
-
filteredIds = [];
|
|
952
|
-
for (const i of items) if (!i.disabled) filteredIds.push(i.id);
|
|
953
|
-
} else {
|
|
954
|
-
const results = searchEngine.search(query, items).toArray();
|
|
955
|
-
if (frecencyEngine) {
|
|
956
|
-
const bonuses = frecencyEngine.getAllBonuses();
|
|
957
|
-
results.sort((a, b) => {
|
|
958
|
-
const aBonus = bonuses.get(a.id) ?? 0;
|
|
959
|
-
const bBonus = bonuses.get(b.id) ?? 0;
|
|
960
|
-
return b.score + bBonus - (a.score + aBonus);
|
|
961
|
-
});
|
|
962
|
-
}
|
|
963
|
-
filteredIds = results.map((r) => r.id);
|
|
964
|
-
}
|
|
965
|
-
filteredIdSet = /* @__PURE__ */ new Set();
|
|
966
|
-
filteredIdIndex = /* @__PURE__ */ new Map();
|
|
967
|
-
for (let i = 0; i < filteredIds.length; i++) {
|
|
968
|
-
const id = filteredIds[i];
|
|
969
|
-
filteredIdSet.add(id);
|
|
970
|
-
filteredIdIndex.set(id, i);
|
|
971
|
-
}
|
|
972
|
-
const groupedIds = Map.groupBy(filteredIds, (id) => {
|
|
973
|
-
return registry.getItem(id)?.groupId ?? "__ungrouped";
|
|
974
|
-
});
|
|
975
|
-
const activeId = state.activeId && filteredIdSet.has(state.activeId) ? state.activeId : filteredIds[0] ?? null;
|
|
976
|
-
setState({
|
|
977
|
-
...state,
|
|
978
|
-
search: query,
|
|
979
|
-
filteredIds,
|
|
980
|
-
groupedIds,
|
|
981
|
-
filteredCount: filteredIds.length,
|
|
982
|
-
activeId,
|
|
983
|
-
lastUpdated: Temporal.Now.instant()
|
|
984
|
-
});
|
|
985
|
-
}
|
|
986
|
-
function navigate(direction) {
|
|
987
|
-
const { filteredIds, activeId } = state;
|
|
988
|
-
if (filteredIds.length === 0) return;
|
|
989
|
-
if (direction === "first") {
|
|
990
|
-
const newActiveId = filteredIds[0] ?? null;
|
|
991
|
-
if (newActiveId !== activeId) {
|
|
992
|
-
setState({
|
|
993
|
-
...state,
|
|
994
|
-
activeId: newActiveId,
|
|
995
|
-
lastUpdated: Temporal.Now.instant()
|
|
996
|
-
});
|
|
997
|
-
options.onActiveChange?.(newActiveId);
|
|
998
|
-
}
|
|
999
|
-
return;
|
|
1000
|
-
}
|
|
1001
|
-
if (direction === "last") {
|
|
1002
|
-
const newActiveId = filteredIds[filteredIds.length - 1] ?? null;
|
|
1003
|
-
if (newActiveId !== activeId) {
|
|
1004
|
-
setState({
|
|
1005
|
-
...state,
|
|
1006
|
-
activeId: newActiveId,
|
|
1007
|
-
lastUpdated: Temporal.Now.instant()
|
|
1008
|
-
});
|
|
1009
|
-
options.onActiveChange?.(newActiveId);
|
|
1010
|
-
}
|
|
1011
|
-
return;
|
|
1012
|
-
}
|
|
1013
|
-
const loop = options.loop ?? true;
|
|
1014
|
-
const currentIdx = activeId ? filteredIdIndex.get(activeId) ?? -1 : -1;
|
|
1015
|
-
let newIdx;
|
|
1016
|
-
if (direction === "next") if (currentIdx === -1 || currentIdx >= filteredIds.length - 1) newIdx = loop ? 0 : filteredIds.length - 1;
|
|
1017
|
-
else newIdx = currentIdx + 1;
|
|
1018
|
-
else if (currentIdx <= 0) newIdx = loop ? filteredIds.length - 1 : 0;
|
|
1019
|
-
else newIdx = currentIdx - 1;
|
|
1020
|
-
const newActiveId = filteredIds[newIdx] ?? null;
|
|
1021
|
-
if (newActiveId !== activeId) {
|
|
1022
|
-
setState({
|
|
1023
|
-
...state,
|
|
1024
|
-
activeId: newActiveId,
|
|
1025
|
-
lastUpdated: Temporal.Now.instant()
|
|
1026
|
-
});
|
|
1027
|
-
options.onActiveChange?.(newActiveId);
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
function handleEvent(event) {
|
|
1031
|
-
switch (event.type) {
|
|
1032
|
-
case "SEARCH_CHANGE":
|
|
1033
|
-
refilter(event.query);
|
|
1034
|
-
options.onSearchChange?.(event.query);
|
|
1035
|
-
break;
|
|
1036
|
-
case "ITEM_SELECT": {
|
|
1037
|
-
const item = registry.getItem(event.id);
|
|
1038
|
-
if (item && !item.disabled) {
|
|
1039
|
-
item.onSelect?.();
|
|
1040
|
-
frecencyEngine?.recordSelection(event.id);
|
|
1041
|
-
emitter.emit("select", event.id);
|
|
1042
|
-
options.onSelect?.(event.id);
|
|
1043
|
-
}
|
|
1044
|
-
break;
|
|
1045
|
-
}
|
|
1046
|
-
case "ITEM_ACTIVATE":
|
|
1047
|
-
if (filteredIdSet.has(event.id)) {
|
|
1048
|
-
setState({
|
|
1049
|
-
...state,
|
|
1050
|
-
activeId: event.id,
|
|
1051
|
-
lastUpdated: Temporal.Now.instant()
|
|
1052
|
-
});
|
|
1053
|
-
options.onActiveChange?.(event.id);
|
|
1054
|
-
}
|
|
1055
|
-
break;
|
|
1056
|
-
case "NAVIGATE":
|
|
1057
|
-
navigate(event.direction);
|
|
1058
|
-
break;
|
|
1059
|
-
case "PAGE_PUSH":
|
|
1060
|
-
setState({
|
|
1061
|
-
...state,
|
|
1062
|
-
page: event.page,
|
|
1063
|
-
pageStack: [...state.pageStack, state.page],
|
|
1064
|
-
lastUpdated: Temporal.Now.instant()
|
|
1065
|
-
});
|
|
1066
|
-
break;
|
|
1067
|
-
case "PAGE_POP": {
|
|
1068
|
-
const prevPage = state.pageStack.at(-1);
|
|
1069
|
-
if (prevPage != null) setState({
|
|
1070
|
-
...state,
|
|
1071
|
-
page: prevPage,
|
|
1072
|
-
pageStack: state.pageStack.slice(0, -1),
|
|
1073
|
-
lastUpdated: Temporal.Now.instant()
|
|
1074
|
-
});
|
|
1075
|
-
break;
|
|
1076
|
-
}
|
|
1077
|
-
case "OPEN":
|
|
1078
|
-
setState({
|
|
1079
|
-
...state,
|
|
1080
|
-
open: true,
|
|
1081
|
-
lastUpdated: Temporal.Now.instant()
|
|
1082
|
-
});
|
|
1083
|
-
emitter.emit("openChange", true);
|
|
1084
|
-
options.onOpenChange?.(true);
|
|
1085
|
-
break;
|
|
1086
|
-
case "CLOSE":
|
|
1087
|
-
setState({
|
|
1088
|
-
...state,
|
|
1089
|
-
open: false,
|
|
1090
|
-
lastUpdated: Temporal.Now.instant()
|
|
1091
|
-
});
|
|
1092
|
-
emitter.emit("openChange", false);
|
|
1093
|
-
options.onOpenChange?.(false);
|
|
1094
|
-
break;
|
|
1095
|
-
case "TOGGLE": {
|
|
1096
|
-
const newOpen = !state.open;
|
|
1097
|
-
setState({
|
|
1098
|
-
...state,
|
|
1099
|
-
open: newOpen,
|
|
1100
|
-
lastUpdated: Temporal.Now.instant()
|
|
1101
|
-
});
|
|
1102
|
-
emitter.emit("openChange", newOpen);
|
|
1103
|
-
options.onOpenChange?.(newOpen);
|
|
1104
|
-
break;
|
|
1105
|
-
}
|
|
1106
|
-
case "ITEMS_LOADED":
|
|
1107
|
-
registry.registerItems(event.items);
|
|
1108
|
-
searchEngine.index(event.items);
|
|
1109
|
-
state = {
|
|
1110
|
-
...state,
|
|
1111
|
-
loading: false
|
|
1112
|
-
};
|
|
1113
|
-
refilter(state.search);
|
|
1114
|
-
break;
|
|
1115
|
-
case "REGISTER_ITEM":
|
|
1116
|
-
registry.registerItem(event.item);
|
|
1117
|
-
searchEngine.index([event.item]);
|
|
1118
|
-
if (event.item.shortcut && event.item.onSelect) keyboardRegistry.register(event.item.shortcut, event.item.id, event.item.onSelect);
|
|
1119
|
-
refilter(state.search);
|
|
1120
|
-
break;
|
|
1121
|
-
case "UNREGISTER_ITEM":
|
|
1122
|
-
registry.unregisterItem(event.id);
|
|
1123
|
-
searchEngine.remove(new Set([event.id]));
|
|
1124
|
-
keyboardRegistry.unregisterByItem(event.id);
|
|
1125
|
-
refilter(state.search);
|
|
1126
|
-
break;
|
|
1127
|
-
case "REGISTER_GROUP":
|
|
1128
|
-
registry.registerGroup(event.group);
|
|
1129
|
-
refilter(state.search);
|
|
1130
|
-
break;
|
|
1131
|
-
case "UNREGISTER_GROUP":
|
|
1132
|
-
registry.unregisterGroup(event.id);
|
|
1133
|
-
refilter(state.search);
|
|
1134
|
-
break;
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
return {
|
|
1138
|
-
getState() {
|
|
1139
|
-
return state;
|
|
1140
|
-
},
|
|
1141
|
-
send(event) {
|
|
1142
|
-
scheduler.schedule(() => handleEvent(event));
|
|
1143
|
-
},
|
|
1144
|
-
subscribe(listener) {
|
|
1145
|
-
const disposable = emitter.on("stateChange", listener);
|
|
1146
|
-
return () => disposable[Symbol.dispose]();
|
|
1147
|
-
},
|
|
1148
|
-
subscribeState(listener) {
|
|
1149
|
-
return emitter.on("stateChange", listener);
|
|
1150
|
-
},
|
|
1151
|
-
getRegistry() {
|
|
1152
|
-
return registry;
|
|
1153
|
-
},
|
|
1154
|
-
getKeyboardRegistry() {
|
|
1155
|
-
return keyboardRegistry;
|
|
1156
|
-
},
|
|
1157
|
-
getFilteredIdSet() {
|
|
1158
|
-
return filteredIdSet;
|
|
1159
|
-
},
|
|
1160
|
-
[Symbol.dispose]() {
|
|
1161
|
-
scheduler[Symbol.dispose]();
|
|
1162
|
-
registry[Symbol.dispose]();
|
|
1163
|
-
searchEngine[Symbol.dispose]();
|
|
1164
|
-
emitter[Symbol.dispose]();
|
|
1165
|
-
keyboardRegistry[Symbol.dispose]();
|
|
1166
|
-
frecencyEngine?.[Symbol.dispose]();
|
|
1167
|
-
}
|
|
1168
|
-
};
|
|
1169
|
-
}
|
|
1170
|
-
//#endregion
|
|
1171
|
-
//#region src/core/search/fuzzy-scorer.ts
|
|
1172
|
-
/**
|
|
1173
|
-
* Async-safe fuzzy scorer — wraps scoring in Promise.try (ES2026) for error resilience.
|
|
1174
|
-
* Falls back gracefully on error via Promise.try's synchronous-throw capture.
|
|
1175
|
-
*/
|
|
1176
|
-
async function scoreItemAsync(query, item) {
|
|
1177
|
-
return Promise.try(() => scoreItem(query, item));
|
|
1178
|
-
}
|
|
1179
|
-
/**
|
|
1180
|
-
* Async generator that yields score results for each item.
|
|
1181
|
-
* Enables streaming consumption and lazy evaluation of scoring operations.
|
|
1182
|
-
*/
|
|
1183
|
-
async function* scoreItemsGenerator(query, items) {
|
|
1184
|
-
for (const item of items) yield Promise.try(() => scoreItem(query, item)).catch(() => null);
|
|
1185
|
-
}
|
|
1186
|
-
/**
|
|
1187
|
-
* Batch score items using Array.fromAsync (ES2026) with an async generator pipeline.
|
|
1188
|
-
* Uses Promise.try for safe execution of each scoring operation and Array.fromAsync
|
|
1189
|
-
* for materializing the async iterable into a concrete array.
|
|
1190
|
-
*/
|
|
1191
|
-
async function batchScoreItems(query, items) {
|
|
1192
|
-
return (await Array.fromAsync(scoreItemsGenerator(query, items))).filter((r) => r != null).sort((a, b) => b.score - a.score);
|
|
1193
|
-
}
|
|
1194
|
-
//#endregion
|
|
1195
|
-
//#region src/core/telemetry.ts
|
|
1196
|
-
/**
|
|
1197
|
-
* Creates a telemetry middleware that measures command palette interactions.
|
|
1198
|
-
* Uses Temporal.Now.instant() (ES2026) for nanosecond-precision timestamps
|
|
1199
|
-
* and Temporal.Instant.since() for accurate duration computation.
|
|
1200
|
-
*
|
|
1201
|
-
* @example
|
|
1202
|
-
* ```ts
|
|
1203
|
-
* const telemetry = createTelemetryMiddleware({
|
|
1204
|
-
* onPaletteOpen: () => analytics.track('palette_opened'),
|
|
1205
|
-
* onItemSelected: (id, query, pos) => analytics.track('item_selected', { id, query, position: pos }),
|
|
1206
|
-
* });
|
|
1207
|
-
* ```
|
|
1208
|
-
*/
|
|
1209
|
-
function createTelemetryMiddleware(hooks) {
|
|
1210
|
-
let openTimestamp = null;
|
|
1211
|
-
return {
|
|
1212
|
-
onOpen() {
|
|
1213
|
-
openTimestamp = Temporal.Now.instant();
|
|
1214
|
-
hooks.onPaletteOpen?.();
|
|
1215
|
-
},
|
|
1216
|
-
onClose() {
|
|
1217
|
-
if (openTimestamp != null) {
|
|
1218
|
-
const elapsed = Temporal.Now.instant().since(openTimestamp);
|
|
1219
|
-
hooks.onPaletteClose?.(elapsed.total("milliseconds"));
|
|
1220
|
-
openTimestamp = null;
|
|
1221
|
-
}
|
|
1222
|
-
},
|
|
1223
|
-
onSearch(query, resultCount, startTime) {
|
|
1224
|
-
const elapsed = Temporal.Now.instant().since(startTime);
|
|
1225
|
-
hooks.onSearchComplete?.(query, resultCount, elapsed.total("milliseconds"));
|
|
1226
|
-
},
|
|
1227
|
-
onSelect(itemId, query, position) {
|
|
1228
|
-
hooks.onItemSelected?.(itemId, query, position);
|
|
1229
|
-
}
|
|
1230
|
-
};
|
|
1231
|
-
}
|
|
1232
|
-
//#endregion
|
|
1233
|
-
export { itemId as C, groupId as S, computeFrecencyBonus as _, createScheduler as a, DEFAULT_MACHINE_OPTIONS as b, scoreItem as c, formatShortcut as d, parseShortcut as f, FrecencyEngine as g, matchesShortcut as h, createCommandMachine as i, CommandRegistry as l, findMatchingShortcut as m, batchScoreItems as n, TypedEmitter as o, detectConflicts as p, scoreItemAsync as r, createSearchEngine as s, createTelemetryMiddleware as t, KeyboardShortcutRegistry as u, MemoryFrecencyStorage as v, IdbFrecencyStorage as w, createInitialState as x, DEFAULT_FRECENCY_DECAY as y };
|
|
1234
|
-
|
|
1235
|
-
//# sourceMappingURL=core-CgQYpHK3.mjs.map
|