ShortcutsReferenceSheet.tsx246 lines · main
1// @ts-nocheck
2import { useHotkeyRegistrations, type SequenceRegistrationView } from '@tanstack/react-hotkeys'
3import { CircleX } from 'lucide-react'
4import { Fragment, useMemo, useState } from 'react'
5import {
6 Button,
7 KeyboardShortcut,
8 Sheet,
9 SheetContent,
10 SheetDescription,
11 SheetHeader,
12 SheetSection,
13 SheetTitle,
14} from 'ui'
15import { Input } from 'ui-patterns/DataInputs/Input'
16
17import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut'
18import {
19 SHORTCUT_REFERENCE_GROUP_LABELS,
20 SHORTCUT_REFERENCE_GROUP_ORDER,
21 SHORTCUT_REFERENCE_GROUPS,
22} from '@/state/shortcuts/referenceGroups'
23import type { ShortcutHotkeyMeta } from '@/state/shortcuts/useShortcut'
24
25interface ShortcutsReferenceSheetProps {
26 open: boolean
27 onOpenChange: (open: boolean) => void
28}
29
30interface ActiveShortcutDefinition {
31 id: string
32 label: string
33 sequence: string[]
34 referenceGroup?: string
35}
36
37interface ShortcutGroup {
38 group: string
39 label: string
40 definitions: ActiveShortcutDefinition[]
41}
42
43const GROUP_LABELS: Record<string, string> = {
44 ...SHORTCUT_REFERENCE_GROUP_LABELS,
45 'action-bar': 'Actions',
46 'ai-assistant': 'AI Assistant',
47 'auth-users': 'Auth Users',
48 'command-menu': 'Command Menu',
49 'data-table': 'Data Tables',
50 'functions-detail': 'Edge Function Actions',
51 'functions-list': 'Edge Functions',
52 'functions-overview': 'Edge Function Overview',
53 'inline-editor': 'Inline Editor',
54 'list-page': 'List pages',
55 'logs-preview': 'Logs Explorer',
56 nav: 'Navigation',
57 'operation-queue': 'Operation Queue',
58 results: 'Results',
59 'realtime-inspector': 'Realtime Inspector',
60 'schema-visualizer': 'Schema Visualizer',
61 shortcuts: 'Shortcuts',
62 'sql-editor': 'SQL Editor',
63 'storage-buckets': 'Storage Buckets',
64 'storage-explorer': 'Storage File Explorer',
65 'table-editor': 'Table Editor',
66 'unified-logs': 'Logs',
67}
68
69const getGroupOrder = (group: string) => {
70 const index = SHORTCUT_REFERENCE_GROUP_ORDER.indexOf(group)
71 return index === -1 ? SHORTCUT_REFERENCE_GROUP_ORDER.length : index
72}
73
74const getGroupLabel = (group: string) => GROUP_LABELS[group] ?? group
75
76const isScopedNavigationGroup = (group: string) =>
77 group.startsWith('navigation.') && group !== SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL
78
79const normalizeSearchValue = (value: string) => value.trim().toLowerCase()
80
81const toActiveDefinition = (
82 registration: SequenceRegistrationView
83): ActiveShortcutDefinition | null => {
84 const meta = registration.options.meta as ShortcutHotkeyMeta | undefined
85 if (!meta?.id || !meta.name) return null
86 return {
87 id: meta.id,
88 label: meta.name,
89 sequence: registration.sequence,
90 referenceGroup: meta.referenceGroup,
91 }
92}
93
94const useActiveShortcuts = (): ActiveShortcutDefinition[] => {
95 const { sequences } = useHotkeyRegistrations()
96
97 return useMemo(() => {
98 const definitions: ActiveShortcutDefinition[] = []
99 const seen = new Set<string>()
100
101 for (const registration of sequences) {
102 if (registration.options.enabled === false) continue
103 const definition = toActiveDefinition(registration)
104 if (!definition) continue
105 if (seen.has(definition.id)) continue
106 seen.add(definition.id)
107 definitions.push(definition)
108 }
109
110 return definitions
111 }, [sequences])
112}
113
114const groupDefinitions = (activeShortcuts: ActiveShortcutDefinition[]): ShortcutGroup[] => {
115 const grouped = activeShortcuts.reduce<Record<string, ActiveShortcutDefinition[]>>(
116 (acc, definition) => {
117 const prefix = definition.referenceGroup ?? definition.id.split('.')[0]
118 acc[prefix] = acc[prefix] ?? []
119 acc[prefix].push(definition)
120 return acc
121 },
122 {}
123 )
124
125 const hasScopedNavigationGroup = Object.keys(grouped).some(isScopedNavigationGroup)
126
127 return Object.entries(grouped)
128 .map(([group, definitions]) => {
129 const label =
130 group === SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL && !hasScopedNavigationGroup
131 ? 'Navigation'
132 : getGroupLabel(group)
133
134 return {
135 group,
136 label,
137 definitions,
138 }
139 })
140 .sort((a, b) => getGroupOrder(a.group) - getGroupOrder(b.group))
141}
142
143const filterGroups = (groups: ShortcutGroup[], search: string) => {
144 const normalizedSearch = normalizeSearchValue(search)
145
146 if (normalizedSearch.length === 0) return groups
147
148 return groups.reduce<ShortcutGroup[]>((acc, group) => {
149 if (normalizeSearchValue(group.label).includes(normalizedSearch)) {
150 acc.push(group)
151 return acc
152 }
153
154 const definitions = group.definitions.filter((definition) =>
155 normalizeSearchValue(definition.label).includes(normalizedSearch)
156 )
157
158 if (definitions.length > 0) {
159 acc.push({ ...group, definitions })
160 }
161
162 return acc
163 }, [])
164}
165
166const ShortcutSequence = ({ sequence }: Pick<ActiveShortcutDefinition, 'sequence'>) => (
167 <div className="flex items-center gap-1">
168 {sequence.map((step, index) => (
169 <Fragment key={`${step}-${index}`}>
170 {index > 0 && <span className="text-foreground-lighter text-[11px]">then</span>}
171 <KeyboardShortcut keys={hotkeyToKeys(step)} variant="pill" />
172 </Fragment>
173 ))}
174 </div>
175)
176
177function ShortcutsReferenceSheetContent() {
178 const [search, setSearch] = useState('')
179 const activeShortcuts = useActiveShortcuts()
180 const groups = filterGroups(groupDefinitions(activeShortcuts), search)
181
182 return (
183 <>
184 <SheetHeader className="shrink-0 py-3">
185 <SheetTitle>Keyboard shortcuts</SheetTitle>
186 <SheetDescription className="sr-only">
187 Browse and search available keyboard shortcuts.
188 </SheetDescription>
189 </SheetHeader>
190 <div className="shrink-0 bg-studio px-5 pt-4 pb-4">
191 <Input
192 aria-label="Search shortcuts"
193 autoFocus
194 className="w-full"
195 onChange={(event) => setSearch(event.target.value)}
196 placeholder="Search shortcuts..."
197 value={search}
198 actions={
199 search ? (
200 <Button
201 aria-label="Clear search"
202 size="tiny"
203 type="text"
204 icon={<CircleX size={14} />}
205 onClick={() => setSearch('')}
206 className="h-5 w-5 p-0"
207 />
208 ) : null
209 }
210 />
211 </div>
212 <SheetSection className="flex flex-1 flex-col gap-6 overflow-y-auto px-5 py-4">
213 {groups.length === 0 ? (
214 <p className="text-sm text-foreground-muted">No matching shortcuts found</p>
215 ) : (
216 groups.map(({ group, label, definitions }) => (
217 <section key={group} className="flex flex-col gap-2">
218 <h3 className="text-xs text-foreground-lighter uppercase tracking-wider">{label}</h3>
219 <ul className="flex flex-col">
220 {definitions.map((definition) => (
221 <li
222 key={definition.id}
223 className="flex min-h-10 items-center justify-between gap-4 border-b border-muted py-2 last:border-b-0"
224 >
225 <span className="text-sm text-foreground">{definition.label}</span>
226 <ShortcutSequence sequence={definition.sequence} />
227 </li>
228 ))}
229 </ul>
230 </section>
231 ))
232 )}
233 </SheetSection>
234 </>
235 )
236}
237
238export function ShortcutsReferenceSheet({ open, onOpenChange }: ShortcutsReferenceSheetProps) {
239 return (
240 <Sheet open={open} onOpenChange={onOpenChange}>
241 <SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-[520px]">
242 {open && <ShortcutsReferenceSheetContent />}
243 </SheetContent>
244 </Sheet>
245 )
246}