diff --git a/.github/workflows/docs-e2e.yml b/.github/workflows/docs-e2e.yml new file mode 100644 index 0000000000000..eae790a43ed97 --- /dev/null +++ b/.github/workflows/docs-e2e.yml @@ -0,0 +1,122 @@ +name: Docs E2E Tests + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] + branches: ['master'] + paths: + - 'apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx' + - 'apps/docs/content/_partials/quickstart_db_setup.mdx' + - 'apps/docs/content/_partials/api_settings.mdx' + - 'e2e/docs/**' + - 'pnpm-lock.yaml' + - '.github/workflows/docs-e2e.yml' + workflow_dispatch: + inputs: + base_url: + description: 'Base URL to test against' + required: false + default: 'https://supabase.com' + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + deployments: read + statuses: read + pull-requests: read + +env: + CI: true + +jobs: + e2e: + name: Docs E2E + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == false + timeout-minutes: 30 + runs-on: blacksmith-4vcpu-ubuntu-2404 + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + sparse-checkout: | + e2e/docs + patches + + # Vercel skips the docs preview when a PR only changes the harness + # (e2e/docs, workflow). Wait for a preview only when apps/docs changed. + - name: Detect docs app changes + if: github.event_name == 'pull_request' + id: filter + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + with: + filters: | + docs_app: + - 'apps/docs/**' + + - name: Wait for Vercel docs preview + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && steps.filter.outputs.docs_app == 'true' + id: deployment + uses: vercel/wait-for-deployment-action@0e2b0c5c5cce31f1648108aeec56467187aca037 + with: + project-slug: docs + environment: preview + timeout: '900' + + - name: Resolve base URL + id: base-url + env: + EVENT_NAME: ${{ github.event_name }} + BASE_URL_INPUT: ${{ inputs.base_url }} + DEPLOYMENT_URL: ${{ steps.deployment.outputs.deployment-url }} + DOCS_APP_CHANGED: ${{ steps.filter.outputs.docs_app }} + run: | + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + printf 'url=%s\n' "$BASE_URL_INPUT" >> "$GITHUB_OUTPUT" + echo "use_bypass=false" >> "$GITHUB_OUTPUT" + elif [ "$DOCS_APP_CHANGED" = "true" ] && [ -n "$DEPLOYMENT_URL" ]; then + printf 'url=%s\n' "$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT" + echo "use_bypass=true" >> "$GITHUB_OUTPUT" + else + # Harness-only PRs have no docs preview; test against production. + echo "url=https://supabase.com" >> "$GITHUB_OUTPUT" + echo "use_bypass=false" >> "$GITHUB_OUTPUT" + fi + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + name: Install pnpm + with: + run_install: false + + - name: Use Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile --filter=e2e-docs... + + - name: Install Playwright Chromium + run: pnpm -C e2e/docs exec playwright install chromium --with-deps --only-shell + + - name: Run docs E2E + working-directory: e2e/docs + run: pnpm run e2e:docs + env: + PLAYWRIGHT_BASE_URL: ${{ steps.base-url.outputs.url }} + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ steps.base-url.outputs.use_bypass == 'true' && secrets.VERCEL_AUTOMATION_BYPASS_DOCS || '' }} + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: docs-playwright-report + path: | + e2e/docs/playwright-report/ + e2e/docs/test-results/ + retention-days: 7 diff --git a/apps/docs/content/guides/realtime/authorization.mdx b/apps/docs/content/guides/realtime/authorization.mdx index 2b65ba51e7f04..7b8cd831b9b89 100644 --- a/apps/docs/content/guides/realtime/authorization.mdx +++ b/apps/docs/content/guides/realtime/authorization.mdx @@ -23,6 +23,14 @@ Realtime uses the `messages` table in your database's `realtime` schema to gener By creating RLS policies on the `realtime.messages` table you can control the access users have to a Channel topic, and features within a Channel topic. + + +Realtime locks down the `realtime` schema to protect it against unexpected changes to guarantee the healthy operation of the Realtime service and avoid conflicts that could be caused by future migrations. Creating a table or function in `realtime` is expected to fail with `permission denied for schema realtime`, whether you run the SQL yourself or through the dashboard. Managing RLS policies on `realtime.messages` is allowed. + +Row level security (RLS) is enabled by default on table `realtime.messages`, you don't need to execute `ALTER TABLE realtime.messages ENABLE ROW LEVEL SECURITY`. + + + The validation is done when the user connects. When their WebSocket connection is established and a Channel topic is joined, their permissions are calculated based on: - The RLS policies on the `realtime.messages` table diff --git a/apps/docs/content/guides/realtime/concepts.mdx b/apps/docs/content/guides/realtime/concepts.mdx index 25a9881e6c6ff..072fe519a68ea 100644 --- a/apps/docs/content/guides/realtime/concepts.mdx +++ b/apps/docs/content/guides/realtime/concepts.mdx @@ -106,7 +106,8 @@ Realtime has a cleanup process that will delete tables older than 3 days. ### Functions -Realtime creates two functions on your database: +Realtime creates some functions on your database: - `realtime.send` - Inserts an entry into `realtime.messages` table that will trigger the replication slot to broadcast the changes to the clients. It also captures errors to prevent the trigger from breaking. +- `realtime.send_binary` - Similar to `realtime.send` but allows you to broadcast binaries. - `realtime.broadcast_changes` - uses `realtime.send` to broadcast the changes with a format that is compatible with Postgres Changes diff --git a/apps/docs/internals/markdown-schema/AiSkillsIndex.ts b/apps/docs/internals/markdown-schema/AiSkillsIndex.ts index d2e7f1cc31de1..efa3e6174f420 100644 --- a/apps/docs/internals/markdown-schema/AiSkillsIndex.ts +++ b/apps/docs/internals/markdown-schema/AiSkillsIndex.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { readFileSync } from 'node:fs' import path from 'node:path' const SKILLS_PATH = path.join(process.cwd(), 'features/docs/generated/ai-skills.json') @@ -10,12 +10,6 @@ interface SkillSummary { } export const AiSkillsIndex = (): string => { - // `build:guides-markdown` can run standalone (e.g. apps/www's prebuild) without - // `build:federated-content`, so this generated artifact may not exist yet. - if (!existsSync(SKILLS_PATH)) { - return '' - } - const skills: SkillSummary[] = JSON.parse(readFileSync(SKILLS_PATH, 'utf-8')) return skills diff --git a/apps/docs/internals/markdown-schema/TerraformProviderSchema.ts b/apps/docs/internals/markdown-schema/TerraformProviderSchema.ts index fae407b18edb2..91c31732e28b3 100644 --- a/apps/docs/internals/markdown-schema/TerraformProviderSchema.ts +++ b/apps/docs/internals/markdown-schema/TerraformProviderSchema.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { readFileSync } from 'node:fs' import path from 'node:path' const SCHEMA_PATH = path.join(process.cwd(), 'features/docs/generated/terraform.schema.json') @@ -15,12 +15,6 @@ function attributesTable(attributes: Record, extraColumns: string[] } export const TerraformProviderSchema = (): string => { - // `build:guides-markdown` can run standalone (e.g. apps/www's prebuild) without - // `build:federated-content`, so this generated artifact may not exist yet. - if (!existsSync(SCHEMA_PATH)) { - return '' - } - const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf-8')) const provider = schema.provider_schemas['registry.terraform.io/supabase/supabase'] diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 7fc5b607de38d..64bf216b7906f 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -24,6 +24,7 @@ Ana Mogul Andrew Valleteau Andrey A Angelico de los Reyes +Anna Baker Ant Evans Ant Wilson Ariuna K @@ -267,6 +268,7 @@ Sergio Cioban Filho Shaii O Shane Adams Shane E +Shaun Newman Shreekar Shetty Sreyas Udayavarman Stephanie Jackson (stejacks) diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx index cd45552820493..459f1666d53c5 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx @@ -585,7 +585,7 @@ export const ProjectCreationForm = ({ loading={!isOrganizationsSuccess} noMargin={isVercelIntegrationFlow} className={cn( - !isVercelIntegrationFlow && 'border-0 shadow-none rounded-none bg-transparent' + isVercelIntegrationFlow && 'border-0 shadow-none rounded-none bg-transparent' )} title={ !isVercelIntegrationFlow && ( diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts index 67467a1d55e19..bb3c8581a1ea6 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts @@ -1,4 +1,5 @@ import type { DiffOnMount, OnMount } from '@monaco-editor/react' +import type { UntrustedSqlFragment } from '@supabase/pg-meta' import { Dispatch, SetStateAction } from 'react' export interface SQLTemplate { @@ -17,6 +18,34 @@ export type ContentDiff = { modified: string } +/** + * Semantic, Monaco-agnostic port onto the main editor. Hooks/controllers call + * this instead of touching `editorRef.current` directly, so the editor is + * swappable for a real in-memory adapter in tests without mocking. + */ +export type EditorController = { + isReady: () => boolean + getValue: () => string | undefined + getSelectionStartLine: () => number | undefined + getSql: (snippetContent?: UntrustedSqlFragment) => UntrustedSqlFragment | undefined + replaceAll: (text: string, source: string) => void + focus: () => void + revealLineInCenter: (line: number) => void + highlightErrorLine: ( + error: { position?: unknown; formattedError?: string }, + hasSelection: boolean + ) => void + clearHighlights: () => void +} + +/** Semantic, Monaco-agnostic port onto the diff editor. */ +export type DiffController = { + isMounted: () => boolean + getModifiedValue: () => string | undefined + setDiff: (diff: ContentDiff, revealLine: number) => void + attach: (editor: IStandaloneDiffEditor) => void +} + export type SQLEditorContextValues = { aiInput: string setAiInput: Dispatch> diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts index 56d728c654fe2..9c6865b0c790c 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts @@ -2,25 +2,31 @@ import { safeSql, untrustedSql } from '@supabase/pg-meta' import { stripIndent } from 'common-tags' import { describe, expect, it, test } from 'vitest' -import { untitledSnippetTitle } from './SQLEditor.constants' -import type { IStandaloneCodeEditor } from './SQLEditor.types' +import { sqlAiDisclaimerComment, untitledSnippetTitle } from './SQLEditor.constants' +import { DiffType, type IStandaloneCodeEditor } from './SQLEditor.types' import { analyzeQueryIssues, appendEnableRLSStatements, applyAutoLimit, assembleCompletionDiff, + buildCompletionRequestBody, + buildDebugChatArgs, buildDebugPromptText, buildExecuteParams, checkAlterDatabaseConnection, checkDestructiveQuery, computeErrorHighlightLine, + deriveSnippetIdentity, + extractDebugContext, filterTablesCoveredByEnsureRLSTrigger, getCreateTablesMissingRLS, getEditorSql, hasActiveEnsureRLSTrigger, hasBlockingIssues, isUpdateWithoutWhere, + planDiffRequestApplication, resolveConnectionString, + resolveDiffKeyAction, shouldAutoGenerateTitle, trimTrailingSemicolons, } from './SQLEditor.utils' @@ -290,6 +296,257 @@ describe('SQLEditor.utils.ts:buildExecuteParams', () => { }) }) +describe('SQLEditor.utils.ts:deriveSnippetIdentity', () => { + test('uses the generated id when there is no url id, loading reflects the snippets map like any other id', () => { + const result = deriveSnippetIdentity({ + urlId: undefined, + generatedId: 'generated-id', + snippets: {}, + }) + expect(result).toEqual({ id: 'generated-id', isLoading: true }) + }) + test('uses the generated id and is not loading once it appears in the snippets map', () => { + const result = deriveSnippetIdentity({ + urlId: undefined, + generatedId: 'generated-id', + snippets: { 'generated-id': { snippet: { content: { some: 'content' } } } }, + }) + expect(result).toEqual({ id: 'generated-id', isLoading: false }) + }) + test('uses the generated id and is never loading when the url id is "new"', () => { + const result = deriveSnippetIdentity({ + urlId: 'new', + generatedId: 'generated-id', + snippets: { 'generated-id': { snippet: { content: undefined } } }, + }) + expect(result).toEqual({ id: 'generated-id', isLoading: false }) + }) + test('uses the url id and is loading when the snippet is not yet in the store', () => { + const result = deriveSnippetIdentity({ + urlId: 'existing-id', + generatedId: 'generated-id', + snippets: {}, + }) + expect(result).toEqual({ id: 'existing-id', isLoading: true }) + }) + test('uses the url id and is loading when the snippet has no content yet', () => { + const result = deriveSnippetIdentity({ + urlId: 'existing-id', + generatedId: 'generated-id', + snippets: { 'existing-id': { snippet: { content: undefined } } }, + }) + expect(result).toEqual({ id: 'existing-id', isLoading: true }) + }) + test('uses the url id and is not loading once the snippet content has arrived', () => { + const result = deriveSnippetIdentity({ + urlId: 'existing-id', + generatedId: 'generated-id', + snippets: { 'existing-id': { snippet: { content: { some: 'content' } } } }, + }) + expect(result).toEqual({ id: 'existing-id', isLoading: false }) + }) +}) + +const buildDebugSnippet = (uncheckedSql: string) => ({ + snippet: { content: { unchecked_sql: untrustedSql(uncheckedSql) } }, +}) + +describe('SQLEditor.utils.ts:extractDebugContext', () => { + test('strips the AI disclaimer comment and trims the sql', () => { + const snippet = buildDebugSnippet(`${sqlAiDisclaimerComment}\n\nselect 1;`) + const result = { error: { message: 'relation does not exist' } } + expect(extractDebugContext(snippet, result)).toEqual({ + sql: 'select 1;', + errorMessage: 'relation does not exist', + }) + }) + test('falls back to an empty sql when the snippet is undefined', () => { + expect(extractDebugContext(undefined, { error: { message: 'boom' } })).toEqual({ + sql: '', + errorMessage: 'boom', + }) + }) + test('falls back to an empty sql when the snippet has no content yet', () => { + expect( + extractDebugContext({ snippet: { content: undefined } }, { error: { message: 'boom' } }) + ).toEqual({ sql: '', errorMessage: 'boom' }) + }) + test('falls back to "Unknown error" when the result is undefined', () => { + const snippet = buildDebugSnippet('select 1;') + expect(extractDebugContext(snippet, undefined)).toEqual({ + sql: 'select 1;', + errorMessage: 'Unknown error', + }) + }) + test('falls back to "Unknown error" when the result has no error message', () => { + const snippet = buildDebugSnippet('select 1;') + expect(extractDebugContext(snippet, {})).toEqual({ + sql: 'select 1;', + errorMessage: 'Unknown error', + }) + }) +}) + +describe('SQLEditor.utils.ts:buildDebugChatArgs', () => { + test('builds the newChat payload from the snippet sql and error message', () => { + const snippet = buildDebugSnippet('select 1;') + const result = { error: { message: 'relation does not exist' } } + expect(buildDebugChatArgs(snippet, result)).toEqual({ + name: 'Debug SQL snippet', + sqlSnippets: ['select 1;'], + initialInput: + 'Help me to debug the attached sql snippet which gives the following error: \n\nrelation does not exist', + }) + }) +}) + +describe('SQLEditor.utils.ts:buildCompletionRequestBody', () => { + test('builds the base request body with no extra options', () => { + expect( + buildCompletionRequestBody({ + projectRef: 'default', + connectionString: 'postgresql://example', + orgSlug: 'acme', + }) + ).toEqual({ + projectRef: 'default', + connectionString: 'postgresql://example', + language: 'sql', + orgSlug: 'acme', + }) + }) + test('merges options on top of the base fields', () => { + expect( + buildCompletionRequestBody({ + projectRef: 'default', + connectionString: 'postgresql://example', + orgSlug: 'acme', + options: { completionMetadata: { prompt: 'add a where clause' } }, + }) + ).toEqual({ + projectRef: 'default', + connectionString: 'postgresql://example', + language: 'sql', + orgSlug: 'acme', + completionMetadata: { prompt: 'add a where clause' }, + }) + }) +}) + +describe('SQLEditor.utils.ts:planDiffRequestApplication', () => { + test('replaces the editor content when it is empty', () => { + const plan = planDiffRequestApplication({ + existingValue: '', + request: { diffType: DiffType.Modification, sql: 'select 1;' }, + }) + expect(plan).toEqual({ kind: 'replace', text: 'select 1;' }) + }) + test('opens a diff against the existing content when it is not empty', () => { + const plan = planDiffRequestApplication({ + existingValue: 'select 0;', + request: { diffType: DiffType.Modification, sql: 'select 1;' }, + }) + expect(plan).toEqual({ + kind: 'diff', + diff: { original: 'select 0;', modified: 'select 1;' }, + diffType: DiffType.Modification, + }) + }) + test('carries through the requested diff type', () => { + const plan = planDiffRequestApplication({ + existingValue: 'select 0;', + request: { diffType: DiffType.NewSnippet, sql: 'select 1;' }, + }) + expect(plan).toEqual({ + kind: 'diff', + diff: { original: 'select 0;', modified: 'select 1;' }, + diffType: DiffType.NewSnippet, + }) + }) +}) + +const keyEvent = (overrides: Partial> = {}) => ({ + key: 'a', + metaKey: false, + ctrlKey: false, + ...overrides, +}) + +describe('SQLEditor.utils.ts:resolveDiffKeyAction', () => { + test('does nothing when neither a diff nor the prompt is open', () => { + const action = resolveDiffKeyAction(keyEvent({ key: 'Escape' }), { + isDiffOpen: false, + isPromptOpen: false, + os: 'macos', + }) + expect(action).toEqual({ type: 'none' }) + }) + test('does nothing for keys other than Enter/Escape', () => { + const action = resolveDiffKeyAction(keyEvent({ key: 'a' }), { + isDiffOpen: true, + isPromptOpen: false, + os: 'macos', + }) + expect(action).toEqual({ type: 'none' }) + }) + test('accepts on Cmd+Enter on macOS when a diff is open', () => { + const action = resolveDiffKeyAction(keyEvent({ key: 'Enter', metaKey: true }), { + isDiffOpen: true, + isPromptOpen: false, + os: 'macos', + }) + expect(action).toEqual({ type: 'accept' }) + }) + test('accepts on Ctrl+Enter on Windows when a diff is open', () => { + const action = resolveDiffKeyAction(keyEvent({ key: 'Enter', ctrlKey: true }), { + isDiffOpen: true, + isPromptOpen: false, + os: 'windows', + }) + expect(action).toEqual({ type: 'accept' }) + }) + test('does not accept on Ctrl+Enter on macOS (wrong modifier for the OS)', () => { + const action = resolveDiffKeyAction(keyEvent({ key: 'Enter', ctrlKey: true }), { + isDiffOpen: true, + isPromptOpen: false, + os: 'macos', + }) + expect(action).toEqual({ type: 'none' }) + }) + test('does not accept on Enter without the modifier key', () => { + const action = resolveDiffKeyAction(keyEvent({ key: 'Enter' }), { + isDiffOpen: true, + isPromptOpen: false, + os: 'macos', + }) + expect(action).toEqual({ type: 'none' }) + }) + test('does not accept on Cmd+Enter when the diff is not open, even if the prompt is', () => { + const action = resolveDiffKeyAction(keyEvent({ key: 'Enter', metaKey: true }), { + isDiffOpen: false, + isPromptOpen: true, + os: 'macos', + }) + expect(action).toEqual({ type: 'none' }) + }) + test('escapes with shouldDiscard true when a diff is open', () => { + const action = resolveDiffKeyAction(keyEvent({ key: 'Escape' }), { + isDiffOpen: true, + isPromptOpen: false, + os: 'macos', + }) + expect(action).toEqual({ type: 'escape', shouldDiscard: true }) + }) + test('escapes with shouldDiscard false when only the prompt is open', () => { + const action = resolveDiffKeyAction(keyEvent({ key: 'Escape' }), { + isDiffOpen: false, + isPromptOpen: true, + os: 'macos', + }) + expect(action).toEqual({ type: 'escape', shouldDiscard: false }) + }) +}) + describe(`SQLEditor.utils.ts:checkDestructiveQuery`, () => { it('drop statement matches', () => { const match = checkDestructiveQuery('drop table films, distributors;') diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index ab35c5a25ae3b..2c47067791b0e 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -15,7 +15,12 @@ import { untitledSnippetTitle, updateWithoutWhereRegex, } from './SQLEditor.constants' -import { ContentDiff, type IStandaloneCodeEditor, type PotentialIssues } from './SQLEditor.types' +import { + ContentDiff, + DiffType, + type IStandaloneCodeEditor, + type PotentialIssues, +} from './SQLEditor.types' import type { SnippetWithContent } from '@/data/content/sql-folders-query' import type { DatabaseEventTrigger } from '@/data/database-event-triggers/database-event-triggers-query' import type { Database } from '@/data/read-replicas/replicas-query' @@ -290,6 +295,26 @@ export function buildExecuteParams({ } } +/** + * Derives the stable snippet id + loading state for the editor from the URL. + */ +export function deriveSnippetIdentity({ + urlId, + generatedId, + snippets, +}: { + urlId: string | undefined + generatedId: string + snippets: Record +}): { id: string; isLoading: boolean } { + const id = !urlId || urlId === 'new' ? generatedId : urlId + + const snippetIsLoading = !(id in snippets && snippets[id].snippet.content !== undefined) + const isLoading = urlId === 'new' ? false : snippetIsLoading + + return { id, isLoading } +} + export const generateMigrationCliCommand = (id: string, name: string, isNpx = false) => ` ${isNpx ? 'npx ' : ''}supabase snippets download ${id} | @@ -447,6 +472,37 @@ export function assembleCompletionDiff( } } +/** + * Builds the request body sent to the AI completion endpoint. `options` is + * the caller-provided extra fields (e.g. `completionMetadata`), merged in + * last so it can override the defaults if it ever needs to. + */ +export function buildCompletionRequestBody({ + projectRef, + connectionString, + orgSlug, + options, +}: { + projectRef: string | undefined + connectionString: string | undefined | null + orgSlug: string | undefined + options?: { completionMetadata?: unknown } +}): { + projectRef: string | undefined + connectionString: string | undefined | null + language: 'sql' + orgSlug: string | undefined + completionMetadata?: unknown +} { + return { + projectRef, + connectionString, + language: 'sql', + orgSlug, + ...(options ?? {}), + } +} + /** * Builds the prompt text used to ask the assistant to debug a failing snippet. */ @@ -454,3 +510,98 @@ export function buildDebugPromptText(sql: string, errorMessage: string): string const prompt = `Help me to debug the attached sql snippet which gives the following error: \n\n${errorMessage}` return `${prompt}\n\nSQL Query:\n\`\`\`sql\n${sql}\n\`\`\`` } + +type DebugSnippet = { snippet: { content?: { unchecked_sql?: UntrustedSqlFragment } } } | undefined +type DebugResult = { error?: { message?: string } } | undefined + +/** + * Extracts the SQL (disclaimer stripped) and error message used to debug a + * failing snippet, shared by `buildDebugPrompt` and `onDebug`. Falls back to + * an empty query / `'Unknown error'` rather than throwing when the snippet or + * its last result aren't available yet. + */ +export function extractDebugContext( + snippet: DebugSnippet, + result: DebugResult +): { sql: string; errorMessage: string } { + const sql = (snippet?.snippet.content?.unchecked_sql ?? '') + .replace(sqlAiDisclaimerComment, '') + .trim() + const errorMessage = result?.error?.message ?? 'Unknown error' + return { sql, errorMessage } +} + +/** + * Builds the `aiSnap.newChat(...)` payload for the debug-this-snippet flow. + */ +export function buildDebugChatArgs( + snippet: DebugSnippet, + result: DebugResult +): { name: string; sqlSnippets: string[]; initialInput: string } { + const { sql, errorMessage } = extractDebugContext(snippet, result) + return { + name: 'Debug SQL snippet', + sqlSnippets: [sql], + initialInput: `Help me to debug the attached sql snippet which gives the following error: \n\n${errorMessage}`, + } +} + +/** What `drainDiffRequest` should do with a pending diff request, given the editor's current value. */ +export type DiffRequestPlan = + | { kind: 'replace'; text: string } + | { kind: 'diff'; diff: ContentDiff; diffType: DiffType } + +/** + * Decides how to apply a pending diff request to the editor: if the editor is + * empty, just copy the request's SQL straight in; otherwise open a diff + * between what's there and the requested SQL. Pure decision only — the + * effect (`drainDiffRequest`) is left to actually touch the editor/diff state. + */ +export function planDiffRequestApplication({ + existingValue, + request, +}: { + existingValue: string + request: { diffType: DiffType; sql: string } +}): DiffRequestPlan { + if (existingValue.length === 0) { + return { kind: 'replace', text: request.sql } + } + return { + kind: 'diff', + diff: { original: existingValue, modified: request.sql }, + diffType: request.diffType, + } +} + +/** What the window keydown handler should do for a key event, given the diff/prompt state. */ +export type DiffKeyAction = + | { type: 'accept' } + | { type: 'escape'; shouldDiscard: boolean } + | { type: 'none' } + +/** + * Decides how the SQL editor's window-level keydown handler should react: + * accept an open diff on Cmd/Ctrl+Enter, or discard-and-dismiss on Escape. + * No-ops when neither a diff nor the AI prompt is open, or for any other key. + */ +export function resolveDiffKeyAction( + e: Pick, + { + isDiffOpen, + isPromptOpen, + os, + }: { isDiffOpen: boolean; isPromptOpen: boolean; os: 'macos' | 'windows' | undefined } +): DiffKeyAction { + if (!isDiffOpen && !isPromptOpen) return { type: 'none' } + + switch (e.key) { + case 'Enter': + if ((os === 'macos' ? e.metaKey : e.ctrlKey) && isDiffOpen) return { type: 'accept' } + return { type: 'none' } + case 'Escape': + return { type: 'escape', shouldDiscard: isDiffOpen } + default: + return { type: 'none' } + } +} diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx index e8f568f0eed34..a5be74739e1f3 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx @@ -10,7 +10,13 @@ import { type RefObject, } from 'react' -import type { IStandaloneCodeEditor, IStandaloneDiffEditor } from './SQLEditor.types' +import type { + ContentDiff, + DiffController, + EditorController, + IStandaloneCodeEditor, + IStandaloneDiffEditor, +} from './SQLEditor.types' import { computeErrorHighlightLine, getEditorSql } from './SQLEditor.utils' type SQLEditorContextValue = { @@ -26,14 +32,10 @@ type SQLEditorContextValue = { markRefocusAfterRun: () => void /** Refocus the editor iff a run-refocus was requested, then clear the flag. */ refocusEditorAfterRunIfNeeded: () => void - getEditorSql: (snippetContent?: UntrustedSqlFragment) => UntrustedSqlFragment | undefined - /** Clear any active error-highlight decorations. */ - clearHighlights: () => void - /** Highlight and reveal the line referenced by an execute error's position. */ - applyErrorHighlight: ( - error: { position?: unknown; formattedError?: string }, - hasSelection: boolean - ) => void + /** Semantic port onto the main editor — no hook should touch `editorRef.current` directly. */ + editor: EditorController + /** Semantic port onto the diff editor — no hook should touch `diffEditorRef.current` directly. */ + diff: DiffController } const SQLEditorContext = createContext(null) @@ -77,10 +79,34 @@ export const SQLEditorProvider = ({ children }: PropsWithChildren) => { refocusEditor() }, [refocusEditor]) - const getEditorSqlFromEditor = useCallback((snippetContent?: UntrustedSqlFragment) => { - const editor = editorRef.current - if (!editor) return undefined - return getEditorSql(editor, snippetContent) + const isEditorReady = useCallback(() => editorRef.current !== null, []) + + const getEditorValue = useCallback(() => editorRef.current?.getValue(), []) + + const getSelectionStartLine = useCallback( + () => editorRef.current?.getSelection()?.startLineNumber, + [] + ) + + const getSqlFromEditor = useCallback((snippetContent?: UntrustedSqlFragment) => { + const editorInstance = editorRef.current + if (!editorInstance) return undefined + return getEditorSql(editorInstance, snippetContent) + }, []) + + const replaceAll = useCallback((text: string, source: string) => { + const editorInstance = editorRef.current + const model = editorInstance?.getModel() + if (!editorInstance || !model) return + editorInstance.executeEdits(source, [{ text, range: model.getFullModelRange() }]) + }, []) + + const focusEditor = useCallback(() => { + editorRef.current?.focus() + }, []) + + const revealLineInCenter = useCallback((line: number) => { + editorRef.current?.revealLineInCenter(line) }, []) const clearHighlights = useCallback(() => { @@ -90,17 +116,19 @@ export const SQLEditorProvider = ({ children }: PropsWithChildren) => { } }, []) - const applyErrorHighlight = useCallback( + const highlightErrorLine = useCallback( (error: { position?: unknown; formattedError?: string }, hasSelection: boolean) => { if (!error.position || !monacoRef.current) return - const editor = editorRef.current + const editorInstance = editorRef.current const monaco = monacoRef.current - const startLineNumber = hasSelection ? (editor?.getSelection()?.startLineNumber ?? 0) : 0 + const startLineNumber = hasSelection + ? (editorInstance?.getSelection()?.startLineNumber ?? 0) + : 0 const line = computeErrorHighlightLine(error, startLineNumber) if (isNaN(line)) return - const decorations = editor?.deltaDecorations( + const decorations = editorInstance?.deltaDecorations( [], [ { @@ -113,13 +141,69 @@ export const SQLEditorProvider = ({ children }: PropsWithChildren) => { ] ) if (decorations) { - editor?.revealLineInCenter(line) + editorInstance?.revealLineInCenter(line) lineHighlightsRef.current = decorations } }, [] ) + const editor = useMemo( + () => ({ + isReady: isEditorReady, + getValue: getEditorValue, + getSelectionStartLine, + getSql: getSqlFromEditor, + replaceAll, + focus: focusEditor, + revealLineInCenter, + highlightErrorLine, + clearHighlights, + }), + [ + isEditorReady, + getEditorValue, + getSelectionStartLine, + getSqlFromEditor, + replaceAll, + focusEditor, + revealLineInCenter, + highlightErrorLine, + clearHighlights, + ] + ) + + const isDiffMounted = useCallback(() => diffEditorRef.current !== null, []) + + const getModifiedValue = useCallback( + () => diffEditorRef.current?.getModel()?.modified.getValue(), + [] + ) + + const setDiff = useCallback((contentDiff: ContentDiff, revealLine: number) => { + const diffEditorInstance = diffEditorRef.current + const model = diffEditorInstance?.getModel() + if (!diffEditorInstance || !model || !model.original || !model.modified) return + + model.original.setValue(contentDiff.original) + model.modified.setValue(contentDiff.modified) + diffEditorInstance.getModifiedEditor().revealLineInCenter(revealLine) + }, []) + + const attachDiffEditor = useCallback((editorInstance: IStandaloneDiffEditor) => { + diffEditorRef.current = editorInstance + }, []) + + const diff = useMemo( + () => ({ + isMounted: isDiffMounted, + getModifiedValue, + setDiff, + attach: attachDiffEditor, + }), + [isDiffMounted, getModifiedValue, setDiff, attachDiffEditor] + ) + const value = useMemo( () => ({ editorRef, @@ -130,18 +214,16 @@ export const SQLEditorProvider = ({ children }: PropsWithChildren) => { clearPendingRunRefocus, markRefocusAfterRun, refocusEditorAfterRunIfNeeded, - getEditorSql: getEditorSqlFromEditor, - clearHighlights, - applyErrorHighlight, + editor, + diff, }), [ refocusEditor, clearPendingRunRefocus, markRefocusAfterRun, refocusEditorAfterRunIfNeeded, - getEditorSqlFromEditor, - clearHighlights, - applyErrorHighlight, + editor, + diff, ] ) diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx index f85aaa6f3d53d..03aeffa3f24b3 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx @@ -111,7 +111,7 @@ export const useSqlEditorRun = () => useGuardedContext(RunContext, 'useSqlEditor export const useSqlEditorUi = () => useGuardedContext(UiContext, 'useSqlEditorUi') export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) => { - const { monacoRef, scrollTopRef, getEditorSql: getEditorSqlFromEditor } = useSQLEditorContext() + const { monacoRef, scrollTopRef, editor } = useSQLEditorContext() const { ref } = useParams() const { data: project } = useSelectedProjectQuery() @@ -148,8 +148,8 @@ export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) => // to safety (acceptUntrustedSql) happens at each user-action site, never here. const readEditorSql = useCallback((): UntrustedSqlFragment | undefined => { const snippet = getSqlEditorV2StateSnapshot().snippets[id] - return getEditorSqlFromEditor(snippet?.snippet.content?.unchecked_sql) - }, [getEditorSqlFromEditor, id]) + return editor.getSql(snippet?.snippet.content?.unchecked_sql) + }, [editor, id]) const { executeQuery, isExecuting, potentialIssues, resetPotentialIssues } = useSqlEditorExecution({ diff --git a/apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.ts b/apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.ts index 1ded7a6c129ca..60c415e79d3bb 100644 --- a/apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.ts +++ b/apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.ts @@ -1,6 +1,5 @@ import { useCallback } from 'react' -import { getEditorSql } from './SQLEditor.utils' import { useSQLEditorContext } from './SQLEditorContext' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { formatSql } from '@/lib/formatSql' @@ -14,7 +13,7 @@ import { * the formatted SQL back to the snippet store. No-op while a diff is open. */ export function usePrettifyQuery({ id, isDiffOpen }: { id: string; isDiffOpen: boolean }) { - const { editorRef } = useSQLEditorContext() + const { editor } = useSQLEditorContext() const { data: project } = useSelectedProjectQuery() const snapV2 = useSqlEditorV2StateSnapshot() @@ -25,21 +24,13 @@ export function usePrettifyQuery({ id, isDiffOpen }: { id: string; isDiffOpen: b const state = getSqlEditorV2StateSnapshot() const snippet = state.snippets[id] - if (editorRef.current && project) { - const editor = editorRef.current - const sql = getEditorSql(editor, snippet?.snippet.content?.unchecked_sql) - const formattedSql = formatSql(sql) + if (editor.isReady() && project) { + const sql = editor.getSql(snippet?.snippet.content?.unchecked_sql) + if (sql === undefined) return - const editorModel = editorRef?.current?.getModel() - if (editorRef.current && editorModel) { - editorRef.current.executeEdits('apply-prettify-edit', [ - { - text: formattedSql, - range: editorModel.getFullModelRange(), - }, - ]) - snapV2.setSql({ id, sql: formattedSql }) - } + const formattedSql = formatSql(sql) + editor.replaceAll(formattedSql, 'apply-prettify-edit') + snapV2.setSql({ id, sql: formattedSql }) } - }, [editorRef, id, isDiffOpen, project, snapV2]) + }, [editor, id, isDiffOpen, project, snapV2]) } diff --git a/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.ts b/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.ts index 9d4f303bf0324..30d8a57ad0045 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.ts @@ -2,6 +2,7 @@ import { useParams } from 'common' import { useMemo } from 'react' import { generateSnippetTitle } from './SQLEditor.constants' +import { deriveSnippetIdentity } from './SQLEditor.utils' import { generateUuid } from '@/lib/api/snippets.browser' import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' @@ -23,13 +24,11 @@ export function useSnippetIdentity() { return [name, generateUuid([`${name}.sql`])] }, [urlId]) - // the id is stable across renders - it depends either on the url or on the memoized generated id - const id = !urlId || urlId === 'new' ? generatedId : urlId - - const snippetIsLoading = !( - id in snapV2.snippets && snapV2.snippets[id].snippet.content !== undefined - ) - const isLoading = urlId === 'new' ? false : snippetIsLoading + const { id, isLoading } = deriveSnippetIdentity({ + urlId, + generatedId, + snippets: snapV2.snippets, + }) return { id, urlId, generatedNewSnippetName, isLoading } } diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts index ca0fec82805b9..5b41e45e6478f 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts @@ -4,12 +4,15 @@ import { useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react import { toast } from 'sonner' import type { useSqlEditorDiff, useSqlEditorPrompt } from './hooks' -import { sqlAiDisclaimerComment } from './SQLEditor.constants' import { DiffType, type IStandaloneDiffEditor } from './SQLEditor.types' import { assembleCompletionDiff, + buildCompletionRequestBody, + buildDebugChatArgs, buildDebugPromptText, createSqlSnippetSkeletonV2, + extractDebugContext, + planDiffRequestApplication, } from './SQLEditor.utils' import { useSQLEditorContext } from './SQLEditorContext' import { useSnippetTitleGenerator } from './useSnippetTitleGenerator' @@ -55,7 +58,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi } = diff const { promptState, setPromptState, resetPrompt } = prompt - const { editorRef, diffEditorRef, refocusEditor } = useSQLEditorContext() + const { editor, diff: diffController, refocusEditor } = useSQLEditorContext() const router = useRouter() const { ref } = useParams() @@ -100,10 +103,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi const buildDebugPrompt = useCallback(() => { const snippet = snapV2.snippets[id] const result = sessionSnap.results[id]?.[0] - const sql = (snippet?.snippet.content?.unchecked_sql ?? '') - .replace(sqlAiDisclaimerComment, '') - .trim() - const errorMessage = result?.error?.message ?? 'Unknown error' + const { sql, errorMessage } = extractDebugContext(snippet, result) return buildDebugPromptText(sql, errorMessage) }, [id, sessionSnap.results, snapV2.snippets]) @@ -113,13 +113,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi const snippet = snapV2.snippets[id] const result = sessionSnap.results[id]?.[0] openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) - aiSnap.newChat({ - name: 'Debug SQL snippet', - sqlSnippets: [ - (snippet.snippet.content?.unchecked_sql ?? '').replace(sqlAiDisclaimerComment, '').trim(), - ], - initialInput: `Help me to debug the attached sql snippet which gives the following error: \n\n${result.error.message}`, - }) + aiSnap.newChat(buildDebugChatArgs(snippet, result)) } catch (error: unknown) { // [Joshen] There's a tendency for the SQL debug to chuck a lengthy error message // that's not relevant for the user - so we prettify it here by avoiding to return the @@ -137,25 +131,16 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi setIsAcceptDiffLoading(true) // TODO: show error if undefined - if (!sourceSqlDiff || !editorRef.current || !diffEditorRef.current) return + if (!sourceSqlDiff || !editor.isReady() || !diffController.isMounted()) return - const editorModel = editorRef.current.getModel() - const diffModel = diffEditorRef.current.getModel() - - if (!editorModel || !diffModel) return - - const sql = diffModel.modified.getValue() + const sql = diffController.getModifiedValue() + if (sql === undefined) return if (selectedDiffType === DiffType.NewSnippet) { const { title } = await generateSqlTitle({ sql }) await handleNewQuery(sql, title) } else { - editorRef.current.executeEdits('apply-ai-edit', [ - { - text: sql, - range: editorModel.getFullModelRange(), - }, - ]) + editor.replaceAll(sql, 'apply-ai-edit') } track('assistant_sql_diff_handler_evaluated', { handlerAccepted: true }) @@ -168,8 +153,8 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi setIsAcceptDiffLoading(false) } }, [ - editorRef, - diffEditorRef, + editor, + diffController, sourceSqlDiff, selectedDiffType, generateSqlTitle, @@ -206,13 +191,14 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi 'Content-Type': 'application/json', ...(options?.headers ?? {}), }, - body: JSON.stringify({ - projectRef: project?.ref, - connectionString: project?.connectionString, - language: 'sql', - orgSlug: org?.slug, - ...(options?.body ?? {}), - }), + body: JSON.stringify( + buildCompletionRequestBody({ + projectRef: project?.ref, + connectionString: project?.connectionString, + orgSlug: org?.slug, + options: options?.body, + }) + ), }) if (!response.ok) { @@ -289,11 +275,11 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi ) const handleDiffEditorMount = useCallback( - (editor: IStandaloneDiffEditor) => { - diffEditorRef.current = editor + (mountedDiffEditor: IStandaloneDiffEditor) => { + diffController.attach(mountedDiffEditor) setIsDiffEditorMounted(true) }, - [diffEditorRef] + [diffController] ) const resetDiff = useEffectEvent(() => { @@ -308,19 +294,14 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]) - useEffect(() => { + const syncDiffEditor = useEffectEvent(() => { if (isDiffOpen) { - const diffEditor = diffEditorRef.current - const model = diffEditor?.getModel() - if (model && model.original && model.modified) { - model.original.setValue(defaultSqlDiff.original) - model.modified.setValue(defaultSqlDiff.modified) - // scroll to the start line of the modification - const modifiedEditor = diffEditor!.getModifiedEditor() - const startLine = promptState.startLineNumber - modifiedEditor.revealLineInCenter(startLine) - } + diffController.setDiff(defaultSqlDiff, promptState.startLineNumber) } + }) + useEffect(() => { + syncDiffEditor() + // Temporary until we update eslint to ignore useEffectEvent // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedDiffType, sourceSqlDiff]) @@ -328,26 +309,18 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi const request = diffRequest.pending if (request === undefined) return - const editorModel = editorRef.current?.getModel() // Editor isn't ready yet; leave the request pending. editorMountCount bumps // on mount and re-runs this effect, so the request applies once mounted. - if (!editorModel) return + if (!editor.isReady()) return - const { diffType, sql } = request - const existingValue = editorRef.current?.getValue() ?? '' - if (existingValue.length === 0) { + const existingValue = editor.getValue() ?? '' + const plan = planDiffRequestApplication({ existingValue, request }) + if (plan.kind === 'replace') { // if the editor is empty, just copy over the code - editorRef.current?.executeEdits('apply-ai-message', [ - { - text: `${sql}`, - range: editorModel.getFullModelRange(), - }, - ]) + editor.replaceAll(plan.text, 'apply-ai-message') } else { - const currentSql = editorRef.current?.getValue() - const diff = { original: currentSql || '', modified: sql } - setSourceSqlDiff(diff) - setSelectedDiffType(diffType) + setSourceSqlDiff(plan.diff) + setSelectedDiffType(plan.diffType) } // One-shot: drain the request so it can't re-apply to a later editor or session. @@ -365,11 +338,11 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi if (!isDiffOpen) { setIsDiffEditorMounted(false) setShowWidget(false) - } else if (diffEditorRef.current && isDiffEditorMounted) { + } else if (diffController.isMounted() && isDiffEditorMounted) { setShowWidget(true) return () => setShowWidget(false) } - }, [diffEditorRef, isDiffOpen, isDiffEditorMounted]) + }, [diffController, isDiffOpen, isDiffEditorMounted]) return useMemo( () => ({ diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts index 1cc661c0db7da..7ded54949db57 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts @@ -40,13 +40,7 @@ export function useSqlEditorExecution({ setAiTitle, }: UseSqlEditorExecutionArgs) { const { ref } = useParams() - const { - editorRef, - clearPendingRunRefocus, - refocusEditorAfterRunIfNeeded, - clearHighlights, - applyErrorHighlight, - } = useSQLEditorContext() + const { editor, clearPendingRunRefocus, refocusEditorAfterRunIfNeeded } = useSQLEditorContext() const { data: project } = useSelectedProjectQuery() const queryClient = useQueryClient() @@ -80,7 +74,7 @@ export function useSqlEditorExecution({ }, onError(error: any, vars) { if (id) { - applyErrorHighlight(error, hasSelection) + editor.highlightErrorLine(error, hasSelection) sessionSnap.addResultError(id, error, vars.autoLimit) } @@ -95,7 +89,7 @@ export function useSqlEditorExecution({ return } - if (editorRef.current === null || isExecuting || project === undefined) { + if (!editor.isReady() || isExecuting || project === undefined) { clearPendingRunRefocus() return } @@ -120,7 +114,7 @@ export function useSqlEditorExecution({ setAiTitle(id, sql) } - clearHighlights() + editor.clearHighlights() const impersonatedRoleState = getImpersonatedRoleState() const connectionString = resolveConnectionString( @@ -148,8 +142,7 @@ export function useSqlEditorExecution({ track('sql_editor_query_run_button_clicked') }, [ - editorRef, - clearHighlights, + editor, clearPendingRunRefocus, isDiffOpen, id, diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts index d5c95518c8b27..cb4fff44419a8 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts @@ -2,6 +2,7 @@ import { useParams } from 'common' import { useRouter } from 'next/router' import { useCallback, useEffect } from 'react' +import { resolveDiffKeyAction } from './SQLEditor.utils' import { useSQLEditorContext } from './SQLEditorContext' import { detectOS } from '@/lib/helpers' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' @@ -32,7 +33,7 @@ export function useSqlEditorShortcuts({ const os = detectOS() const router = useRouter() const { ref } = useParams() - const { editorRef, refocusEditor } = useSQLEditorContext() + const { editor, refocusEditor } = useSQLEditorContext() const openNewSnippet = useCallback(() => { if (!ref) return @@ -56,23 +57,23 @@ export function useSqlEditorShortcuts({ useEffect(() => { const handler = (e: KeyboardEvent) => { - if (!isDiffOpen && !isPromptOpen) return + const action = resolveDiffKeyAction(e, { isDiffOpen, isPromptOpen, os }) - switch (e.key) { - case 'Enter': - if ((os === 'macos' ? e.metaKey : e.ctrlKey) && isDiffOpen) { - acceptAiHandler() - resetPrompt() - } + switch (action.type) { + case 'accept': + acceptAiHandler() + resetPrompt() return - case 'Escape': - if (isDiffOpen) discardAiHandler() + case 'escape': + if (action.shouldDiscard) discardAiHandler() resetPrompt() - editorRef.current?.focus() + editor.focus() + return + case 'none': return } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) - }, [editorRef, os, isDiffOpen, isPromptOpen, acceptAiHandler, discardAiHandler, resetPrompt]) + }, [editor, os, isDiffOpen, isPromptOpen, acceptAiHandler, discardAiHandler, resetPrompt]) } diff --git a/apps/studio/state/sql-editor/sql-editor-state.test.ts b/apps/studio/state/sql-editor/sql-editor-state.test.ts new file mode 100644 index 0000000000000..ba59013f006cc --- /dev/null +++ b/apps/studio/state/sql-editor/sql-editor-state.test.ts @@ -0,0 +1,72 @@ +import { untrustedSql } from '@supabase/pg-meta' +import { beforeEach, describe, expect, it } from 'vitest' + +import { sqlEditorState } from './sql-editor-state' +import type { SnippetWithContent } from '@/data/content/sql-folders-query' + +function makeSnippet( + id: string, + overrides: Omit, 'content'> = {} +): SnippetWithContent { + return { + id, + type: 'sql', + name: 'My Query', + description: 'A description', + visibility: 'user', + project_id: 42, + owner_id: 7, + folder_id: null, + favorite: false, + status: 'saved', + inserted_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-01T00:00:00.000Z', + ...overrides, + content: { + content_id: id, + schema_version: '1', + unchecked_sql: untrustedSql('SELECT * FROM users;'), + }, + } +} + +describe('addFavorite / removeFavorite', () => { + beforeEach(() => { + // sqlEditorState is a module-level singleton, so reset the state these tests touch + for (const id of Object.keys(sqlEditorState.snippets)) { + delete sqlEditorState.snippets[id] + } + sqlEditorState.needsSaving.clear() + }) + + it('marks a loaded snippet as favorite and queues it for saving', () => { + sqlEditorState.addSnippet({ projectRef: 'ref', snippet: makeSnippet('snippet-1') }) + + sqlEditorState.addFavorite('snippet-1') + + expect(sqlEditorState.snippets['snippet-1'].snippet.favorite).toBe(true) + expect(sqlEditorState.needsSaving.get('snippet-1')).toBe(true) + }) + + it('unmarks a favorited snippet and queues it for saving', () => { + sqlEditorState.addSnippet({ + projectRef: 'ref', + snippet: makeSnippet('snippet-1', { favorite: true }), + }) + + sqlEditorState.removeFavorite('snippet-1') + + expect(sqlEditorState.snippets['snippet-1'].snippet.favorite).toBe(false) + expect(sqlEditorState.needsSaving.get('snippet-1')).toBe(true) + }) + + it('ignores addFavorite for a snippet that is not in the store', () => { + expect(() => sqlEditorState.addFavorite('missing')).not.toThrow() + expect(sqlEditorState.needsSaving.has('missing')).toBe(false) + }) + + it('ignores removeFavorite for a snippet that is not in the store', () => { + expect(() => sqlEditorState.removeFavorite('missing')).not.toThrow() + expect(sqlEditorState.needsSaving.has('missing')).toBe(false) + }) +}) diff --git a/apps/studio/state/sql-editor/sql-editor-state.ts b/apps/studio/state/sql-editor/sql-editor-state.ts index 6e1c6cdc5458d..c0073c6df9f74 100644 --- a/apps/studio/state/sql-editor/sql-editor-state.ts +++ b/apps/studio/state/sql-editor/sql-editor-state.ts @@ -257,7 +257,7 @@ export const sqlEditorState = proxy({ removeFavorite: (id: string) => { const storeSnippet = sqlEditorState.snippets[id] - if (storeSnippet.snippet) { + if (storeSnippet) { storeSnippet.snippet.favorite = false sqlEditorState.needsSaving.set(id, true) } diff --git a/apps/www/components/Blog/AuthorAvatars.tsx b/apps/www/components/Blog/AuthorAvatars.tsx index a0da27b800237..fc3fbf3a73277 100644 --- a/apps/www/components/Blog/AuthorAvatars.tsx +++ b/apps/www/components/Blog/AuthorAvatars.tsx @@ -1,4 +1,5 @@ import Image from 'next/image' +import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' import type Author from '@/types/author' @@ -8,40 +9,53 @@ interface Props { size?: 'sm' | 'md' } +const MAX_VISIBLE_AVATARS = 4 + export default function AuthorAvatars({ authors, showName = true, size = 'sm' }: Props) { const valid = authors.filter(Boolean) as Author[] if (!valid.length) return null const px = size === 'md' ? 'w-6 h-6' : 'w-5 h-5' + const visibleAvatars = valid.slice(0, MAX_VISIBLE_AVATARS) + const allNames = valid.map((a) => a.author).join(', ') - return ( -
-
- {valid.map((author, i) => ( -
- {author.author_image_url && ( - {showName - )} + const nameLabel = + valid.length > 2 + ? `${valid[0].author}, +${valid.length - 1} other${valid.length - 1 > 1 ? 's' : ''}` + : allNames + + const content = ( +
+
+ {visibleAvatars.map((author, i) => ( +
+
+ {author.author_image_url && ( + {showName + )} +
))}
- {showName && ( -

- {valid.map((a) => a.author).join(', ')} -

- )} + {showName &&

{nameLabel}

}
) + + return ( + + {content} + + {allNames} + + + ) } diff --git a/apps/www/components/Blog/FeaturedThumb.tsx b/apps/www/components/Blog/FeaturedThumb.tsx index fdf89004cc0bd..8692e69c901a7 100644 --- a/apps/www/components/Blog/FeaturedThumb.tsx +++ b/apps/www/components/Blog/FeaturedThumb.tsx @@ -51,7 +51,7 @@ function renderFeaturedThumb(blog: PostTypes, author: any[]) {
{/* Text */} -
+

{blog.title} @@ -59,18 +59,20 @@ function renderFeaturedThumb(blog: PostTypes, author: any[]) {

{blog.description}

-
-
+
+
Author:
-
+
Published {blog.formattedDate} - - {blog.readingTime} + + {blog.readingTime}
diff --git a/apps/www/package.json b/apps/www/package.json index b1e2deea0f6e0..404c0d41de581 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -6,7 +6,7 @@ "scripts": { "preinstall": "npx only-allow pnpm", "dev": "pnpm run content:build && next --port 3000", - "prebuild": "pnpm --filter=docs run build:guides-markdown", + "prebuild": "pnpm --filter=docs run build:federated-content && pnpm --filter=docs run build:guides-markdown", "build": "pnpm run content:build && next build", "export": "next export", "start": "next start", diff --git a/e2e/docs/.gitignore b/e2e/docs/.gitignore new file mode 100644 index 0000000000000..68c5d18f00dcb --- /dev/null +++ b/e2e/docs/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/docs/README.md b/e2e/docs/README.md new file mode 100644 index 0000000000000..deb4ca41c8a5f --- /dev/null +++ b/e2e/docs/README.md @@ -0,0 +1,83 @@ +# Supabase Docs E2E Tests + +Playwright end-to-end tests for the docs site under `apps/docs`. +Add new docs journeys under `features/`. They all run together as one suite. + +## Setup + +Install the Playwright browser once from this directory: + +```bash +cd e2e/docs +pnpm exec playwright install chromium +``` + +## Choosing a target URL + +Tests run against whatever `PLAYWRIGHT_BASE_URL` points to, defaulting to the +local docs dev server at `http://localhost:3001`. + +- **Local docs server** — in a separate terminal, start docs from the repo root: + + ```bash + pnpm dev:docs + ``` + +- **A deployed site** — set the base URL inline: + + ```bash + PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs + ``` + +If the target is a protected Vercel preview, also set +`VERCEL_AUTOMATION_BYPASS_SECRET` so the tests can bypass deployment protection. + +The local docs server requires a full monorepo install and credentials for some +content, so the quickest way to run the suite is against a deployed site. Reach +for the local server only when you need to test unpublished content changes. + +## Running the tests + +From the repo root: + +```bash +pnpm e2e:docs +``` + +Or from this directory: + +```bash +pnpm run e2e:docs +``` + +### UI mode for debugging + +```bash +pnpm e2e:docs:ui +``` + +### Run a single file + +```bash +pnpm run e2e:docs -- features/.spec.ts +``` + +## Debugging + +- View the HTML report after a run: + + ```bash + pnpm -C e2e/docs exec playwright show-report + ``` + +- Traces and screenshots for failures are saved in `test-results/`. + +## CI + +`.github/workflows/docs-e2e.yml` runs this suite on pull requests that touch the +tested docs pages or `e2e/docs`. When the PR changes `apps/docs`, the workflow +waits for the matching Vercel preview and points `PLAYWRIGHT_BASE_URL` at it. +When only the harness or workflow changes, Vercel skips the docs preview, so +the suite falls back to production. Draft PRs are skipped until marked ready +for review. The workflow can also be triggered manually with an optional +`base_url` input that defaults to production. diff --git a/e2e/docs/features/quickstarts.spec.ts b/e2e/docs/features/quickstarts.spec.ts new file mode 100644 index 0000000000000..afc09c4637067 --- /dev/null +++ b/e2e/docs/features/quickstarts.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test' + +import { collectDocsOwnedLinks } from '../utils/docs-links.js' + +const QUICKSTART_PATH = '/docs/guides/getting-started/quickstarts/nextjs' +const ARTICLE_SELECTOR = '#sb-docs-guide-main-article' + +test.describe('Next.js quickstart', () => { + test('loads and docs-owned article links resolve', async ({ page }, testInfo) => { + const baseURL = testInfo.project.use.baseURL + expect(baseURL, 'A Playwright base URL should be configured').toBeTruthy() + + const response = await page.goto(QUICKSTART_PATH) + expect(response, `Expected a response for ${QUICKSTART_PATH}`).not.toBeNull() + expect( + response!.ok(), + `Quickstart page should return a successful status, got ${response!.status()}` + ).toBeTruthy() + + const article = page.locator(ARTICLE_SELECTOR) + await expect(article, 'Guide article should be present').toBeVisible() + await expect( + article.getByRole('heading', { level: 1 }), + 'Guide article should include an h1' + ).toBeVisible() + + const links = await collectDocsOwnedLinks(page, baseURL!) + + for (const url of links) { + try { + const linkResponse = await page.request.get(url) + expect + .soft(linkResponse.ok(), `${url} should resolve (status ${linkResponse.status()})`) + .toBeTruthy() + } catch (error) { + expect + .soft( + null, + `${url} should be reachable (${error instanceof Error ? error.message : error})` + ) + .toBeTruthy() + } + } + }) +}) diff --git a/e2e/docs/package.json b/e2e/docs/package.json new file mode 100644 index 0000000000000..3a53ee4976ee4 --- /dev/null +++ b/e2e/docs/package.json @@ -0,0 +1,13 @@ +{ + "name": "e2e-docs", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "e2e:docs": "playwright test", + "e2e:ui": "playwright test --ui" + }, + "dependencies": { + "@playwright/test": "^1.59.1" + } +} diff --git a/e2e/docs/playwright.config.ts b/e2e/docs/playwright.config.ts new file mode 100644 index 0000000000000..27712136db900 --- /dev/null +++ b/e2e/docs/playwright.config.ts @@ -0,0 +1,50 @@ +import { defineConfig } from '@playwright/test' + +const IS_CI = !!process.env.CI + +export default defineConfig({ + testDir: './features', + testMatch: /.*\.spec\.ts/, + timeout: 60_000, + forbidOnly: IS_CI, + retries: IS_CI ? 2 : 0, + maxFailures: 3, + expect: { + timeout: 15_000, + }, + fullyParallel: false, + workers: 1, + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3001', + browserName: 'chromium', + headless: true, + navigationTimeout: 30_000, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + video: 'off', + extraHTTPHeaders: process.env.VERCEL_AUTOMATION_BYPASS_SECRET + ? { + 'x-vercel-protection-bypass': process.env.VERCEL_AUTOMATION_BYPASS_SECRET, + 'x-vercel-set-bypass-cookie': 'true', + } + : undefined, + }, + projects: [ + { + name: 'Features', + testDir: './features', + testMatch: /.*\.spec\.ts/, + use: { + browserName: 'chromium', + }, + }, + ], + reporter: IS_CI + ? [['list'], ['html', { open: 'never', outputFolder: './playwright-report' }]] + : [ + ['list'], + ['html', { open: 'never', outputFolder: './playwright-report' }], + ['json', { outputFile: './test-results/test-results.json' }], + ], + outputDir: './test-results', +}) diff --git a/e2e/docs/tsconfig.json b/e2e/docs/tsconfig.json new file mode 100644 index 0000000000000..d59602689dc22 --- /dev/null +++ b/e2e/docs/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "skipLibCheck": true, + "esModuleInterop": true, + "strict": true + } +} diff --git a/e2e/docs/utils/docs-links.ts b/e2e/docs/utils/docs-links.ts new file mode 100644 index 0000000000000..5796b0b84ce4d --- /dev/null +++ b/e2e/docs/utils/docs-links.ts @@ -0,0 +1,42 @@ +import type { Page } from '@playwright/test' + +const ARTICLE_SELECTOR = '#sb-docs-guide-main-article' +const DOCS_PATH_PREFIX = '/docs' + +/** + * Collect unique docs-owned links from the main guide article. + * + * Cross-app paths such as `/ui` and `/dashboard` are excluded because the + * docs preview does not own those routes. + */ +export async function collectDocsOwnedLinks(page: Page, baseURL: string): Promise { + const origin = new URL(baseURL).origin + const hrefs = await page + .locator(`${ARTICLE_SELECTOR} a[href]`) + .evaluateAll((anchors) => + anchors.map((anchor) => (anchor as HTMLAnchorElement).getAttribute('href') ?? '') + ) + const links = new Set() + + for (const href of hrefs) { + if (!href || href.startsWith('#')) continue + + let url: URL + try { + url = new URL(href, baseURL) + } catch { + continue + } + + if (!['http:', 'https:'].includes(url.protocol)) continue + if (url.origin !== origin) continue + if (url.pathname !== DOCS_PATH_PREFIX && !url.pathname.startsWith(`${DOCS_PATH_PREFIX}/`)) { + continue + } + + url.hash = '' + links.add(url.toString()) + } + + return [...links].sort() +} diff --git a/package.json b/package.json index e76e4c7dd3cc6..7ccfdf72a2251 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "e2e:setup:platform": "SKIP_ASSET_UPLOAD=1 NODE_OPTIONS=\"--max-old-space-size=4096\" pnpm run build:studio && pnpm --prefix ./apps/studio start", "e2e": "pnpm --prefix e2e/studio run e2e", "e2e:ui": "pnpm --prefix e2e/studio run e2e:ui", + "e2e:docs": "pnpm --prefix e2e/docs run e2e:docs", + "e2e:docs:ui": "pnpm --prefix e2e/docs run e2e:ui", "perf:kong": "ab -t 5 -c 20 -T application/json http://localhost:8000/", "perf:meta": "ab -t 5 -c 20 -T application/json http://localhost:5555/tables", "setup:cli": "supabase start -x studio && supabase status --output json > keys.json && node scripts/generateLocalEnv.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c64b8b7953d51..3a8f291d2a4b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2007,6 +2007,12 @@ importers: specifier: ^7.3.2 version: 7.3.5(@types/node@22.13.14)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.22.4)(yaml@2.9.0) + e2e/docs: + dependencies: + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 + e2e/studio: dependencies: '@playwright/test':