({
+ activeSectionId: null,
+})
+
+export function useActiveSectionId() {
+ return useSnapshot(commandMenuFilterBarState).activeSectionId
+}
+
+export function setActiveSectionId(sectionId: string | null) {
+ commandMenuFilterBarState.activeSectionId = sectionId
+}
diff --git a/apps/studio/components/interfaces/App/CommandMenu/CommandMenuFilterBar.tsx b/apps/studio/components/interfaces/App/CommandMenu/CommandMenuFilterBar.tsx
new file mode 100644
index 00000000000..7656a34472c
--- /dev/null
+++ b/apps/studio/components/interfaces/App/CommandMenu/CommandMenuFilterBar.tsx
@@ -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 (
+
+ )
+}
+
+/**
+ * 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 (
+
+ setActiveSectionId(null)}
+ />
+ {sectionCounts.map((section) => (
+ setActiveSectionId(section.id)}
+ />
+ ))}
+
+ )
+}
diff --git a/apps/studio/components/interfaces/App/CommandMenu/DatabaseResourceCommands.tsx b/apps/studio/components/interfaces/App/CommandMenu/DatabaseResourceCommands.tsx
new file mode 100644
index 00000000000..c4da8037063
--- /dev/null
+++ b/apps/studio/components/interfaces/App/CommandMenu/DatabaseResourceCommands.tsx
@@ -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) {
+ 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 (
+
+ {children}
+
+ )
+}
+
+/**
+ * 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(() => {
+ 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: () => ,
+ badge: () => (
+
+ {columnCount} column{columnCount !== 1 ? 's' : ''} • {policyCount} polic
+ {policyCount !== 1 ? 'ies' : 'y'} • RLS {t.rls_enabled ? 'enabled' : 'disabled'}
+
+ ),
+ route: `/project/${projectRef}/editor/${t.id}?schema=${t.schema}` as `/${string}`,
+ }
+ })
+ }, [showTables, tableMatches, limit, policies, projectRef])
+
+ const policyCommands = useMemo(() => {
+ 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: () => ,
+ badge: () => (
+
+ {p.command} • Applies to {p.roles.length ? p.roles.join(', ') : 'public'}
+
+ ),
+ route: `/project/${projectRef}/database/policies?schema=${p.schema}&edit=${p.id}` as `/${string}`,
+ }))
+ }, [showPolicies, policyMatches, limit, projectRef])
+
+ const functionCommands = useMemo(() => {
+ 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: () => ,
+ badge: references.length
+ ? () => References: {references.join(', ')}
+ : undefined,
+ route: `/project/${projectRef}/database/functions?edit=${f.id}` as `/${string}`,
+ }
+ })
+ }, [showFunctions, functionMatches, limit, tables, projectRef])
+
+ const triggerCommands = useMemo(() => {
+ 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: () => ,
+ badge: () => (
+
+ {t.activation} {t.events.join('/')} • {t.schema}.{t.table}
+
+ ),
+ 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,
+ })
+}
diff --git a/apps/studio/components/interfaces/App/CommandMenu/FilterableCommandMenuList.tsx b/apps/studio/components/interfaces/App/CommandMenu/FilterableCommandMenuList.tsx
new file mode 100644
index 00000000000..2e319ccfa37
--- /dev/null
+++ b/apps/studio/components/interfaces/App/CommandMenu/FilterableCommandMenuList.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ const commandSections = useCommands()
+ const query = useQuery()
+ const activeSectionId = useActiveSectionId()
+
+ const innerRef = useRef(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 (
+
+ No results found.
+ {visibleSections.map((section) => {
+ if (section.commands.every((command) => command.defaultHidden) && !query) return null
+
+ return (
+
+ {section.commands
+ .filter((command) => !command.defaultHidden || query)
+ .map((command) => (
+
+ {command.name}
+
+ ))}
+
+ )
+ })}
+
+ )
+})
+FilterableCommandMenuList.displayName = 'FilterableCommandMenuList'
+
+export { FilterableCommandMenuList }
diff --git a/packages/ui-patterns/src/CommandMenu/internal/CommandMenuEmpty.tsx b/packages/ui-patterns/src/CommandMenu/internal/CommandMenuEmpty.tsx
index f0ce9f833e9..8e6806d28de 100644
--- a/packages/ui-patterns/src/CommandMenu/internal/CommandMenuEmpty.tsx
+++ b/packages/ui-patterns/src/CommandMenu/internal/CommandMenuEmpty.tsx
@@ -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 && (