ShortcutsReferenceSheet.tsx246 lines · main
| 1 | // @ts-nocheck |
| 2 | import { useHotkeyRegistrations, type SequenceRegistrationView } from '@tanstack/react-hotkeys' |
| 3 | import { CircleX } from 'lucide-react' |
| 4 | import { Fragment, useMemo, useState } from 'react' |
| 5 | import { |
| 6 | Button, |
| 7 | KeyboardShortcut, |
| 8 | Sheet, |
| 9 | SheetContent, |
| 10 | SheetDescription, |
| 11 | SheetHeader, |
| 12 | SheetSection, |
| 13 | SheetTitle, |
| 14 | } from 'ui' |
| 15 | import { Input } from 'ui-patterns/DataInputs/Input' |
| 16 | |
| 17 | import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut' |
| 18 | import { |
| 19 | SHORTCUT_REFERENCE_GROUP_LABELS, |
| 20 | SHORTCUT_REFERENCE_GROUP_ORDER, |
| 21 | SHORTCUT_REFERENCE_GROUPS, |
| 22 | } from '@/state/shortcuts/referenceGroups' |
| 23 | import type { ShortcutHotkeyMeta } from '@/state/shortcuts/useShortcut' |
| 24 | |
| 25 | interface ShortcutsReferenceSheetProps { |
| 26 | open: boolean |
| 27 | onOpenChange: (open: boolean) => void |
| 28 | } |
| 29 | |
| 30 | interface ActiveShortcutDefinition { |
| 31 | id: string |
| 32 | label: string |
| 33 | sequence: string[] |
| 34 | referenceGroup?: string |
| 35 | } |
| 36 | |
| 37 | interface ShortcutGroup { |
| 38 | group: string |
| 39 | label: string |
| 40 | definitions: ActiveShortcutDefinition[] |
| 41 | } |
| 42 | |
| 43 | const 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 | |
| 69 | const getGroupOrder = (group: string) => { |
| 70 | const index = SHORTCUT_REFERENCE_GROUP_ORDER.indexOf(group) |
| 71 | return index === -1 ? SHORTCUT_REFERENCE_GROUP_ORDER.length : index |
| 72 | } |
| 73 | |
| 74 | const getGroupLabel = (group: string) => GROUP_LABELS[group] ?? group |
| 75 | |
| 76 | const isScopedNavigationGroup = (group: string) => |
| 77 | group.startsWith('navigation.') && group !== SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL |
| 78 | |
| 79 | const normalizeSearchValue = (value: string) => value.trim().toLowerCase() |
| 80 | |
| 81 | const 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 | |
| 94 | const 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 | |
| 114 | const 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 | |
| 143 | const 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 | |
| 166 | const 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 | |
| 177 | function 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 | |
| 238 | export 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 | } |