Resource aware cmd K

This commit is contained in:
Joshen Lim committed 2026-10-02 18:39:34 +08:00
1 parent 1168fd8a21
commit a261d1bced
9 files changed
+489 -10

No files matched your search

Whitespace-only changes.
@@ -0,0 +1,54 @@
- generic [ref=f3e2]:
- generic [ref=f3e3]:
- link "Skip to content" [ref=f3e4] [cursor=pointer]:
- /url: "#start-of-content"
- banner [ref=f3e6]
- main [ref=f3e9]:
- generic [ref=f3e10]:
- generic [ref=f3e12]:
- img "Supabase Gotrue Local logo" [ref=f3e14]
- paragraph [ref=f3e15]:
- text: Sign in to
- strong [ref=f3e16]: GitHub
- text: to continue to
- strong [ref=f3e17]: Supabase Gotrue Local
- generic [ref=f3e18]:
- generic [ref=f3e19]:
- generic [ref=f3e20]:
- generic [ref=f3e21]: Username or email address
- textbox "Username or email address" [active] [ref=f3e22]
- generic [ref=f3e23]:
- generic [ref=f3e24]: Password
- textbox "Password" [ref=f3e25]
- link "Forgot password?" [ref=f3e26] [cursor=pointer]:
- /url: /password_reset
- button "Sign in" [ref=f3e28] [cursor=pointer]
- generic [ref=f3e29]:
- generic [ref=f3e30]: or
- button "Continue with Google" [ref=f3e33] [cursor=pointer]
- button "Continue with Apple" [ref=f3e37] [cursor=pointer]
- generic [ref=f3e40]:
- paragraph [ref=f3e42]:
- text: New to GitHub?
- link "Create an account" [ref=f3e43] [cursor=pointer]:
- /url: /join?return_to=%2Flogin%2Foauth%2Fauthorize%3Fclient_id%3Dceae60fb00c5e6c95d65%26redirect_to%3Dhttp%253A%252F%252Flocalhost%253A8082%252Fsign-in-mfa%253FreturnTo%253D%25252Forg%2526method%253Dgithub%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8000%252Fauth%252Fv1%252Fcallback%26response_type%3Dcode%26scope%3Duser%253Aemail%26state%3D586331fd-8456-4480-9f0a-1c7017c40779&source=oauth
- paragraph [ref=f3e45]:
- button "Sign in with a passkey" [ref=f3e46] [cursor=pointer]
- contentinfo [ref=f3e49]:
- list [ref=f3e50]:
- listitem [ref=f3e51]:
- link "Terms" [ref=f3e52] [cursor=pointer]:
- /url: https://docs.github.com/site-policy/github-terms/github-terms-of-service
- listitem [ref=f3e53]:
- link "Privacy" [ref=f3e54] [cursor=pointer]:
- /url: https://docs.github.com/site-policy/privacy-policies/github-privacy-statement
- listitem [ref=f3e55]:
- link "Docs" [ref=f3e56] [cursor=pointer]:
- /url: https://docs.github.com
- listitem [ref=f3e57]:
- link "Contact GitHub Support" [ref=f3e58] [cursor=pointer]:
- /url: https://support.github.com
- listitem [ref=f3e59]:
- button "Manage cookies" [ref=f3e61] [cursor=pointer]
- listitem [ref=f3e62]:
- button "Do not share my personal information" [ref=f3e64] [cursor=pointer]
@@ -1,10 +1,5 @@
import { IS_PLATFORM } from 'common'
import {
CommandHeader,
CommandMenu,
CommandMenuInput,
CommandMenuList,
} from 'ui-patterns/CommandMenu'
import { CommandHeader, CommandMenu, CommandMenuInput } from 'ui-patterns/CommandMenu'
import { useChangelogCommand } from 'ui-patterns/CommandMenu/prepackaged/Changelog'
import { useDocsAiCommands } from 'ui-patterns/CommandMenu/prepackaged/DocsAi'
import { useDocsSearchCommands } from 'ui-patterns/CommandMenu/prepackaged/DocsSearch'
@@ -12,9 +7,12 @@ import { useThemeSwitcherCommands } from 'ui-patterns/CommandMenu/prepackaged/Th
import { useApiKeysCommands } from './ApiKeys'
import { useApiUrlCommand } from './ApiUrl'
import { CommandMenuFilterBar } from './CommandMenuFilterBar'
import { useContextSearchCommands } from './ContextSearchCommands'
import { useCreateCommands } from './CreateCommands'
import { useDatabaseResourceCommands } from './DatabaseResourceCommands'
import { useFeaturePreviewCommands } from './FeaturePreviews'
import { FilterableCommandMenuList } from './FilterableCommandMenuList'
import { orderCommandSectionsByPriority } from './ordering'
import { useConfigureOrganizationCommand, useProjectSwitchCommand } from './OrgProjectSwitcher'
import { useSupportCommands } from './Support'
@@ -33,7 +31,8 @@ export function CommandMenuInnerContent() {
<CommandHeader>
<CommandMenuInput />
</CommandHeader>
<CommandMenuList />
<CommandMenuFilterBar />
<FilterableCommandMenuList />
</>
)
}
@@ -61,6 +60,7 @@ export default function StudioCommandMenu() {
useThemeSwitcherCommands()
useContextSearchCommands()
useFeaturePreviewCommands()
useDatabaseResourceCommands()
return (
<CommandMenu>
@@ -8,4 +8,9 @@ export const COMMAND_MENU_SECTIONS = {
DATABASE: 'Database',
INTEGRATIONS: 'Integrations',
SHORTCUTS: 'Shortcuts',
// Resource-aware search results (POC): live matches against DB objects
RESOURCE_TABLES: 'Tables',
RESOURCE_POLICIES: 'Policies',
RESOURCE_FUNCTIONS: 'Functions',
RESOURCE_TRIGGERS: 'Triggers',
} as const
@@ -0,0 +1,24 @@
'use client'
import { proxy, useSnapshot } from 'valtio'
interface CommandMenuFilterBarState {
// Matches ICommandSection['id']. null = "All" (no section filter applied).
activeSectionId: string | null
}
// POC: shared state so the filter chip bar (reading all registered sections)
// and the list renderer (which section(s) to actually show) agree on the
// current selection without prop-drilling through the generic CommandMenu
// tree.
export const commandMenuFilterBarState = proxy<CommandMenuFilterBarState>({
activeSectionId: null,
})
export function useActiveSectionId() {
return useSnapshot(commandMenuFilterBarState).activeSectionId
}
export function setActiveSectionId(sectionId: string | null) {
commandMenuFilterBarState.activeSectionId = sectionId
}
@@ -0,0 +1,93 @@
'use client'
import { useEffect } from 'react'
import { cn } from 'ui'
import { useCommands, useQuery } from 'ui-patterns/CommandMenu'
import type { ICommand, ICommandSection } from 'ui-patterns/CommandMenu/internal/types'
import { setActiveSectionId, useActiveSectionId } from './CommandMenuFilterBar.state'
// Approximates cmdk's own per-item visibility check (see CommandMenuList),
// so chip counts roughly track what's actually visible below. Not an exact
// match: cmdk uses fuzzy scoring, this is plain substring matching.
function isCommandVisible(command: ICommand, trimmedQuery: string) {
if (!trimmedQuery) return !command.defaultHidden
return (command.value ?? command.name).toLowerCase().includes(trimmedQuery)
}
function countVisibleCommands(section: ICommandSection, trimmedQuery: string) {
return section.commands.filter((command) => isCommandVisible(command, trimmedQuery)).length
}
function FilterChip({
label,
active,
onClick,
}: {
label: string
active: boolean
onClick: () => void
}) {
return (
<button
type="button"
tabIndex={0}
onClick={onClick}
className={cn(
'shrink-0 rounded-full border px-2 py-0.5 text-xs whitespace-nowrap transition-colors',
active
? 'bg-foreground text-background border-foreground'
: 'bg-transparent text-foreground-light border-strong hover:bg-overlay-hover'
)}
>
{label}
</button>
)
}
/**
* Filter chip bar: reads every registered command section (not just DB
* resources) and lets the user narrow the palette down to one of them. Works
* alongside FilterableCommandMenuList, which does the actual hiding.
*/
export function CommandMenuFilterBar() {
const commandSections = useCommands() as ICommandSection[]
const query = useQuery()
const activeSectionId = useActiveSectionId()
const trimmedQuery = query.trim().toLowerCase()
useEffect(() => {
if (!trimmedQuery) setActiveSectionId(null)
}, [trimmedQuery])
if (!trimmedQuery) return null
const sectionCounts = commandSections
.map((section) => ({
id: section.id,
name: section.name,
count: countVisibleCommands(section, trimmedQuery),
}))
.filter((section) => section.count > 0)
if (sectionCounts.length === 0) return null
return (
<div className="flex items-center gap-2 px-4 pt-3 pb-1 overflow-x-auto shrink-0">
<FilterChip
label="All"
active={activeSectionId === null}
onClick={() => setActiveSectionId(null)}
/>
{sectionCounts.map((section) => (
<FilterChip
key={section.id}
label={`${section.name} ${section.count}`}
active={activeSectionId === section.id}
onClick={() => setActiveSectionId(section.id)}
/>
))}
</div>
)
}
@@ -0,0 +1,224 @@
'use client'
import { useDebounce } from '@uidotdev/usehooks'
import { useParams } from 'common'
import { Auth } from 'icons'
import { escapeRegExp } from 'lodash'
import { Database, FunctionSquare, Zap } from 'lucide-react'
import { useMemo, type ReactNode } from 'react'
import type { ICommand } from 'ui-patterns/CommandMenu'
import { useQuery, useRegisterCommands } from 'ui-patterns/CommandMenu'
import { COMMAND_MENU_SECTIONS } from './CommandMenu.utils'
import { useActiveSectionId } from './CommandMenuFilterBar.state'
import { useDatabaseFunctionsQuery } from '@/data/database-functions/database-functions-query'
import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query'
import { useDatabaseTriggersQuery } from '@/data/database-triggers/database-triggers-query'
import { useTablesQuery } from '@/data/tables/tables-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
// POC: min characters before we fetch/match against DB objects, and caps on
// how many results to register per section so the palette doesn't get
// swamped on a project with many objects. The cap lifts once the user has
// drilled into a single section via the filter bar (see CommandMenuFilterBar).
const MIN_QUERY_LENGTH = 2
const DEFAULT_LIMIT = 5
const FOCUSED_LIMIT = 25
// Section ids are derived by ui-patterns from COMMAND_MENU_SECTIONS labels
// (lowercased, spaces -> hyphens). Ours are single words, so these are fixed.
const SECTION_IDS = {
tables: 'tables',
policies: 'policies',
functions: 'functions',
triggers: 'triggers',
} as const
function matchText(...values: Array<string | null | undefined>) {
return values.filter(Boolean).join('|').toLowerCase()
}
// Heuristic only: looks for mentions of known table names in a function's
// source text. Not a real dependency scan (no AST/catalog dependency walk).
function getReferencedTables(
definition: string,
tables: Array<{ schema: string; name: string }>
) {
const found: string[] = []
for (const t of tables) {
if (t.name.length < 3) continue
const pattern = new RegExp(`\\b${escapeRegExp(t.name)}\\b`, 'i')
if (pattern.test(definition)) found.push(`${t.schema}.${t.name}`)
if (found.length >= 3) break
}
return found
}
function MetaBadge({ children }: { children: ReactNode }) {
return (
<span className="text-xs text-foreground-lighter truncate max-w-[16rem] pl-4">
{children}
</span>
)
}
/**
* Makes the Cmd+K palette resource-aware: as the user types, live-matches
* against the project's actual tables/policies/functions/triggers (not just
* static nav entries) and shows them grouped by type. CommandMenuFilterBar
* (generic, reads all registered sections) lets the user narrow down to one
* of these sections specifically.
*/
export function useDatabaseResourceCommands() {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const activeSectionId = useActiveSectionId()
const query = useQuery()
const debouncedQuery = useDebounce(query, 200)
const trimmedQuery = debouncedQuery.trim().toLowerCase()
const isSearching = trimmedQuery.length >= MIN_QUERY_LENGTH
const queryEnabled = isSearching && !!project?.ref
const { data: tables } = useTablesQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
includeColumns: true,
},
{ enabled: queryEnabled }
)
const { data: policies } = useDatabasePoliciesQuery(
{ projectRef: project?.ref, connectionString: project?.connectionString },
{ enabled: queryEnabled }
)
const { data: functions } = useDatabaseFunctionsQuery(
{ projectRef: project?.ref, connectionString: project?.connectionString },
{ enabled: queryEnabled }
)
const { data: triggers } = useDatabaseTriggersQuery(
{ projectRef: project?.ref, connectionString: project?.connectionString },
{ enabled: queryEnabled }
)
const tableMatches = useMemo(() => {
if (!isSearching || !tables) return []
return tables.filter((t) => matchText(t.name, t.schema).includes(trimmedQuery))
}, [isSearching, tables, trimmedQuery])
const policyMatches = useMemo(() => {
if (!isSearching || !policies) return []
return policies.filter((p) =>
matchText(p.name, p.table, p.schema, p.command).includes(trimmedQuery)
)
}, [isSearching, policies, trimmedQuery])
const functionMatches = useMemo(() => {
if (!isSearching || !functions) return []
return functions.filter((f) => matchText(f.name, f.schema).includes(trimmedQuery))
}, [isSearching, functions, trimmedQuery])
const triggerMatches = useMemo(() => {
if (!isSearching || !triggers) return []
return triggers.filter((t) => matchText(t.name, t.table, t.schema).includes(trimmedQuery))
}, [isSearching, triggers, trimmedQuery])
const limit = activeSectionId === null ? DEFAULT_LIMIT : FOCUSED_LIMIT
const showTables = activeSectionId === null || activeSectionId === SECTION_IDS.tables
const showPolicies = activeSectionId === null || activeSectionId === SECTION_IDS.policies
const showFunctions = activeSectionId === null || activeSectionId === SECTION_IDS.functions
const showTriggers = activeSectionId === null || activeSectionId === SECTION_IDS.triggers
const tableCommands = useMemo<ICommand[]>(() => {
if (!showTables) return []
return tableMatches.slice(0, limit).map((t) => {
const displayName = `${t.schema}.${t.name}`
const columnCount = t.columns?.length ?? 0
const policyCount =
policies?.filter((p) => p.schema === t.schema && p.table === t.name).length ?? 0
return {
id: `resource-table-${t.id}`,
name: displayName,
value: displayName,
icon: () => <Database className="h-4 w-4" strokeWidth={1.5} />,
badge: () => (
<MetaBadge>
{columnCount} column{columnCount !== 1 ? 's' : ''} • {policyCount} polic
{policyCount !== 1 ? 'ies' : 'y'} • RLS {t.rls_enabled ? 'enabled' : 'disabled'}
</MetaBadge>
),
route: `/project/${projectRef}/editor/${t.id}?schema=${t.schema}` as `/${string}`,
}
})
}, [showTables, tableMatches, limit, policies, projectRef])
const policyCommands = useMemo<ICommand[]>(() => {
if (!showPolicies) return []
return policyMatches.slice(0, limit).map((p) => ({
id: `resource-policy-${p.id}`,
name: p.name || 'Untitled policy',
value: `${p.name ?? ''} ${p.schema}.${p.table}`,
icon: () => <Auth className="h-4 w-4" strokeWidth={1.5} />,
badge: () => (
<MetaBadge>
{p.command} • Applies to {p.roles.length ? p.roles.join(', ') : 'public'}
</MetaBadge>
),
route: `/project/${projectRef}/database/policies?schema=${p.schema}&edit=${p.id}` as `/${string}`,
}))
}, [showPolicies, policyMatches, limit, projectRef])
const functionCommands = useMemo<ICommand[]>(() => {
if (!showFunctions) return []
return functionMatches.slice(0, limit).map((f) => {
const displayName = `${f.schema}.${f.name}`
const references = getReferencedTables(f.definition, tables ?? [])
return {
id: `resource-function-${f.id}`,
name: displayName,
value: displayName,
icon: () => <FunctionSquare className="h-4 w-4" strokeWidth={1.5} />,
badge: references.length
? () => <MetaBadge>References: {references.join(', ')}</MetaBadge>
: undefined,
route: `/project/${projectRef}/database/functions?edit=${f.id}` as `/${string}`,
}
})
}, [showFunctions, functionMatches, limit, tables, projectRef])
const triggerCommands = useMemo<ICommand[]>(() => {
if (!showTriggers) return []
return triggerMatches.slice(0, limit).map((t) => ({
id: `resource-trigger-${t.id}`,
name: t.name,
value: `${t.name} ${t.schema}.${t.table}`,
icon: () => <Zap className="h-4 w-4" strokeWidth={1.5} />,
badge: () => (
<MetaBadge>
{t.activation} {t.events.join('/')} • {t.schema}.{t.table}
</MetaBadge>
),
route: `/project/${projectRef}/database/triggers?edit=${t.id}` as `/${string}`,
}))
}, [showTriggers, triggerMatches, limit, projectRef])
useRegisterCommands(COMMAND_MENU_SECTIONS.RESOURCE_TABLES, tableCommands, {
deps: [tableCommands],
enabled: tableCommands.length > 0,
})
useRegisterCommands(COMMAND_MENU_SECTIONS.RESOURCE_POLICIES, policyCommands, {
deps: [policyCommands],
enabled: policyCommands.length > 0,
})
useRegisterCommands(COMMAND_MENU_SECTIONS.RESOURCE_FUNCTIONS, functionCommands, {
deps: [functionCommands],
enabled: functionCommands.length > 0,
})
useRegisterCommands(COMMAND_MENU_SECTIONS.RESOURCE_TRIGGERS, triggerCommands, {
deps: [triggerCommands],
enabled: triggerCommands.length > 0,
})
}
@@ -0,0 +1,65 @@
'use client'
import { forwardRef, useRef } from 'react'
import { cn, CommandList } from 'ui'
import { TextHighlighter, useCommands, useQuery } from 'ui-patterns/CommandMenu'
import { CommandMenuEmpty } from 'ui-patterns/CommandMenu/internal/CommandMenuEmpty'
import { CommandMenuGroup } from 'ui-patterns/CommandMenu/internal/CommandMenuGroup'
import { CommandMenuItem } from 'ui-patterns/CommandMenu/internal/CommandMenuItem'
import { useActiveSectionId } from './CommandMenuFilterBar.state'
/**
* Studio-local variant of ui-patterns' CommandMenuList that additionally
* respects CommandMenuFilterBar's section selection. Mirrors the shared
* component's rendering exactly, minus the section filter, to avoid changing
* behaviour for other CommandMenu consumers (e.g. apps/www).
*/
const FilterableCommandMenuList = forwardRef<
React.ElementRef<typeof CommandList>,
React.ComponentPropsWithoutRef<typeof CommandList>
>(({ className, ...props }, ref) => {
const commandSections = useCommands()
const query = useQuery()
const activeSectionId = useActiveSectionId()
const innerRef = useRef<HTMLDivElement | undefined>(undefined)
const setInnerRef = (elem: HTMLDivElement) => (innerRef.current = elem)
const setRef = (elem: HTMLDivElement) => {
if (ref) typeof ref === 'function' ? ref(elem) : (ref.current = elem)
setInnerRef(elem)
}
const visibleSections = activeSectionId
? commandSections.filter((section) => section.id === activeSectionId)
: commandSections
return (
<CommandList
ref={setRef}
className={cn('max-h-[initial] overflow-y-auto overflow-x-hidden bg-transparent', className)}
{...props}
>
<CommandMenuEmpty listRef={innerRef}>No results found.</CommandMenuEmpty>
{visibleSections.map((section) => {
if (section.commands.every((command) => command.defaultHidden) && !query) return null
return (
<CommandMenuGroup key={section.id} heading={section.name} forceMount={section.forceMount}>
{section.commands
.filter((command) => !command.defaultHidden || query)
.map((command) => (
<CommandMenuItem key={command.id} command={command}>
<TextHighlighter>{command.name}</TextHighlighter>
</CommandMenuItem>
))}
</CommandMenuGroup>
)
})}
</CommandList>
)
})
FilterableCommandMenuList.displayName = 'FilterableCommandMenuList'
export { FilterableCommandMenuList }
@@ -24,9 +24,23 @@ const CommandMenuEmpty = ({
const [render, setRender] = useState(false)
useEffect(() => {
if (!query) return setRender(false)
setRender(!listRef?.current?.querySelector('[cmdk-item]'))
}, [query])
if (!query) {
setRender(false)
return
}
const node = listRef?.current
const checkEmpty = () => setRender(!node?.querySelector('[cmdk-item]'))
checkEmpty()
if (!node) return
// Some sections (e.g. live DB resource search results) populate
// asynchronously after the query changes, so re-check whenever the list's
// contents actually change rather than only once per keystroke.
const observer = new MutationObserver(checkEmpty)
observer.observe(node, { childList: true, subtree: true })
return () => observer.disconnect()
}, [query, listRef])
return (
render && (