@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 +56 -0
- package/package.json +30 -0
- package/src/components/NavbarSearch.tsx +89 -0
- package/src/components/SearchDialog.tsx +231 -0
- package/src/index.tsx +368 -0
- package/tsconfig.json +6 -0
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
|
+
}
|