@checkmate-monitor/command-frontend 0.0.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # @checkmate-monitor/command-frontend
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [ffc28f6]
8
+ - @checkmate-monitor/common@0.1.0
9
+ - @checkmate-monitor/command-common@0.0.2
10
+ - @checkmate-monitor/frontend-api@0.0.2
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@checkmate-monitor/command-frontend",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "import": "./src/index.ts"
8
+ }
9
+ },
10
+ "dependencies": {
11
+ "@checkmate-monitor/command-common": "workspace:*",
12
+ "@checkmate-monitor/common": "workspace:*",
13
+ "@checkmate-monitor/frontend-api": "workspace:*",
14
+ "react": "^18.2.0"
15
+ },
16
+ "devDependencies": {
17
+ "typescript": "^5.7.2",
18
+ "@types/react": "^18.2.0",
19
+ "@checkmate-monitor/tsconfig": "workspace:*",
20
+ "@checkmate-monitor/scripts": "workspace:*"
21
+ },
22
+ "scripts": {
23
+ "typecheck": "tsc --noEmit",
24
+ "lint": "bun run lint:code",
25
+ "lint:code": "eslint . --max-warnings 0"
26
+ }
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,348 @@
1
+ import { useEffect, useCallback, useMemo, useState, useRef } from "react";
2
+ import {
3
+ useApi,
4
+ rpcApiRef,
5
+ createApiRef,
6
+ } from "@checkmate-monitor/frontend-api";
7
+ import {
8
+ CommandApi,
9
+ type SearchResult,
10
+ } from "@checkmate-monitor/command-common";
11
+ import type { InferClient } from "@checkmate-monitor/common";
12
+
13
+ // =============================================================================
14
+ // API REF
15
+ // =============================================================================
16
+
17
+ export type CommandApiClient = InferClient<typeof CommandApi>;
18
+
19
+ export const commandApiRef =
20
+ createApiRef<CommandApiClient>("plugin.command.api");
21
+
22
+ // =============================================================================
23
+ // SHORTCUT UTILITIES (Frontend-only - requires DOM types)
24
+ // =============================================================================
25
+
26
+ interface ParsedShortcut {
27
+ meta: boolean;
28
+ ctrl: boolean;
29
+ alt: boolean;
30
+ shift: boolean;
31
+ key: string;
32
+ }
33
+
34
+ /**
35
+ * Parse a shortcut string like "meta+shift+k" into components.
36
+ */
37
+ function parseShortcut(shortcut: string): ParsedShortcut {
38
+ const parts = shortcut.toLowerCase().split("+");
39
+ const key = parts.pop() ?? "";
40
+ return {
41
+ meta: parts.includes("meta"),
42
+ ctrl: parts.includes("ctrl"),
43
+ alt: parts.includes("alt"),
44
+ shift: parts.includes("shift"),
45
+ key,
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Check if a keyboard event matches a parsed shortcut.
51
+ */
52
+ function matchesShortcut(
53
+ event: KeyboardEvent,
54
+ shortcut: ParsedShortcut
55
+ ): boolean {
56
+ return (
57
+ event.metaKey === shortcut.meta &&
58
+ event.ctrlKey === shortcut.ctrl &&
59
+ event.altKey === shortcut.alt &&
60
+ event.shiftKey === shortcut.shift &&
61
+ event.key.toLowerCase() === shortcut.key
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Format a shortcut for display.
67
+ * "meta+i" → "⌘I" on Mac, "Ctrl+I" on Windows
68
+ */
69
+ export function formatShortcut(shortcut: string, isMac: boolean): string {
70
+ const parsed = parseShortcut(shortcut);
71
+ const parts: string[] = [];
72
+
73
+ if (parsed.ctrl) {
74
+ parts.push(isMac ? "⌃" : "Ctrl");
75
+ }
76
+ if (parsed.alt) {
77
+ parts.push(isMac ? "⌥" : "Alt");
78
+ }
79
+ if (parsed.shift) {
80
+ parts.push(isMac ? "⇧" : "Shift");
81
+ }
82
+ if (parsed.meta) {
83
+ parts.push(isMac ? "⌘" : "Win");
84
+ }
85
+ parts.push(parsed.key.toUpperCase());
86
+
87
+ return isMac ? parts.join("") : parts.join("+");
88
+ }
89
+
90
+ // =============================================================================
91
+ // HOOKS
92
+ // =============================================================================
93
+
94
+ /**
95
+ * Hook that registers global keyboard shortcuts for commands.
96
+ * When a shortcut is triggered, it navigates to the command's route.
97
+ * Should be used once at the app root level.
98
+ *
99
+ * @param commands - Array of commands with shortcuts
100
+ * @param navigate - Navigation function to call when a command is triggered
101
+ * @param userPermissions - Array of permission IDs the user has
102
+ */
103
+ export function useGlobalShortcuts(
104
+ commands: SearchResult[],
105
+ navigate: (route: string) => void,
106
+ userPermissions: string[]
107
+ ): void {
108
+ useEffect(() => {
109
+ // Check if user has wildcard permission (admin)
110
+ const hasWildcard = userPermissions.includes("*");
111
+
112
+ const handleKeyDown = (event: KeyboardEvent) => {
113
+ // Don't trigger in input fields
114
+ const target = event.target as HTMLElement;
115
+ if (
116
+ target.tagName === "INPUT" ||
117
+ target.tagName === "TEXTAREA" ||
118
+ target.isContentEditable
119
+ ) {
120
+ return;
121
+ }
122
+
123
+ // Find matching command
124
+ for (const command of commands) {
125
+ if (!command.shortcuts || !command.route) continue;
126
+
127
+ // Check permissions (skip if user has wildcard)
128
+ if (!hasWildcard && command.requiredPermissions?.length) {
129
+ const hasPermission = command.requiredPermissions.every((perm) =>
130
+ userPermissions.includes(perm)
131
+ );
132
+ if (!hasPermission) continue;
133
+ }
134
+
135
+ for (const shortcut of command.shortcuts) {
136
+ const parsed = parseShortcut(shortcut);
137
+ if (matchesShortcut(event, parsed)) {
138
+ event.preventDefault();
139
+ navigate(command.route);
140
+ return;
141
+ }
142
+ }
143
+ }
144
+ };
145
+
146
+ globalThis.addEventListener("keydown", handleKeyDown);
147
+ return () => globalThis.removeEventListener("keydown", handleKeyDown);
148
+ }, [commands, navigate, userPermissions]);
149
+ }
150
+
151
+ /**
152
+ * Hook to format a shortcut string for the current platform.
153
+ */
154
+ export function useFormatShortcut(): (shortcut: string) => string {
155
+ const isMac = useMemo(
156
+ () =>
157
+ typeof navigator !== "undefined" &&
158
+ /Mac|iPhone|iPad/.test(navigator.userAgent),
159
+ []
160
+ );
161
+
162
+ return useCallback(
163
+ (shortcut: string) => formatShortcut(shortcut, isMac),
164
+ [isMac]
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Hook to search across all providers via backend RPC.
170
+ * Returns search function and loading state.
171
+ */
172
+ export function useCommandPaletteSearch(): {
173
+ search: (query: string) => Promise<SearchResult[]>;
174
+ getCommands: () => Promise<SearchResult[]>;
175
+ loading: boolean;
176
+ } {
177
+ const rpcApi = useApi(rpcApiRef);
178
+ const commandApi = useMemo(() => rpcApi.forPlugin(CommandApi), [rpcApi]);
179
+ const [loading, setLoading] = useState(false);
180
+
181
+ const search = useCallback(
182
+ async (query: string): Promise<SearchResult[]> => {
183
+ setLoading(true);
184
+ try {
185
+ return await commandApi.search({ query });
186
+ } finally {
187
+ setLoading(false);
188
+ }
189
+ },
190
+ [commandApi]
191
+ );
192
+
193
+ const getCommands = useCallback(async (): Promise<SearchResult[]> => {
194
+ setLoading(true);
195
+ try {
196
+ return await commandApi.getCommands();
197
+ } finally {
198
+ setLoading(false);
199
+ }
200
+ }, [commandApi]);
201
+
202
+ return { search, getCommands, loading };
203
+ }
204
+
205
+ /**
206
+ * Hook for debounced search in the command palette.
207
+ * Automatically debounces the search query by the specified delay.
208
+ */
209
+ export function useDebouncedSearch(delayMs: number = 300): {
210
+ results: SearchResult[];
211
+ loading: boolean;
212
+ search: (query: string) => void;
213
+ reset: () => void;
214
+ } {
215
+ const { search: doSearch, getCommands } = useCommandPaletteSearch();
216
+ const [results, setResults] = useState<SearchResult[]>([]);
217
+ const [loading, setLoading] = useState(false);
218
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
219
+
220
+ const search = useCallback(
221
+ (query: string) => {
222
+ // Clear any pending search
223
+ if (timeoutRef.current) {
224
+ clearTimeout(timeoutRef.current);
225
+ }
226
+
227
+ // If empty query, fetch all results immediately (commands + entities)
228
+ if (!query.trim()) {
229
+ setLoading(true);
230
+ doSearch("")
231
+ .then(setResults)
232
+ .catch(() => setResults([]))
233
+ .finally(() => setLoading(false));
234
+ return;
235
+ }
236
+
237
+ // Debounce non-empty queries
238
+ setLoading(true);
239
+ timeoutRef.current = setTimeout(() => {
240
+ doSearch(query)
241
+ .then(setResults)
242
+ .catch(() => setResults([]))
243
+ .finally(() => setLoading(false));
244
+ }, delayMs);
245
+ },
246
+ [doSearch, getCommands, delayMs]
247
+ );
248
+
249
+ const reset = useCallback(() => {
250
+ if (timeoutRef.current) {
251
+ clearTimeout(timeoutRef.current);
252
+ }
253
+ setResults([]);
254
+ setLoading(false);
255
+ }, []);
256
+
257
+ // Cleanup on unmount
258
+ useEffect(() => {
259
+ return () => {
260
+ if (timeoutRef.current) {
261
+ clearTimeout(timeoutRef.current);
262
+ }
263
+ };
264
+ }, []);
265
+
266
+ return { results, loading, search, reset };
267
+ }
268
+
269
+ // =============================================================================
270
+ // GLOBAL SHORTCUTS COMPONENT
271
+ // =============================================================================
272
+
273
+ /**
274
+ * Hook to fetch commands with shortcuts from the backend.
275
+ * Returns commands that can be used with useGlobalShortcuts.
276
+ */
277
+ export function useCommands(): {
278
+ commands: SearchResult[];
279
+ loading: boolean;
280
+ } {
281
+ const rpcApi = useApi(rpcApiRef);
282
+ const commandApi = useMemo(() => rpcApi.forPlugin(CommandApi), [rpcApi]);
283
+ const [commands, setCommands] = useState<SearchResult[]>([]);
284
+ const [loading, setLoading] = useState(true);
285
+
286
+ useEffect(() => {
287
+ let cancelled = false;
288
+
289
+ async function fetchCommands() {
290
+ try {
291
+ const results = await commandApi.getCommands();
292
+ if (!cancelled) {
293
+ // Filter to only commands with shortcuts
294
+ setCommands(
295
+ results.filter((r) => r.shortcuts && r.shortcuts.length > 0)
296
+ );
297
+ }
298
+ } catch {
299
+ // Ignore errors - commands just won't be available
300
+ } finally {
301
+ if (!cancelled) {
302
+ setLoading(false);
303
+ }
304
+ }
305
+ }
306
+
307
+ fetchCommands();
308
+
309
+ return () => {
310
+ cancelled = true;
311
+ };
312
+ }, [commandApi]);
313
+
314
+ return { commands, loading };
315
+ }
316
+
317
+ /**
318
+ * Component that registers global keyboard shortcuts for commands.
319
+ * Mount this at the app root level (e.g., in Layout or App).
320
+ *
321
+ * @example
322
+ * ```tsx
323
+ * import { GlobalShortcuts } from "@checkmate-monitor/command-frontend";
324
+ *
325
+ * function App() {
326
+ * return (
327
+ * <>
328
+ * <GlobalShortcuts />
329
+ * {/* rest of app *\/}
330
+ * </>
331
+ * );
332
+ * }
333
+ * ```
334
+ */
335
+ export function GlobalShortcuts(): React.ReactNode {
336
+ const { commands } = useCommands();
337
+ const navigate = useCallback((route: string) => {
338
+ // Use window.location for reliable navigation
339
+ globalThis.location.href = route;
340
+ }, []);
341
+
342
+ // For now, pass "*" as permission since the backend already filters by permission
343
+ // The commands returned from getCommands are already filtered
344
+ useGlobalShortcuts(commands, navigate, ["*"]);
345
+
346
+ // This component renders nothing - it only registers event listeners
347
+ return;
348
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkmate-monitor/tsconfig/frontend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }