diff --git a/apps/studio/.playwright-mcp/page-2026-10-02T10-00-22-742Z.yml b/apps/studio/.playwright-mcp/page-2026-10-02T10-00-22-742Z.yml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/studio/.playwright-mcp/page-2026-10-02T10-00-53-530Z.yml b/apps/studio/.playwright-mcp/page-2026-10-02T10-00-53-530Z.yml new file mode 100644 index 00000000000..69a2dd802b2 --- /dev/null +++ b/apps/studio/.playwright-mcp/page-2026-10-02T10-00-53-530Z.yml @@ -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] \ No newline at end of file diff --git a/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx b/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx index 73865eb4e1b..819f36f0d99 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx @@ -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() { - + + ) } @@ -61,6 +60,7 @@ export default function StudioCommandMenu() { useThemeSwitcherCommands() useContextSearchCommands() useFeaturePreviewCommands() + useDatabaseResourceCommands() return ( diff --git a/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.utils.ts b/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.utils.ts index 832332f31c7..7116705276e 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.utils.ts +++ b/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.utils.ts @@ -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 diff --git a/apps/studio/components/interfaces/App/CommandMenu/CommandMenuFilterBar.state.ts b/apps/studio/components/interfaces/App/CommandMenu/CommandMenuFilterBar.state.ts new file mode 100644 index 00000000000..559ba8fa590 --- /dev/null +++ b/apps/studio/components/interfaces/App/CommandMenu/CommandMenuFilterBar.state.ts @@ -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({ + 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 && (