@checkstack/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,56 @@
1
+ # @checkstack/command-frontend
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
8
+ - Updated dependencies [d20d274]
9
+ - @checkstack/command-common@0.0.2
10
+ - @checkstack/common@0.0.2
11
+ - @checkstack/frontend-api@0.0.2
12
+ - @checkstack/ui@0.0.2
13
+
14
+ ## 0.1.0
15
+
16
+ ### Minor Changes
17
+
18
+ - ae33df2: Move command palette from dashboard to centered navbar position
19
+
20
+ - Converted `command-frontend` into a plugin with `NavbarCenterSlot` extension
21
+ - Added compact `NavbarSearch` component with responsive search trigger
22
+ - Moved `SearchDialog` from dashboard-frontend to command-frontend
23
+ - Keyboard shortcut (⌘K / Ctrl+K) now works on every page
24
+ - Renamed navbar slots for clarity:
25
+ - `NavbarSlot` → `NavbarRightSlot`
26
+ - `NavbarMainSlot` → `NavbarLeftSlot`
27
+ - Added new `NavbarCenterSlot` for centered content
28
+
29
+ ### Patch Changes
30
+
31
+ - Updated dependencies [52231ef]
32
+ - Updated dependencies [b0124ef]
33
+ - Updated dependencies [54cc787]
34
+ - Updated dependencies [a65e002]
35
+ - Updated dependencies [ae33df2]
36
+ - Updated dependencies [32ea706]
37
+ - @checkstack/ui@0.1.2
38
+ - @checkstack/common@0.2.0
39
+ - @checkstack/frontend-api@0.1.0
40
+ - @checkstack/command-common@0.0.3
41
+
42
+ ## 0.0.3
43
+
44
+ ### Patch Changes
45
+
46
+ - Updated dependencies [0f8cc7d]
47
+ - @checkstack/frontend-api@0.0.3
48
+
49
+ ## 0.0.2
50
+
51
+ ### Patch Changes
52
+
53
+ - Updated dependencies [ffc28f6]
54
+ - @checkstack/common@0.1.0
55
+ - @checkstack/command-common@0.0.2
56
+ - @checkstack/frontend-api@0.0.2
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@checkstack/command-frontend",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "import": "./src/index.tsx"
8
+ }
9
+ },
10
+ "dependencies": {
11
+ "@checkstack/command-common": "workspace:*",
12
+ "@checkstack/common": "workspace:*",
13
+ "@checkstack/frontend-api": "workspace:*",
14
+ "@checkstack/ui": "workspace:*",
15
+ "lucide-react": "^0.468.0",
16
+ "react": "^18.2.0",
17
+ "react-router-dom": "^7.1.1"
18
+ },
19
+ "devDependencies": {
20
+ "typescript": "^5.7.2",
21
+ "@types/react": "^18.2.0",
22
+ "@checkstack/tsconfig": "workspace:*",
23
+ "@checkstack/scripts": "workspace:*"
24
+ },
25
+ "scripts": {
26
+ "typecheck": "tsc --noEmit",
27
+ "lint": "bun run lint:code",
28
+ "lint:code": "eslint . --max-warnings 0"
29
+ }
30
+ }
@@ -0,0 +1,89 @@
1
+ import { useState, useEffect, useMemo } from "react";
2
+ import { cn } from "@checkstack/ui";
3
+ import { Search, Command } from "lucide-react";
4
+ import { SearchDialog } from "./SearchDialog";
5
+
6
+ /**
7
+ * NavbarSearch - Compact command palette trigger for the navbar.
8
+ * Displays a small search button that opens the global search dialog.
9
+ * Includes the ⌘K keyboard shortcut listener.
10
+ */
11
+ export const NavbarSearch = () => {
12
+ const [searchOpen, setSearchOpen] = useState(false);
13
+
14
+ // Detect Mac for keyboard shortcut display
15
+ const isMac = useMemo(
16
+ () =>
17
+ typeof navigator !== "undefined" &&
18
+ /Mac|iPhone|iPad/.test(navigator.userAgent),
19
+ []
20
+ );
21
+
22
+ // Global keyboard shortcut for search (⌘K / Ctrl+K)
23
+ useEffect(() => {
24
+ const handleKeyDown = (e: KeyboardEvent) => {
25
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
26
+ e.preventDefault();
27
+ setSearchOpen(true);
28
+ }
29
+ };
30
+
31
+ document.addEventListener("keydown", handleKeyDown);
32
+ return () => document.removeEventListener("keydown", handleKeyDown);
33
+ }, []);
34
+
35
+ return (
36
+ <>
37
+ {/* Search Dialog */}
38
+ <SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
39
+
40
+ {/* Compact trigger button */}
41
+ <button
42
+ onClick={() => setSearchOpen(true)}
43
+ className={cn(
44
+ // Base styles
45
+ "flex items-center gap-2 px-3 py-1.5 rounded-lg",
46
+ // Glassmorphism effect
47
+ "bg-muted/50 border border-primary/30",
48
+ // Subtle primary pulse animation
49
+ "animate-pulse-subtle ring-1 ring-primary/20",
50
+ // Hover state
51
+ "hover:bg-muted hover:border-primary/50 hover:ring-primary/40",
52
+ // Transition
53
+ "transition-all duration-200",
54
+ // Focus ring
55
+ "focus:outline-none focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background",
56
+ // Cursor
57
+ "cursor-pointer"
58
+ )}
59
+ style={{
60
+ animation: "pulse-glow 3s ease-in-out infinite",
61
+ }}
62
+ aria-label="Open search"
63
+ >
64
+ <Search className="w-4 h-4 text-muted-foreground" />
65
+ {/* Show placeholder text only on larger screens */}
66
+ <span className="hidden md:inline text-sm text-muted-foreground">
67
+ Search...
68
+ </span>
69
+ {/* Keyboard shortcut badge - hidden on small screens */}
70
+ <kbd
71
+ className={cn(
72
+ "hidden sm:flex items-center gap-0.5 px-1.5 py-0.5 rounded",
73
+ "bg-background/50 border border-border/50",
74
+ "text-xs text-muted-foreground font-mono"
75
+ )}
76
+ >
77
+ {isMac ? (
78
+ <>
79
+ <Command className="w-3 h-3" />
80
+ <span>K</span>
81
+ </>
82
+ ) : (
83
+ <span>Ctrl+K</span>
84
+ )}
85
+ </kbd>
86
+ </button>
87
+ </>
88
+ );
89
+ };
@@ -0,0 +1,231 @@
1
+ import React, { useState, useEffect, useCallback, useRef } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ Input,
7
+ DynamicIcon,
8
+ type LucideIconName,
9
+ } from "@checkstack/ui";
10
+ import { useDebouncedSearch, useFormatShortcut } from "../index";
11
+ import type { SearchResult } from "@checkstack/command-common";
12
+ import { Search, ArrowUp, ArrowDown, CornerDownLeft } from "lucide-react";
13
+
14
+ interface SearchDialogProps {
15
+ open: boolean;
16
+ onOpenChange: (open: boolean) => void;
17
+ }
18
+
19
+ export const SearchDialog: React.FC<SearchDialogProps> = ({
20
+ open,
21
+ onOpenChange,
22
+ }) => {
23
+ const navigate = useNavigate();
24
+ const formatShortcut = useFormatShortcut();
25
+ const { results, loading, search, reset } = useDebouncedSearch(300);
26
+
27
+ const [query, setQuery] = useState("");
28
+ const [selectedIndex, setSelectedIndex] = useState(0);
29
+
30
+ const inputRef = useRef<HTMLInputElement>(null);
31
+
32
+ // Trigger search when dialog opens or query changes
33
+ useEffect(() => {
34
+ if (open) {
35
+ search(query);
36
+ // Focus input after dialog opens
37
+ setTimeout(() => inputRef.current?.focus(), 50);
38
+ } else {
39
+ // Reset state when dialog closes
40
+ setQuery("");
41
+ setSelectedIndex(0);
42
+ reset();
43
+ }
44
+ }, [open, query, search, reset]);
45
+
46
+ // Group results by category
47
+ const groupedResults: Record<string, SearchResult[]> = {};
48
+ for (const result of results) {
49
+ const category = result.category;
50
+ if (!groupedResults[category]) {
51
+ groupedResults[category] = [];
52
+ }
53
+ groupedResults[category].push(result);
54
+ }
55
+
56
+ // Flatten for navigation
57
+ const flatResults = Object.values(groupedResults).flat();
58
+
59
+ // Reset selection when results change
60
+ useEffect(() => {
61
+ setSelectedIndex(0);
62
+ }, [results]);
63
+
64
+ const handleSelect = useCallback(
65
+ (result: SearchResult) => {
66
+ onOpenChange(false);
67
+ if (result.route) {
68
+ navigate(result.route);
69
+ }
70
+ },
71
+ [navigate, onOpenChange]
72
+ );
73
+
74
+ // Keyboard navigation
75
+ const handleKeyDown = useCallback(
76
+ (e: React.KeyboardEvent) => {
77
+ switch (e.key) {
78
+ case "ArrowDown": {
79
+ e.preventDefault();
80
+ setSelectedIndex((prev) =>
81
+ Math.min(prev + 1, flatResults.length - 1)
82
+ );
83
+ break;
84
+ }
85
+ case "ArrowUp": {
86
+ e.preventDefault();
87
+ setSelectedIndex((prev) => Math.max(prev - 1, 0));
88
+ break;
89
+ }
90
+ case "Enter": {
91
+ e.preventDefault();
92
+ if (flatResults[selectedIndex]) {
93
+ handleSelect(flatResults[selectedIndex]);
94
+ }
95
+ break;
96
+ }
97
+ case "Escape": {
98
+ e.preventDefault();
99
+ onOpenChange(false);
100
+ break;
101
+ }
102
+ }
103
+ },
104
+ [flatResults, selectedIndex, handleSelect, onOpenChange]
105
+ );
106
+
107
+ // Render a single result item
108
+ const renderResult = (result: SearchResult, globalIndex: number) => {
109
+ const isSelected = globalIndex === selectedIndex;
110
+
111
+ return (
112
+ <button
113
+ key={result.id}
114
+ onClick={() => handleSelect(result)}
115
+ onMouseEnter={() => setSelectedIndex(globalIndex)}
116
+ className={`w-full flex items-center gap-3 px-4 py-2 text-left transition-colors ${
117
+ isSelected
118
+ ? "bg-primary/10 text-foreground"
119
+ : "text-muted-foreground hover:bg-muted/50"
120
+ }`}
121
+ >
122
+ <DynamicIcon
123
+ name={result.iconName as LucideIconName}
124
+ className="w-4 h-4 flex-shrink-0"
125
+ />
126
+ <div className="flex-1 min-w-0">
127
+ <span className="block truncate">{result.title}</span>
128
+ {result.subtitle && (
129
+ <span className="block text-xs text-muted-foreground truncate">
130
+ {result.subtitle}
131
+ </span>
132
+ )}
133
+ </div>
134
+ {/* Show shortcuts for commands */}
135
+ {result.type === "command" &&
136
+ result.shortcuts &&
137
+ result.shortcuts.length > 0 && (
138
+ <div className="flex gap-1">
139
+ {result.shortcuts.slice(0, 1).map((shortcut) => (
140
+ <kbd
141
+ key={shortcut}
142
+ className="px-1.5 py-0.5 text-xs rounded bg-muted border border-border font-mono"
143
+ >
144
+ {formatShortcut(shortcut)}
145
+ </kbd>
146
+ ))}
147
+ </div>
148
+ )}
149
+ {isSelected && (
150
+ <CornerDownLeft className="w-4 h-4 text-muted-foreground flex-shrink-0" />
151
+ )}
152
+ </button>
153
+ );
154
+ };
155
+
156
+ // Track global index for selection
157
+ let globalIndex = 0;
158
+
159
+ return (
160
+ <Dialog open={open} onOpenChange={onOpenChange}>
161
+ <DialogContent
162
+ size="lg"
163
+ className="p-0 gap-0 overflow-hidden"
164
+ onKeyDown={handleKeyDown}
165
+ >
166
+ {/* Search input */}
167
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-border">
168
+ <Search className="w-5 h-5 text-muted-foreground flex-shrink-0" />
169
+ <Input
170
+ ref={inputRef}
171
+ value={query}
172
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
173
+ setQuery(e.target.value)
174
+ }
175
+ placeholder="Search commands and systems..."
176
+ className="border-0 bg-transparent focus-visible:ring-0 px-0 text-base"
177
+ />
178
+ </div>
179
+
180
+ {/* Results */}
181
+ <div className="max-h-[300px] overflow-y-auto py-2">
182
+ {loading ? (
183
+ <div className="px-4 py-8 text-center text-muted-foreground">
184
+ Searching...
185
+ </div>
186
+ ) : flatResults.length === 0 ? (
187
+ <div className="px-4 py-8 text-center text-muted-foreground">
188
+ {query ? "No results found" : "Start typing to search..."}
189
+ </div>
190
+ ) : (
191
+ Object.entries(groupedResults).map(
192
+ ([category, categoryResults]) => (
193
+ <div key={category}>
194
+ {/* Category header */}
195
+ <div className="px-4 py-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider bg-muted/30">
196
+ {category} ({categoryResults.length})
197
+ </div>
198
+ {/* Category results */}
199
+ {categoryResults.map((result) => {
200
+ const element = renderResult(result, globalIndex);
201
+ globalIndex++;
202
+ return element;
203
+ })}
204
+ </div>
205
+ )
206
+ )
207
+ )}
208
+ </div>
209
+
210
+ {/* Footer with keyboard hints */}
211
+ <div className="flex items-center gap-4 px-4 py-2 border-t border-border bg-muted/30 text-xs text-muted-foreground">
212
+ <div className="flex items-center gap-1">
213
+ <ArrowUp className="w-3 h-3" />
214
+ <ArrowDown className="w-3 h-3" />
215
+ <span>Navigate</span>
216
+ </div>
217
+ <div className="flex items-center gap-1">
218
+ <CornerDownLeft className="w-3 h-3" />
219
+ <span>Select</span>
220
+ </div>
221
+ <div className="flex items-center gap-1">
222
+ <kbd className="px-1 rounded bg-muted border border-border font-mono">
223
+ esc
224
+ </kbd>
225
+ <span>Close</span>
226
+ </div>
227
+ </div>
228
+ </DialogContent>
229
+ </Dialog>
230
+ );
231
+ };
package/src/index.tsx ADDED
@@ -0,0 +1,368 @@
1
+ import { useEffect, useCallback, useMemo, useState, useRef } from "react";
2
+ import {
3
+ useApi,
4
+ rpcApiRef,
5
+ createApiRef,
6
+ createFrontendPlugin,
7
+ NavbarCenterSlot,
8
+ } from "@checkstack/frontend-api";
9
+ import {
10
+ CommandApi,
11
+ pluginMetadata,
12
+ type SearchResult,
13
+ } from "@checkstack/command-common";
14
+ import type { InferClient } from "@checkstack/common";
15
+ import { NavbarSearch } from "./components/NavbarSearch";
16
+
17
+ // =============================================================================
18
+ // PLUGIN
19
+ // =============================================================================
20
+
21
+ export const commandPlugin = createFrontendPlugin({
22
+ metadata: pluginMetadata,
23
+ routes: [],
24
+ extensions: [
25
+ {
26
+ id: "command.navbar.search",
27
+ slot: NavbarCenterSlot,
28
+ component: NavbarSearch,
29
+ },
30
+ ],
31
+ });
32
+
33
+ // =============================================================================
34
+ // API REF
35
+ // =============================================================================
36
+
37
+ export type CommandApiClient = InferClient<typeof CommandApi>;
38
+
39
+ export const commandApiRef =
40
+ createApiRef<CommandApiClient>("plugin.command.api");
41
+
42
+ // =============================================================================
43
+ // SHORTCUT UTILITIES (Frontend-only - requires DOM types)
44
+ // =============================================================================
45
+
46
+ interface ParsedShortcut {
47
+ meta: boolean;
48
+ ctrl: boolean;
49
+ alt: boolean;
50
+ shift: boolean;
51
+ key: string;
52
+ }
53
+
54
+ /**
55
+ * Parse a shortcut string like "meta+shift+k" into components.
56
+ */
57
+ function parseShortcut(shortcut: string): ParsedShortcut {
58
+ const parts = shortcut.toLowerCase().split("+");
59
+ const key = parts.pop() ?? "";
60
+ return {
61
+ meta: parts.includes("meta"),
62
+ ctrl: parts.includes("ctrl"),
63
+ alt: parts.includes("alt"),
64
+ shift: parts.includes("shift"),
65
+ key,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Check if a keyboard event matches a parsed shortcut.
71
+ */
72
+ function matchesShortcut(
73
+ event: KeyboardEvent,
74
+ shortcut: ParsedShortcut
75
+ ): boolean {
76
+ return (
77
+ event.metaKey === shortcut.meta &&
78
+ event.ctrlKey === shortcut.ctrl &&
79
+ event.altKey === shortcut.alt &&
80
+ event.shiftKey === shortcut.shift &&
81
+ event.key.toLowerCase() === shortcut.key
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Format a shortcut for display.
87
+ * "meta+i" → "⌘I" on Mac, "Ctrl+I" on Windows
88
+ */
89
+ export function formatShortcut(shortcut: string, isMac: boolean): string {
90
+ const parsed = parseShortcut(shortcut);
91
+ const parts: string[] = [];
92
+
93
+ if (parsed.ctrl) {
94
+ parts.push(isMac ? "⌃" : "Ctrl");
95
+ }
96
+ if (parsed.alt) {
97
+ parts.push(isMac ? "⌥" : "Alt");
98
+ }
99
+ if (parsed.shift) {
100
+ parts.push(isMac ? "⇧" : "Shift");
101
+ }
102
+ if (parsed.meta) {
103
+ parts.push(isMac ? "⌘" : "Win");
104
+ }
105
+ parts.push(parsed.key.toUpperCase());
106
+
107
+ return isMac ? parts.join("") : parts.join("+");
108
+ }
109
+
110
+ // =============================================================================
111
+ // HOOKS
112
+ // =============================================================================
113
+
114
+ /**
115
+ * Hook that registers global keyboard shortcuts for commands.
116
+ * When a shortcut is triggered, it navigates to the command's route.
117
+ * Should be used once at the app root level.
118
+ *
119
+ * @param commands - Array of commands with shortcuts
120
+ * @param navigate - Navigation function to call when a command is triggered
121
+ * @param userPermissions - Array of permission IDs the user has
122
+ */
123
+ export function useGlobalShortcuts(
124
+ commands: SearchResult[],
125
+ navigate: (route: string) => void,
126
+ userPermissions: string[]
127
+ ): void {
128
+ useEffect(() => {
129
+ // Check if user has wildcard permission (admin)
130
+ const hasWildcard = userPermissions.includes("*");
131
+
132
+ const handleKeyDown = (event: KeyboardEvent) => {
133
+ // Don't trigger in input fields
134
+ const target = event.target as HTMLElement;
135
+ if (
136
+ target.tagName === "INPUT" ||
137
+ target.tagName === "TEXTAREA" ||
138
+ target.isContentEditable
139
+ ) {
140
+ return;
141
+ }
142
+
143
+ // Find matching command
144
+ for (const command of commands) {
145
+ if (!command.shortcuts || !command.route) continue;
146
+
147
+ // Check permissions (skip if user has wildcard)
148
+ if (!hasWildcard && command.requiredPermissions?.length) {
149
+ const hasPermission = command.requiredPermissions.every((perm) =>
150
+ userPermissions.includes(perm)
151
+ );
152
+ if (!hasPermission) continue;
153
+ }
154
+
155
+ for (const shortcut of command.shortcuts) {
156
+ const parsed = parseShortcut(shortcut);
157
+ if (matchesShortcut(event, parsed)) {
158
+ event.preventDefault();
159
+ navigate(command.route);
160
+ return;
161
+ }
162
+ }
163
+ }
164
+ };
165
+
166
+ globalThis.addEventListener("keydown", handleKeyDown);
167
+ return () => globalThis.removeEventListener("keydown", handleKeyDown);
168
+ }, [commands, navigate, userPermissions]);
169
+ }
170
+
171
+ /**
172
+ * Hook to format a shortcut string for the current platform.
173
+ */
174
+ export function useFormatShortcut(): (shortcut: string) => string {
175
+ const isMac = useMemo(
176
+ () =>
177
+ typeof navigator !== "undefined" &&
178
+ /Mac|iPhone|iPad/.test(navigator.userAgent),
179
+ []
180
+ );
181
+
182
+ return useCallback(
183
+ (shortcut: string) => formatShortcut(shortcut, isMac),
184
+ [isMac]
185
+ );
186
+ }
187
+
188
+ /**
189
+ * Hook to search across all providers via backend RPC.
190
+ * Returns search function and loading state.
191
+ */
192
+ export function useCommandPaletteSearch(): {
193
+ search: (query: string) => Promise<SearchResult[]>;
194
+ getCommands: () => Promise<SearchResult[]>;
195
+ loading: boolean;
196
+ } {
197
+ const rpcApi = useApi(rpcApiRef);
198
+ const commandApi = useMemo(() => rpcApi.forPlugin(CommandApi), [rpcApi]);
199
+ const [loading, setLoading] = useState(false);
200
+
201
+ const search = useCallback(
202
+ async (query: string): Promise<SearchResult[]> => {
203
+ setLoading(true);
204
+ try {
205
+ return await commandApi.search({ query });
206
+ } finally {
207
+ setLoading(false);
208
+ }
209
+ },
210
+ [commandApi]
211
+ );
212
+
213
+ const getCommands = useCallback(async (): Promise<SearchResult[]> => {
214
+ setLoading(true);
215
+ try {
216
+ return await commandApi.getCommands();
217
+ } finally {
218
+ setLoading(false);
219
+ }
220
+ }, [commandApi]);
221
+
222
+ return { search, getCommands, loading };
223
+ }
224
+
225
+ /**
226
+ * Hook for debounced search in the command palette.
227
+ * Automatically debounces the search query by the specified delay.
228
+ */
229
+ export function useDebouncedSearch(delayMs: number = 300): {
230
+ results: SearchResult[];
231
+ loading: boolean;
232
+ search: (query: string) => void;
233
+ reset: () => void;
234
+ } {
235
+ const { search: doSearch, getCommands } = useCommandPaletteSearch();
236
+ const [results, setResults] = useState<SearchResult[]>([]);
237
+ const [loading, setLoading] = useState(false);
238
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
239
+
240
+ const search = useCallback(
241
+ (query: string) => {
242
+ // Clear any pending search
243
+ if (timeoutRef.current) {
244
+ clearTimeout(timeoutRef.current);
245
+ }
246
+
247
+ // If empty query, fetch all results immediately (commands + entities)
248
+ if (!query.trim()) {
249
+ setLoading(true);
250
+ doSearch("")
251
+ .then(setResults)
252
+ .catch(() => setResults([]))
253
+ .finally(() => setLoading(false));
254
+ return;
255
+ }
256
+
257
+ // Debounce non-empty queries
258
+ setLoading(true);
259
+ timeoutRef.current = setTimeout(() => {
260
+ doSearch(query)
261
+ .then(setResults)
262
+ .catch(() => setResults([]))
263
+ .finally(() => setLoading(false));
264
+ }, delayMs);
265
+ },
266
+ [doSearch, getCommands, delayMs]
267
+ );
268
+
269
+ const reset = useCallback(() => {
270
+ if (timeoutRef.current) {
271
+ clearTimeout(timeoutRef.current);
272
+ }
273
+ setResults([]);
274
+ setLoading(false);
275
+ }, []);
276
+
277
+ // Cleanup on unmount
278
+ useEffect(() => {
279
+ return () => {
280
+ if (timeoutRef.current) {
281
+ clearTimeout(timeoutRef.current);
282
+ }
283
+ };
284
+ }, []);
285
+
286
+ return { results, loading, search, reset };
287
+ }
288
+
289
+ // =============================================================================
290
+ // GLOBAL SHORTCUTS COMPONENT
291
+ // =============================================================================
292
+
293
+ /**
294
+ * Hook to fetch commands with shortcuts from the backend.
295
+ * Returns commands that can be used with useGlobalShortcuts.
296
+ */
297
+ export function useCommands(): {
298
+ commands: SearchResult[];
299
+ loading: boolean;
300
+ } {
301
+ const rpcApi = useApi(rpcApiRef);
302
+ const commandApi = useMemo(() => rpcApi.forPlugin(CommandApi), [rpcApi]);
303
+ const [commands, setCommands] = useState<SearchResult[]>([]);
304
+ const [loading, setLoading] = useState(true);
305
+
306
+ useEffect(() => {
307
+ let cancelled = false;
308
+
309
+ async function fetchCommands() {
310
+ try {
311
+ const results = await commandApi.getCommands();
312
+ if (!cancelled) {
313
+ // Filter to only commands with shortcuts
314
+ setCommands(
315
+ results.filter((r) => r.shortcuts && r.shortcuts.length > 0)
316
+ );
317
+ }
318
+ } catch {
319
+ // Ignore errors - commands just won't be available
320
+ } finally {
321
+ if (!cancelled) {
322
+ setLoading(false);
323
+ }
324
+ }
325
+ }
326
+
327
+ fetchCommands();
328
+
329
+ return () => {
330
+ cancelled = true;
331
+ };
332
+ }, [commandApi]);
333
+
334
+ return { commands, loading };
335
+ }
336
+
337
+ /**
338
+ * Component that registers global keyboard shortcuts for commands.
339
+ * Mount this at the app root level (e.g., in Layout or App).
340
+ *
341
+ * @example
342
+ * ```tsx
343
+ * import { GlobalShortcuts } from "@checkstack/command-frontend";
344
+ *
345
+ * function App() {
346
+ * return (
347
+ * <>
348
+ * <GlobalShortcuts />
349
+ * {/* rest of app *\/}
350
+ * </>
351
+ * );
352
+ * }
353
+ * ```
354
+ */
355
+ export function GlobalShortcuts(): React.ReactNode {
356
+ const { commands } = useCommands();
357
+ const navigate = useCallback((route: string) => {
358
+ // Use window.location for reliable navigation
359
+ globalThis.location.href = route;
360
+ }, []);
361
+
362
+ // For now, pass "*" as permission since the backend already filters by permission
363
+ // The commands returned from getCommands are already filtered
364
+ useGlobalShortcuts(commands, navigate, ["*"]);
365
+
366
+ // This component renders nothing - it only registers event listeners
367
+ return;
368
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/frontend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }