Files
supabase/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx
T
Ali WaseemandDanny White bffe49bb1d feat(edge-functions): keyboard shortcuts on overview, detail, and test sheet (#45947)
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 -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](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>
2026-05-15 07:59:18 -06:00

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)