mirror of
https://github.com/supabase/supabase.git
synced 2026-10-18 07:39:07 +03:00
Closes [FE-3245](https://linear.app/supabase/issue/FE-3245/add-keyboard-shortcuts-to-edge-functions-pages). Adds keyboard shortcuts across the Edge Functions surface, mirroring the patterns already in place for Database / Auth / Storage. ## Summary Three layers of new shortcuts, plus one quality-of-life fix on the existing search input: ### 1. Edge Functions list page (`/project/:ref/functions`) | Key | Action | |---|---| | `Shift+F` | Focus the search input | | `Shift+N` | Route to `/functions/new` (deploy a new function) | | `F` then `C` | Clear search filter | | `Shift+R` | Refresh the functions list (new toolbar button) | | `S` then `C` | Reset sort to `name:asc` | | `Esc` (in search) | Clears value, then blurs on a second press (`onSearchInputEscape`) | ### 2. Edge Functions section nav (active anywhere under `/functions/*`) | Key | Action | |---|---| | `F` then `O` | Functions overview | | `F` then `K` | Secrets | Wired through `EdgeFunctionsProductMenu` items via `shortcutId`, registered by `<ProductMenuShortcuts />` mounted in `EdgeFunctionsLayout`. ### 3. Per-function detail (active anywhere under `/functions/:slug/*`) | Key | Action | |---|---| | `1` | Overview | | `2` | Invocations | | `3` | Logs | | `4` | Code | | `5` | Settings | | `Shift+T` | Open the Test sheet | | `Shift+D` | Toggle the Download popover | | `Shift+C` | Copy the function URL (with toast) | ### 4. Test sheet (active when `EdgeFunctionTesterSheet` is open) | Key | Action | |---|---| | `Mod+Enter` | Send Request — first binding for this; mirrors `SQL_EDITOR_RUN` semantics | ### 5. New per-function Overview (`edgeFunctionsOverview` flag) | Key | Action | |---|---| | `I` then `M` | 15 min | | `I` then `H` | 1 hour | | `I` then `T` | 3 hours | | `I` then `D` | 1 day | | `Shift+R` | Refresh combined stats query | | `O` then `L` | Open Logs (or Invocations if unified-logs preview is off) | `ShortcutTooltip` added to the most prominent buttons (search, refresh, copy URL, download, test, send request). Interval/refresh/open-logs on the overview are registered without inline tooltips but remain discoverable via `Cmd+K` and the shortcut reference sheet (`Mod+/`). ## Implementation notes - New reference group `NAVIGATION_FUNCTION_DETAIL` ("Function Page Navigation") added to keep the reference sheet grouped sensibly. - Three new registry files: `functions-list.ts`, `functions-nav.ts`, `functions-detail.ts`, `functions-detail-nav.ts`, `functions-overview.ts`. - Three new hooks: `useFunctionsListShortcuts`, `useFunctionsDetailShortcuts`, `useEdgeFunctionOverviewShortcuts`. - `EdgeFunctionsLayout` refactored to share a single `useGenerateEdgeFunctionsMenu` hook between `<ProductMenu>` and `<ProductMenuShortcuts>` (matches the AuthLayout / DatabaseLayout pattern). - Download popover hoisted to controlled state so `Shift+D` can toggle it. ## Test plan ### Functions list page - [x] On `/project/:ref/functions`, press `Shift+F` — search input gains focus and value is selected - [x] Type in the search → press `Esc` → value clears (focus retained). Press `Esc` again → blurs - [x] Press `Shift+N` → routes to `/functions/new` - [x] With a non-default sort, press `S` then `C` → sort resets to `name:asc`. Confirm shortcut is disabled when already at default - [x] Press `Shift+R` → list refetches; loading indicator appears on the new Refresh button - [x] Press `F` then `C` → search clears ### Section nav (anywhere under `/functions/*`) - [x] From any page under `/functions/*`, press `F` then `O` → navigates to Functions list - [x] Press `F` then `K` → navigates to Secrets - [x] Verify the chord doesn't fire while typing in an input ### Per-function detail (any sub-page) - [x] On any function detail tab, press `1`/`2`/`3`/`4`/`5` → navigates to Overview / Invocations / Logs / Code / Settings respectively (digits 2 and 3 only on platform builds) - [x] Press `Shift+T` → Test sheet opens. Press escape to close - [x] Press `Shift+D` → Download popover opens; press escape to close - [x] Press `Shift+C` → URL copied + toast appears - [x] Hover the URL copy button, Download button, Test button — `ShortcutTooltip` shows the chord ### Test sheet - [x] Open the Test sheet (button or `Shift+T`) - [x] Without focusing anything, press `Mod+Enter` → request fires - [x] With focus inside the body editor / a header input, press `Mod+Enter` → request still fires (`Mod+`-keys bypass input guard) - [x] While `isPending`, `Mod+Enter` is a no-op (shortcut disabled) - [x] Hover Send Request → tooltip shows `Mod+Enter` ### New overview (with `edgeFunctionsOverview` flag enabled) - [x] Press `I` then `M` / `H` / `T` / `D` → interval segmented buttons highlight accordingly and chart re-fetches - [x] Press `Shift+R` → stats refetch - [x] Press `O` then `L` → routes to logs (or invocations when unified-logs preview is off) ### Regression checks - [x] `Cmd+/` opens the reference sheet and the new "Edge Functions Navigation" and "Function Page Navigation" groups render - [x] `Cmd+K` command palette includes the new shortcut entries under "Shortcuts" - [x] On the list page, the existing X button on the search still clears value - [x] Esc handler does not interfere with closing modals/popovers elsewhere on the page <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added comprehensive keyboard shortcuts for Edge Functions (navigation, tab switching, chart intervals, create/refresh, test/send request, download, copy URL) with visible shortcut hints on relevant buttons and inputs. * **Refactor** * Layouts and product menu updated to surface and wire these shortcuts across the UI. * **Tests** * Shortcut reference tests updated to include Edge Functions groups and entries. * **Documentation** * Shortcut reference sheet labels updated to include Edge Functions sections. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45947) <!-- 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>
448 lines
16 KiB
TypeScript
448 lines
16 KiB
TypeScript
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
|
import { BlobReader, BlobWriter, ZipWriter } from '@zip.js/zip.js'
|
|
import { IS_PLATFORM, useParams } from 'common'
|
|
import dayjs from 'dayjs'
|
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
|
import { Clock, Download, FileArchive, Send } from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import { useRouter } from 'next/router'
|
|
import React, { useEffect, useState, type PropsWithChildren } from 'react'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
BreadcrumbItem_Shadcn_ as BreadcrumbItem,
|
|
BreadcrumbLink_Shadcn_ as BreadcrumbLink,
|
|
BreadcrumbList_Shadcn_ as BreadcrumbList,
|
|
BreadcrumbSeparator_Shadcn_ as BreadcrumbSeparator,
|
|
Button,
|
|
copyToClipboard,
|
|
HoverCard,
|
|
HoverCardContent,
|
|
HoverCardTrigger,
|
|
NavMenu,
|
|
NavMenuItem,
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
Separator,
|
|
} from 'ui'
|
|
import { TimestampInfo } from 'ui-patterns'
|
|
import { Input } from 'ui-patterns/DataInputs/Input'
|
|
import {
|
|
PageHeader,
|
|
PageHeaderAside,
|
|
PageHeaderBreadcrumb,
|
|
PageHeaderDescription,
|
|
PageHeaderMeta,
|
|
PageHeaderNavigationTabs,
|
|
PageHeaderSummary,
|
|
PageHeaderTitle,
|
|
} from 'ui-patterns/PageHeader'
|
|
|
|
import { ProjectLayout } from '../ProjectLayout'
|
|
import EdgeFunctionsLayout from './EdgeFunctionsLayout'
|
|
import { EdgeFunctionTesterSheet } from '@/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet'
|
|
import { useFunctionsDetailShortcuts } from '@/components/interfaces/Functions/useFunctionsDetailShortcuts'
|
|
import CopyButton from '@/components/ui/CopyButton'
|
|
import { DocsButton } from '@/components/ui/DocsButton'
|
|
import NoPermission from '@/components/ui/NoPermission'
|
|
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
|
|
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
|
|
import { useEdgeFunctionBodyQuery } from '@/data/edge-functions/edge-function-body-query'
|
|
import { useEdgeFunctionQuery } from '@/data/edge-functions/edge-function-query'
|
|
import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
|
|
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
|
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
|
import { withAuth } from '@/hooks/misc/withAuth'
|
|
import { DOCS_URL } from '@/lib/constants'
|
|
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
|
|
|
dayjs.extend(relativeTime)
|
|
|
|
interface EdgeFunctionDetailsLayoutProps {
|
|
title: string
|
|
}
|
|
|
|
const EdgeFunctionDetailsLayout = ({
|
|
title,
|
|
children,
|
|
}: PropsWithChildren<EdgeFunctionDetailsLayoutProps>) => {
|
|
const router = useRouter()
|
|
const { data: org } = useSelectedOrganizationQuery()
|
|
const { functionSlug, ref } = useParams()
|
|
const { mutate: sendEvent } = useSendEventMutation()
|
|
|
|
const { isLoading, can: canReadFunctions } = useAsyncCheckPermissions(
|
|
PermissionAction.FUNCTIONS_READ,
|
|
'*'
|
|
)
|
|
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const [isDownloadOpen, setIsDownloadOpen] = useState(false)
|
|
const [isTimestampHoverCardOpen, setIsTimestampHoverCardOpen] = useState(false)
|
|
|
|
const {
|
|
data: selectedFunction,
|
|
error,
|
|
isError,
|
|
} = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug })
|
|
const { data: endpoint } = useProjectApiUrl({ projectRef: ref })
|
|
|
|
const { data: functionBody = { version: 0, files: [] }, error: filesError } =
|
|
useEdgeFunctionBodyQuery(
|
|
{
|
|
projectRef: ref,
|
|
slug: functionSlug,
|
|
},
|
|
{
|
|
retry: false,
|
|
retryOnMount: true,
|
|
refetchOnWindowFocus: false,
|
|
staleTime: Infinity,
|
|
refetchOnMount: false,
|
|
refetchOnReconnect: false,
|
|
refetchInterval: false,
|
|
refetchIntervalInBackground: false,
|
|
}
|
|
)
|
|
|
|
const name = selectedFunction?.name || ''
|
|
const functionUrl =
|
|
endpoint && selectedFunction?.slug ? `${endpoint}/functions/v1/${selectedFunction.slug}` : ''
|
|
const createdRelative = selectedFunction?.created_at
|
|
? dayjs(selectedFunction.created_at).fromNow()
|
|
: undefined
|
|
const updatedRelative = selectedFunction?.updated_at
|
|
? dayjs(selectedFunction.updated_at).fromNow()
|
|
: undefined
|
|
const browserTitle = {
|
|
entity: functionSlug ? name || functionSlug : undefined,
|
|
section: title,
|
|
}
|
|
|
|
const breadcrumbItems = [
|
|
{
|
|
label: 'Edge Functions',
|
|
href: `/project/${ref}/functions`,
|
|
},
|
|
{
|
|
label: functionSlug,
|
|
href: `/project/${ref}/functions/${functionSlug}`,
|
|
},
|
|
]
|
|
|
|
const navigationItems = functionSlug
|
|
? [
|
|
...(IS_PLATFORM
|
|
? [
|
|
{
|
|
label: 'Overview',
|
|
href: `/project/${ref}/functions/${functionSlug}`,
|
|
},
|
|
{
|
|
label: 'Invocations',
|
|
href: `/project/${ref}/functions/${functionSlug}/invocations`,
|
|
},
|
|
{
|
|
label: 'Logs',
|
|
href: `/project/${ref}/functions/${functionSlug}/logs`,
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
label: 'Code',
|
|
href: `/project/${ref}/functions/${functionSlug}/code`,
|
|
},
|
|
{
|
|
label: 'Settings',
|
|
href: `/project/${ref}/functions/${functionSlug}/details`,
|
|
},
|
|
]
|
|
: []
|
|
|
|
const downloadFunction = async () => {
|
|
if (filesError) return toast.error('Failed to retrieve edge function files')
|
|
|
|
const zipFileWriter = new BlobWriter('application/zip')
|
|
const zipWriter = new ZipWriter(zipFileWriter, { bufferedWrite: true })
|
|
|
|
// Extract file paths relative to function slug
|
|
const filePaths = functionBody.files.map((file) => {
|
|
const nameSections = file.name.split('/')
|
|
const slugIndex = nameSections.indexOf(functionSlug ?? '')
|
|
return nameSections.slice(slugIndex + 1).join('/')
|
|
})
|
|
|
|
// Find the deepest relative path (count leading ../ segments)
|
|
let maxDepth = 0
|
|
filePaths.forEach((path) => {
|
|
const segments = path.split('/')
|
|
let depth = 0
|
|
for (const segment of segments) {
|
|
if (segment === '..') {
|
|
depth++
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
maxDepth = Math.max(maxDepth, depth)
|
|
})
|
|
|
|
// Add files to zip with normalized paths
|
|
functionBody.files.forEach((file) => {
|
|
const nameSections = file.name.split('/')
|
|
const slugIndex = nameSections.indexOf(functionSlug ?? '')
|
|
const fileName = nameSections.slice(slugIndex + 1).join('/')
|
|
|
|
// Count and remove leading ../ segments
|
|
const segments = fileName.split('/')
|
|
let parentDirCount = 0
|
|
while (segments.length > 0 && segments[0] === '..') {
|
|
segments.shift()
|
|
parentDirCount++
|
|
}
|
|
|
|
// Calculate safe path:
|
|
// - Files without ../ go into the full base path
|
|
// - Files with ../ go into a shallower path based on how many levels up they go
|
|
const depthFromBase = maxDepth - parentDirCount
|
|
const safePath =
|
|
depthFromBase > 0
|
|
? Array.from({ length: depthFromBase }, (_, i) => (i === 0 ? 'src' : `src${i}`)).join(
|
|
'/'
|
|
) +
|
|
'/' +
|
|
segments.join('/')
|
|
: segments.join('/')
|
|
|
|
const fileBlob = new Blob([file.content])
|
|
zipWriter.add(safePath, new BlobReader(fileBlob))
|
|
})
|
|
|
|
const blobURL = URL.createObjectURL(await zipWriter.close())
|
|
const link = document.createElement('a')
|
|
link.href = blobURL
|
|
link.setAttribute('download', `${functionSlug}.zip`)
|
|
document.body.appendChild(link)
|
|
link.click()
|
|
link.parentNode?.removeChild(link)
|
|
}
|
|
|
|
useEffect(() => {
|
|
let cancel = false
|
|
|
|
if (!!functionSlug && isError && error.code === 404 && !cancel) {
|
|
toast('Edge function cannot be found in your project')
|
|
router.push(`/project/${ref}/functions`)
|
|
}
|
|
|
|
return () => {
|
|
cancel = true
|
|
}
|
|
}, [isError])
|
|
|
|
const openTestSheet = () => {
|
|
if (!functionSlug) return
|
|
setIsOpen(true)
|
|
if (IS_PLATFORM) {
|
|
sendEvent({
|
|
action: 'edge_function_test_side_panel_opened',
|
|
groups: {
|
|
project: ref ?? 'Unknown',
|
|
organization: org?.slug ?? 'Unknown',
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
const copyFunctionUrl = () => {
|
|
if (!functionUrl) return
|
|
copyToClipboard(functionUrl)
|
|
toast.success('Function URL copied to clipboard')
|
|
}
|
|
|
|
useFunctionsDetailShortcuts({
|
|
projectRef: ref,
|
|
functionSlug,
|
|
isPlatform: IS_PLATFORM,
|
|
onOpenTest: openTestSheet,
|
|
onOpenDownload: () => setIsDownloadOpen((prev) => !prev),
|
|
onCopyUrl: copyFunctionUrl,
|
|
})
|
|
|
|
if (!isLoading && !canReadFunctions) {
|
|
return (
|
|
<ProjectLayout product="Edge Functions" browserTitle={browserTitle}>
|
|
<NoPermission isFullPage resourceText="access your project's edge functions" />
|
|
</ProjectLayout>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<EdgeFunctionsLayout title={title} browserTitle={browserTitle}>
|
|
<div className="w-full min-h-full flex flex-col items-stretch">
|
|
<PageHeader size="full" className="sticky top-0 z-10 bg-surface-75">
|
|
{breadcrumbItems.length > 0 && (
|
|
<PageHeaderBreadcrumb>
|
|
<BreadcrumbList>
|
|
{breadcrumbItems.map((item, index) => (
|
|
<React.Fragment key={item.label || `breadcrumb-${index}`}>
|
|
<BreadcrumbItem>
|
|
{item.href ? (
|
|
<BreadcrumbLink asChild>
|
|
<Link href={item.href}>{item.label}</Link>
|
|
</BreadcrumbLink>
|
|
) : (
|
|
<span>{item.label}</span>
|
|
)}
|
|
</BreadcrumbItem>
|
|
{index < breadcrumbItems.length - 1 && <BreadcrumbSeparator />}
|
|
</React.Fragment>
|
|
))}
|
|
</BreadcrumbList>
|
|
</PageHeaderBreadcrumb>
|
|
)}
|
|
|
|
<PageHeaderMeta>
|
|
<PageHeaderSummary>
|
|
<PageHeaderTitle>{functionSlug ? name : 'Edge Functions'}</PageHeaderTitle>
|
|
<PageHeaderDescription className="flex flex-row flex-wrap items-center gap-x-4 gap-y-1 text-sm!">
|
|
<div className="flex items-center gap-x-2">
|
|
<span className="flex items-center gap-2">{functionUrl}</span>
|
|
<ShortcutTooltip shortcutId={SHORTCUT_IDS.FUNCTION_DETAIL_COPY_URL} side="bottom">
|
|
<CopyButton iconOnly type="text" text={functionUrl} />
|
|
</ShortcutTooltip>
|
|
</div>
|
|
|
|
<HoverCard
|
|
openDelay={250}
|
|
closeDelay={100}
|
|
open={isTimestampHoverCardOpen}
|
|
onOpenChange={setIsTimestampHoverCardOpen}
|
|
>
|
|
<HoverCardTrigger asChild>
|
|
<button type="button" className="flex items-center gap-2 group">
|
|
<Clock size={16} strokeWidth={1.5} className="text-foreground-lighter" />
|
|
<span className="transition text-foreground-light group-hover:text-foreground underline decoration-dotted decoration-foreground-muted underline-offset-4">
|
|
{updatedRelative ?? 'Deploy status unavailable'}
|
|
</span>
|
|
</button>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent side="bottom" align="start" className="w-40 p-0">
|
|
{createdRelative && (
|
|
<div className="px-4 py-2 space-y-1">
|
|
<h3 className="heading-meta text-foreground-light">Created</h3>
|
|
{!!selectedFunction && (
|
|
<TimestampInfo
|
|
className="text-sm"
|
|
label={createdRelative}
|
|
utcTimestamp={selectedFunction.created_at}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
{updatedRelative && (
|
|
<div className="px-4 py-2 space-y-1">
|
|
<h3 className="heading-meta text-foreground-light">Last deployed</h3>
|
|
{!!selectedFunction && (
|
|
<TimestampInfo
|
|
className="text-sm"
|
|
label={updatedRelative}
|
|
utcTimestamp={selectedFunction.updated_at}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
{selectedFunction?.version !== undefined && (
|
|
<div className="px-4 py-2 space-y-1">
|
|
<h3 className="heading-meta text-foreground-light">Deployments</h3>
|
|
<p className="text-sm text-foreground">{selectedFunction.version}</p>
|
|
</div>
|
|
)}
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
</PageHeaderDescription>
|
|
</PageHeaderSummary>
|
|
|
|
<PageHeaderAside>
|
|
<div className="flex items-center space-x-2">
|
|
<DocsButton href={`${DOCS_URL}/guides/functions`} />
|
|
<Popover open={isDownloadOpen} onOpenChange={setIsDownloadOpen}>
|
|
<ShortcutTooltip
|
|
shortcutId={SHORTCUT_IDS.FUNCTION_DETAIL_OPEN_DOWNLOAD}
|
|
side="bottom"
|
|
open={isDownloadOpen ? false : undefined}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<Button type="default" icon={<Download />}>
|
|
Download
|
|
</Button>
|
|
</PopoverTrigger>
|
|
</ShortcutTooltip>
|
|
<PopoverContent align="end" className="p-0">
|
|
{IS_PLATFORM && (
|
|
<>
|
|
<div className="p-3 flex flex-col gap-y-2">
|
|
<p className="text-xs text-foreground-light">Download via CLI</p>
|
|
<Input
|
|
copy
|
|
showCopyOnHover
|
|
readOnly
|
|
containerClassName=""
|
|
className="text-xs font-mono tracking-tighter"
|
|
value={`supabase functions download ${functionSlug}`}
|
|
/>
|
|
</div>
|
|
<Separator className="bg-border-overlay!" />
|
|
</>
|
|
)}
|
|
<div className="py-2 px-1">
|
|
<Button
|
|
type="text"
|
|
className="w-min hover:bg-transparent"
|
|
icon={<FileArchive />}
|
|
onClick={downloadFunction}
|
|
>
|
|
Download as ZIP
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
{!!functionSlug && (
|
|
<ShortcutTooltip
|
|
shortcutId={SHORTCUT_IDS.FUNCTION_DETAIL_OPEN_TEST}
|
|
side="bottom"
|
|
>
|
|
<Button type="default" icon={<Send />} onClick={openTestSheet}>
|
|
Test
|
|
</Button>
|
|
</ShortcutTooltip>
|
|
)}
|
|
</div>
|
|
</PageHeaderAside>
|
|
</PageHeaderMeta>
|
|
|
|
{navigationItems.length > 0 && (
|
|
<PageHeaderNavigationTabs>
|
|
<NavMenu>
|
|
{navigationItems.map((item) => {
|
|
const isActive = router.asPath.split('?')[0] === item.href
|
|
return (
|
|
<NavMenuItem key={item.label} active={isActive}>
|
|
<Link href={item.href}>{item.label}</Link>
|
|
</NavMenuItem>
|
|
)
|
|
})}
|
|
</NavMenu>
|
|
</PageHeaderNavigationTabs>
|
|
)}
|
|
</PageHeader>
|
|
|
|
{children}
|
|
<EdgeFunctionTesterSheet visible={isOpen} onClose={() => setIsOpen(false)} />
|
|
</div>
|
|
</EdgeFunctionsLayout>
|
|
)
|
|
}
|
|
|
|
export default withAuth(EdgeFunctionDetailsLayout)
|