mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 04:15:04 +03:00
## What kind of change does this PR introduce? Feature — adds keyboard shortcuts for the Realtime section in Studio. ## What is the current behavior? No keyboard shortcuts exist for Realtime navigation or inspector actions. ## What is the new behavior? ### Navigation chords (layout-scoped, active on any Realtime page) | Shortcut | Action | |---|---| | `R I` | Go to Inspector | | `R P` | Go to Policies | | `R S` | Go to Settings | ### Inspector actions (active on the Inspector page) | Shortcut | Action | Gating | |---|---|---| | `Shift+J` | Join a channel | Only when no channel is joined | | `Shift+L` | Start/Stop listening | Only when a channel is joined | | `Shift+F` | Open filter popover | Only when a channel is joined | | `Shift+B` | Broadcast a message | Only when listening | | `Mod+Shift+C` | Copy selected message | Only when a message is selected | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Realtime Inspector: new keyboard shortcuts (join channel, toggle listening, toggle filters, broadcast) and a copy-message shortcut with toast feedback. * Channel & Filter popovers support controlled/uncontrolled open state; header now wires popover state through props. * Shortcut tooltips added to copy and broadcast actions; realtime page navigation shortcuts and menu shortcuts added. * **Tests** * Shortcut reference sheet tests updated to include realtime navigation and inspector groups. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46001?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
246 lines
7.5 KiB
TypeScript
246 lines
7.5 KiB
TypeScript
import { useHotkeyRegistrations, type SequenceRegistrationView } from '@tanstack/react-hotkeys'
|
|
import { CircleX } from 'lucide-react'
|
|
import { Fragment, useMemo, useState } from 'react'
|
|
import {
|
|
Button,
|
|
KeyboardShortcut,
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetHeader,
|
|
SheetSection,
|
|
SheetTitle,
|
|
} from 'ui'
|
|
import { Input } from 'ui-patterns/DataInputs/Input'
|
|
|
|
import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut'
|
|
import {
|
|
SHORTCUT_REFERENCE_GROUP_LABELS,
|
|
SHORTCUT_REFERENCE_GROUP_ORDER,
|
|
SHORTCUT_REFERENCE_GROUPS,
|
|
} from '@/state/shortcuts/referenceGroups'
|
|
import type { ShortcutHotkeyMeta } from '@/state/shortcuts/useShortcut'
|
|
|
|
interface ShortcutsReferenceSheetProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
}
|
|
|
|
interface ActiveShortcutDefinition {
|
|
id: string
|
|
label: string
|
|
sequence: string[]
|
|
referenceGroup?: string
|
|
}
|
|
|
|
interface ShortcutGroup {
|
|
group: string
|
|
label: string
|
|
definitions: ActiveShortcutDefinition[]
|
|
}
|
|
|
|
const GROUP_LABELS: Record<string, string> = {
|
|
...SHORTCUT_REFERENCE_GROUP_LABELS,
|
|
'action-bar': 'Actions',
|
|
'ai-assistant': 'AI Assistant',
|
|
'auth-users': 'Auth Users',
|
|
'command-menu': 'Command Menu',
|
|
'data-table': 'Data Tables',
|
|
'functions-detail': 'Edge Function Actions',
|
|
'functions-list': 'Edge Functions',
|
|
'functions-overview': 'Edge Function Overview',
|
|
'inline-editor': 'Inline Editor',
|
|
'list-page': 'List pages',
|
|
'logs-preview': 'Logs Explorer',
|
|
nav: 'Navigation',
|
|
'operation-queue': 'Operation Queue',
|
|
results: 'Results',
|
|
'realtime-inspector': 'Realtime Inspector',
|
|
'schema-visualizer': 'Schema Visualizer',
|
|
shortcuts: 'Shortcuts',
|
|
'sql-editor': 'SQL Editor',
|
|
'storage-buckets': 'Storage Buckets',
|
|
'storage-explorer': 'Storage File Explorer',
|
|
'table-editor': 'Table Editor',
|
|
'unified-logs': 'Logs',
|
|
}
|
|
|
|
const getGroupOrder = (group: string) => {
|
|
const index = SHORTCUT_REFERENCE_GROUP_ORDER.indexOf(group)
|
|
return index === -1 ? SHORTCUT_REFERENCE_GROUP_ORDER.length : index
|
|
}
|
|
|
|
const getGroupLabel = (group: string) => GROUP_LABELS[group] ?? group
|
|
|
|
const isScopedNavigationGroup = (group: string) =>
|
|
group.startsWith('navigation.') && group !== SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL
|
|
|
|
const normalizeSearchValue = (value: string) => value.trim().toLowerCase()
|
|
|
|
const toActiveDefinition = (
|
|
registration: SequenceRegistrationView
|
|
): ActiveShortcutDefinition | null => {
|
|
const meta = registration.options.meta as ShortcutHotkeyMeta | undefined
|
|
if (!meta?.id || !meta.name) return null
|
|
return {
|
|
id: meta.id,
|
|
label: meta.name,
|
|
sequence: registration.sequence,
|
|
referenceGroup: meta.referenceGroup,
|
|
}
|
|
}
|
|
|
|
const useActiveShortcuts = (): ActiveShortcutDefinition[] => {
|
|
const { sequences } = useHotkeyRegistrations()
|
|
|
|
return useMemo(() => {
|
|
const definitions: ActiveShortcutDefinition[] = []
|
|
const seen = new Set<string>()
|
|
|
|
for (const registration of sequences) {
|
|
if (registration.options.enabled === false) continue
|
|
const definition = toActiveDefinition(registration)
|
|
if (!definition) continue
|
|
if (seen.has(definition.id)) continue
|
|
seen.add(definition.id)
|
|
definitions.push(definition)
|
|
}
|
|
|
|
return definitions
|
|
}, [sequences])
|
|
}
|
|
|
|
const groupDefinitions = (activeShortcuts: ActiveShortcutDefinition[]): ShortcutGroup[] => {
|
|
const grouped = activeShortcuts.reduce<Record<string, ActiveShortcutDefinition[]>>(
|
|
(acc, definition) => {
|
|
const prefix = definition.referenceGroup ?? definition.id.split('.')[0]
|
|
acc[prefix] = acc[prefix] ?? []
|
|
acc[prefix].push(definition)
|
|
return acc
|
|
},
|
|
{}
|
|
)
|
|
|
|
const hasScopedNavigationGroup = Object.keys(grouped).some(isScopedNavigationGroup)
|
|
|
|
return Object.entries(grouped)
|
|
.map(([group, definitions]) => {
|
|
const label =
|
|
group === SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL && !hasScopedNavigationGroup
|
|
? 'Navigation'
|
|
: getGroupLabel(group)
|
|
|
|
return {
|
|
group,
|
|
label,
|
|
definitions,
|
|
}
|
|
})
|
|
.sort((a, b) => getGroupOrder(a.group) - getGroupOrder(b.group))
|
|
}
|
|
|
|
const filterGroups = (groups: ShortcutGroup[], search: string) => {
|
|
const normalizedSearch = normalizeSearchValue(search)
|
|
|
|
if (normalizedSearch.length === 0) return groups
|
|
|
|
return groups.reduce<ShortcutGroup[]>((acc, group) => {
|
|
if (normalizeSearchValue(group.label).includes(normalizedSearch)) {
|
|
acc.push(group)
|
|
return acc
|
|
}
|
|
|
|
const definitions = group.definitions.filter((definition) =>
|
|
normalizeSearchValue(definition.label).includes(normalizedSearch)
|
|
)
|
|
|
|
if (definitions.length > 0) {
|
|
acc.push({ ...group, definitions })
|
|
}
|
|
|
|
return acc
|
|
}, [])
|
|
}
|
|
|
|
const ShortcutSequence = ({ sequence }: Pick<ActiveShortcutDefinition, 'sequence'>) => (
|
|
<div className="flex items-center gap-1">
|
|
{sequence.map((step, index) => (
|
|
<Fragment key={`${step}-${index}`}>
|
|
{index > 0 && <span className="text-foreground-lighter text-[11px]">then</span>}
|
|
<KeyboardShortcut keys={hotkeyToKeys(step)} variant="pill" />
|
|
</Fragment>
|
|
))}
|
|
</div>
|
|
)
|
|
|
|
function ShortcutsReferenceSheetContent() {
|
|
const [search, setSearch] = useState('')
|
|
const activeShortcuts = useActiveShortcuts()
|
|
const groups = filterGroups(groupDefinitions(activeShortcuts), search)
|
|
|
|
return (
|
|
<>
|
|
<SheetHeader className="shrink-0 py-3">
|
|
<SheetTitle>Keyboard shortcuts</SheetTitle>
|
|
<SheetDescription className="sr-only">
|
|
Browse and search available keyboard shortcuts.
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
<div className="shrink-0 bg-studio px-5 pt-4 pb-4">
|
|
<Input
|
|
aria-label="Search shortcuts"
|
|
autoFocus
|
|
className="w-full"
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
placeholder="Search shortcuts..."
|
|
value={search}
|
|
actions={
|
|
search ? (
|
|
<Button
|
|
aria-label="Clear search"
|
|
size="tiny"
|
|
type="text"
|
|
icon={<CircleX size={14} />}
|
|
onClick={() => setSearch('')}
|
|
className="h-5 w-5 p-0"
|
|
/>
|
|
) : null
|
|
}
|
|
/>
|
|
</div>
|
|
<SheetSection className="flex flex-1 flex-col gap-6 overflow-y-auto px-5 py-4">
|
|
{groups.length === 0 ? (
|
|
<p className="text-sm text-foreground-muted">No matching shortcuts found</p>
|
|
) : (
|
|
groups.map(({ group, label, definitions }) => (
|
|
<section key={group} className="flex flex-col gap-2">
|
|
<h3 className="text-xs text-foreground-lighter uppercase tracking-wider">{label}</h3>
|
|
<ul className="flex flex-col">
|
|
{definitions.map((definition) => (
|
|
<li
|
|
key={definition.id}
|
|
className="flex min-h-10 items-center justify-between gap-4 border-b border-muted py-2 last:border-b-0"
|
|
>
|
|
<span className="text-sm text-foreground">{definition.label}</span>
|
|
<ShortcutSequence sequence={definition.sequence} />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
))
|
|
)}
|
|
</SheetSection>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function ShortcutsReferenceSheet({ open, onOpenChange }: ShortcutsReferenceSheetProps) {
|
|
return (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-[520px]">
|
|
{open && <ShortcutsReferenceSheetContent />}
|
|
</SheetContent>
|
|
</Sheet>
|
|
)
|
|
}
|