From 133ca57628a428677b546993984f21780cd1dd9e Mon Sep 17 00:00:00 2001 From: Will Chen Date: Tue, 14 Oct 2025 15:34:42 -0700 Subject: [PATCH] Support Supabase branches (#1394) ## Summary by cubic Adds Supabase database branch selection per app, with a new schema field and UI to choose a branch after connecting a project. Resets branch when changing or disconnecting the project to keep state consistent. - **New Features** - Added apps.supabase_branch_id column. - Branch dropdown in SupabaseConnector shown after a project is connected; selection persists and triggers app refresh. - New state and hooks: supabaseBranchesAtom, loadBranches(projectId), setAppBranch(branchId). - IPC endpoints: supabase:list-branches and supabase:set-app-branch; setting/unsetting project also clears the branch. - **Migration** - Apply drizzle migration 0013_supabase_branch.sql to add the supabase_branch_id column (defaults to null). --- > [!NOTE] > Adds Supabase database branch selection per app, including parent project tracking, new IPC endpoints, UI dropdown, and an accompanying DB migration with e2e tests. > > - **Database**: > - Add `apps.supabase_parent_project_id` via migration `drizzle/0015_complete_old_lace.sql`; snapshot and journal updated. > - **IPC/Main**: > - New `supabase:list-branches` handler and management client `listSupabaseBranches` (real API + test stubs). > - Update `supabase:set-app-project` to accept `{ projectId, parentProjectId?, appId }`; unset clears both IDs. > - `get-app` resolves `supabaseProjectName` using `supabase_parent_project_id` when present. > - **Types & Client**: > - Add `SupabaseBranch`, `SetSupabaseAppProjectParams`, and `App.supabaseParentProjectId`; expose `listSupabaseBranches` and updated `setSupabaseAppProject` in `ipc_client` and preload whitelist. > - **UI/Hooks**: > - Supabase UI: branch dropdown in `SupabaseConnector` with `loadBranches`, selection persists via updated `setAppProject`. > - State: add `supabaseBranchesAtom`; `useSupabase` gets `branches`, `loadBranches`, new param shape for `setAppProject`. > - TokenBar/ChatInput: add `data-testid` for token bar and toggle. > - **Supabase Context (tests)**: > - Test build returns large context for `test-branch-project-id` to validate branch selection. > - **E2E Tests**: > - Add `supabase_branch.spec.ts` and snapshot verifying branch selection affects token usage. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 33054278db8396b4371ed6e8224105cb5684b7ac. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- drizzle/0015_complete_old_lace.sql | 1 + drizzle/meta/0015_snapshot.json | 753 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + ...supabase-branch-selection-works-1.aria.yml | 1 + e2e-tests/supabase_branch.spec.ts | 25 + src/atoms/supabaseAtoms.ts | 2 + src/components/SupabaseConnector.tsx | 65 +- src/components/chat/ChatInput.tsx | 1 + src/components/chat/TokenBar.tsx | 2 +- src/db/schema.ts | 6 + src/hooks/useSupabase.ts | 29 +- src/ipc/handlers/app_handlers.ts | 4 +- src/ipc/handlers/supabase_handlers.ts | 45 +- src/ipc/ipc_client.ts | 16 +- src/ipc/ipc_types.ts | 15 + src/preload.ts | 1 + src/supabase_admin/supabase_context.ts | 3 + .../supabase_management_client.ts | 55 ++ 18 files changed, 1011 insertions(+), 20 deletions(-) create mode 100644 drizzle/0015_complete_old_lace.sql create mode 100644 drizzle/meta/0015_snapshot.json create mode 100644 e2e-tests/snapshots/supabase_branch.spec.ts_supabase-branch-selection-works-1.aria.yml create mode 100644 e2e-tests/supabase_branch.spec.ts diff --git a/drizzle/0015_complete_old_lace.sql b/drizzle/0015_complete_old_lace.sql new file mode 100644 index 0000000..4b8f784 --- /dev/null +++ b/drizzle/0015_complete_old_lace.sql @@ -0,0 +1 @@ +ALTER TABLE `apps` ADD `supabase_parent_project_id` text; \ No newline at end of file diff --git a/drizzle/meta/0015_snapshot.json b/drizzle/meta/0015_snapshot.json new file mode 100644 index 0000000..7b19023 --- /dev/null +++ b/drizzle/meta/0015_snapshot.json @@ -0,0 +1,753 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "41549b62-b247-48d5-90e1-6bfc70f02040", + "prevId": "340e33e4-c82c-44fb-afda-29943bd6bf62", + "tables": { + "apps": { + "name": "apps", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_repo": { + "name": "github_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_branch": { + "name": "github_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supabase_project_id": { + "name": "supabase_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supabase_parent_project_id": { + "name": "supabase_parent_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "neon_project_id": { + "name": "neon_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "neon_development_branch_id": { + "name": "neon_development_branch_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "neon_preview_branch_id": { + "name": "neon_preview_branch_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vercel_project_id": { + "name": "vercel_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vercel_project_name": { + "name": "vercel_project_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vercel_team_id": { + "name": "vercel_team_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vercel_deployment_url": { + "name": "vercel_deployment_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "install_command": { + "name": "install_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_command": { + "name": "start_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_context": { + "name": "chat_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_favorite": { + "name": "is_favorite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "0" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chats": { + "name": "chats", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "app_id": { + "name": "app_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initial_commit_hash": { + "name": "initial_commit_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "chats_app_id_apps_id_fk": { + "name": "chats_app_id_apps_id_fk", + "tableFrom": "chats", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "language_model_providers": { + "name": "language_model_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_base_url": { + "name": "api_base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "env_var_name": { + "name": "env_var_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "language_models": { + "name": "language_models", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_name": { + "name": "api_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "builtin_provider_id": { + "name": "builtin_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_provider_id": { + "name": "custom_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "language_models_custom_provider_id_language_model_providers_id_fk": { + "name": "language_models_custom_provider_id_language_model_providers_id_fk", + "tableFrom": "language_models", + "tableTo": "language_model_providers", + "columnsFrom": [ + "custom_provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_servers": { + "name": "mcp_servers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "args": { + "name": "args", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "env_json": { + "name": "env_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "0" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_tool_consents": { + "name": "mcp_tool_consents", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "server_id": { + "name": "server_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consent": { + "name": "consent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ask'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "uniq_mcp_consent": { + "name": "uniq_mcp_consent", + "columns": [ + "server_id", + "tool_name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "mcp_tool_consents_server_id_mcp_servers_id_fk": { + "name": "mcp_tool_consents_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tool_consents", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approval_state": { + "name": "approval_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_hash": { + "name": "commit_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prompts": { + "name": "prompts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "app_id": { + "name": "app_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "commit_hash": { + "name": "commit_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "neon_db_timestamp": { + "name": "neon_db_timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "versions_app_commit_unique": { + "name": "versions_app_commit_unique", + "columns": [ + "app_id", + "commit_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "versions_app_id_apps_id_fk": { + "name": "versions_app_id_apps_id_fk", + "tableFrom": "versions", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 866dc9c..1e2a0d2 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1760034009367, "tag": "0014_needy_vertigo", "breakpoints": true + }, + { + "idx": 15, + "version": "6", + "when": 1760474402750, + "tag": "0015_complete_old_lace", + "breakpoints": true } ] } \ No newline at end of file diff --git a/e2e-tests/snapshots/supabase_branch.spec.ts_supabase-branch-selection-works-1.aria.yml b/e2e-tests/snapshots/supabase_branch.spec.ts_supabase-branch-selection-works-1.aria.yml new file mode 100644 index 0000000..66d065e --- /dev/null +++ b/e2e-tests/snapshots/supabase_branch.spec.ts_supabase-branch-selection-works-1.aria.yml @@ -0,0 +1 @@ +- text: "/Tokens: \\d+,\\d+ \\d+% of [\\d,.]+[bkmBKM]+ Optimize your tokens with Dyad Pro's Smart Context/" \ No newline at end of file diff --git a/e2e-tests/supabase_branch.spec.ts b/e2e-tests/supabase_branch.spec.ts new file mode 100644 index 0000000..97112ab --- /dev/null +++ b/e2e-tests/supabase_branch.spec.ts @@ -0,0 +1,25 @@ +import { testSkipIfWindows } from "./helpers/test_helper"; +import { expect } from "@playwright/test"; + +testSkipIfWindows("supabase branch selection works", async ({ po }) => { + await po.setUp({ autoApprove: true }); + await po.importApp("minimal"); + await po.sendPrompt("tc=add-supabase"); + + // Connect to Supabase + await po.page.getByText("Set up supabase").click(); + await po.clickConnectSupabaseButton(); + await po.clickBackButton(); + await po.page.getByTestId("token-bar-toggle").click(); + // The default branch has a small context. + await expect(po.page.getByTestId("token-bar")).toContainText("6% of 128K"); + + await po.getTitleBarAppNameButton().click(); + await po.page.getByTestId("supabase-branch-select").click(); + await po.page.getByRole("option", { name: "Test Branch" }).click(); + + await po.clickBackButton(); + // The test branch has a large context (200k tokens) so it'll hit the 100% limit. + // This is to make sure we're connecting to the right supabase project for the branch. + await expect(po.page.getByTestId("token-bar")).toContainText("100% of 128K"); +}); diff --git a/src/atoms/supabaseAtoms.ts b/src/atoms/supabaseAtoms.ts index 999e88f..7f38406 100644 --- a/src/atoms/supabaseAtoms.ts +++ b/src/atoms/supabaseAtoms.ts @@ -1,7 +1,9 @@ import { atom } from "jotai"; +import { SupabaseBranch } from "@/ipc/ipc_types"; // Define atom for storing the list of Supabase projects export const supabaseProjectsAtom = atom([]); +export const supabaseBranchesAtom = atom([]); // Define atom for tracking loading state export const supabaseLoadingAtom = atom(false); diff --git a/src/components/SupabaseConnector.tsx b/src/components/SupabaseConnector.tsx index 79c5969..ead7944 100644 --- a/src/components/SupabaseConnector.tsx +++ b/src/components/SupabaseConnector.tsx @@ -56,6 +56,8 @@ export function SupabaseConnector({ appId }: { appId: number }) { loading, error, loadProjects, + branches, + loadBranches, setAppProject, unsetAppProject, } = useSupabase(); @@ -70,7 +72,7 @@ export function SupabaseConnector({ appId }: { appId: number }) { const handleProjectSelect = async (projectId: string) => { try { - await setAppProject(projectId, appId); + await setAppProject({ projectId, appId }); toast.success("Project connected to app successfully"); await refreshApp(); } catch (error) { @@ -78,6 +80,14 @@ export function SupabaseConnector({ appId }: { appId: number }) { } }; + const projectIdForBranches = + app?.supabaseParentProjectId || app?.supabaseProjectId; + useEffect(() => { + if (projectIdForBranches) { + loadBranches(projectIdForBranches); + } + }, [projectIdForBranches, loadBranches]); + const handleUnsetProject = async () => { try { await unsetAppProject(appId); @@ -122,9 +132,56 @@ export function SupabaseConnector({ appId }: { appId: number }) { - +
+
+ + +
+ + +
); diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 4a3bbaf..394393c 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -351,6 +351,7 @@ export function ChatInput({ chatId }: { chatId?: number }) { showTokenBar ? "text-purple-500 bg-purple-100" : "" }`} size="sm" + data-testid="token-bar-toggle" > diff --git a/src/components/chat/TokenBar.tsx b/src/components/chat/TokenBar.tsx index db2eb3c..be69b1a 100644 --- a/src/components/chat/TokenBar.tsx +++ b/src/components/chat/TokenBar.tsx @@ -67,7 +67,7 @@ export function TokenBar({ chatId }: TokenBarProps) { const inputPercent = (inputTokens / contextWindow) * 100; return ( -
+
diff --git a/src/db/schema.ts b/src/db/schema.ts index 8bab1ff..6361158 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -29,6 +29,12 @@ export const apps = sqliteTable("apps", { githubRepo: text("github_repo"), githubBranch: text("github_branch"), supabaseProjectId: text("supabase_project_id"), + // If supabaseProjectId is a branch, then the parent project id set. + // This is because there's no way to retrieve ALL the branches for ALL projects + // in a single API call + // This is only used for display purposes but is NOT used for any actual + // supabase management logic. + supabaseParentProjectId: text("supabase_parent_project_id"), neonProjectId: text("neon_project_id"), neonDevelopmentBranchId: text("neon_development_branch_id"), neonPreviewBranchId: text("neon_preview_branch_id"), diff --git a/src/hooks/useSupabase.ts b/src/hooks/useSupabase.ts index f00dbcc..b203e2a 100644 --- a/src/hooks/useSupabase.ts +++ b/src/hooks/useSupabase.ts @@ -2,14 +2,17 @@ import { useCallback } from "react"; import { useAtom } from "jotai"; import { supabaseProjectsAtom, + supabaseBranchesAtom, supabaseLoadingAtom, supabaseErrorAtom, selectedSupabaseProjectAtom, } from "@/atoms/supabaseAtoms"; import { IpcClient } from "@/ipc/ipc_client"; +import { SetSupabaseAppProjectParams } from "@/ipc/ipc_types"; export function useSupabase() { const [projects, setProjects] = useAtom(supabaseProjectsAtom); + const [branches, setBranches] = useAtom(supabaseBranchesAtom); const [loading, setLoading] = useAtom(supabaseLoadingAtom); const [error, setError] = useAtom(supabaseErrorAtom); const [selectedProject, setSelectedProject] = useAtom( @@ -35,14 +38,34 @@ export function useSupabase() { } }, [ipcClient, setProjects, setError, setLoading]); + /** + * Load branches for a Supabase project + */ + const loadBranches = useCallback( + async (projectId: string) => { + setLoading(true); + try { + const list = await ipcClient.listSupabaseBranches({ projectId }); + setBranches(Array.isArray(list) ? list : []); + setError(null); + } catch (error) { + console.error("Error loading Supabase branches:", error); + setError(error instanceof Error ? error : new Error(String(error))); + } finally { + setLoading(false); + } + }, + [ipcClient, setBranches, setError, setLoading], + ); + /** * Associate a Supabase project with an app */ const setAppProject = useCallback( - async (projectId: string, appId: number) => { + async (params: SetSupabaseAppProjectParams) => { setLoading(true); try { - await ipcClient.setSupabaseAppProject(projectId, appId); + await ipcClient.setSupabaseAppProject(params); setError(null); } catch (error) { console.error("Error setting Supabase project for app:", error); @@ -87,10 +110,12 @@ export function useSupabase() { return { projects, + branches, loading, error, selectedProject, loadProjects, + loadBranches, setAppProject, unsetAppProject, selectProject, diff --git a/src/ipc/handlers/app_handlers.ts b/src/ipc/handlers/app_handlers.ts index b8a6907..590b789 100644 --- a/src/ipc/handlers/app_handlers.ts +++ b/src/ipc/handlers/app_handlers.ts @@ -724,7 +724,9 @@ export function registerAppHandlers() { let supabaseProjectName: string | null = null; const settings = readSettings(); if (app.supabaseProjectId && settings.supabase?.accessToken?.value) { - supabaseProjectName = await getSupabaseProjectName(app.supabaseProjectId); + supabaseProjectName = await getSupabaseProjectName( + app.supabaseParentProjectId || app.supabaseProjectId, + ); } let vercelTeamSlug: string | null = null; diff --git a/src/ipc/handlers/supabase_handlers.ts b/src/ipc/handlers/supabase_handlers.ts index c52045b..07b2307 100644 --- a/src/ipc/handlers/supabase_handlers.ts +++ b/src/ipc/handlers/supabase_handlers.ts @@ -2,7 +2,10 @@ import log from "electron-log"; import { db } from "../../db"; import { eq } from "drizzle-orm"; import { apps } from "../../db/schema"; -import { getSupabaseClient } from "../../supabase_admin/supabase_management_client"; +import { + getSupabaseClient, + listSupabaseBranches, +} from "../../supabase_admin/supabase_management_client"; import { createLoggedHandler, createTestOnlyLoggedHandler, @@ -10,6 +13,8 @@ import { import { handleSupabaseOAuthReturn } from "../../supabase_admin/supabase_return_handler"; import { safeSend } from "../utils/safe_sender"; +import { SetSupabaseAppProjectParams, SupabaseBranch } from "../ipc_types"; + const logger = log.scope("supabase_handlers"); const handle = createLoggedHandler(logger); const testOnlyHandle = createTestOnlyLoggedHandler(logger); @@ -20,16 +25,44 @@ export function registerSupabaseHandlers() { return supabase.getProjects(); }); + // List branches for a Supabase project (database branches) + handle( + "supabase:list-branches", + async ( + _, + { projectId }: { projectId: string }, + ): Promise> => { + const branches = await listSupabaseBranches({ + supabaseProjectId: projectId, + }); + return branches.map((branch) => ({ + id: branch.id, + name: branch.name, + isDefault: branch.is_default, + projectRef: branch.project_ref, + parentProjectRef: branch.parent_project_ref, + })); + }, + ); + // Set app project - links a Dyad app to a Supabase project handle( "supabase:set-app-project", - async (_, { project, app }: { project: string; app: number }) => { + async ( + _, + { projectId, appId, parentProjectId }: SetSupabaseAppProjectParams, + ) => { await db .update(apps) - .set({ supabaseProjectId: project }) - .where(eq(apps.id, app)); + .set({ + supabaseProjectId: projectId, + supabaseParentProjectId: parentProjectId, + }) + .where(eq(apps.id, appId)); - logger.info(`Associated app ${app} with Supabase project ${project}`); + logger.info( + `Associated app ${appId} with Supabase project ${projectId} ${parentProjectId ? `and parent project ${parentProjectId}` : ""}`, + ); }, ); @@ -37,7 +70,7 @@ export function registerSupabaseHandlers() { handle("supabase:unset-app-project", async (_, { app }: { app: number }) => { await db .update(apps) - .set({ supabaseProjectId: null }) + .set({ supabaseProjectId: null, supabaseParentProjectId: null }) .where(eq(apps.id, app)); logger.info(`Removed Supabase project association for app ${app}`); diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index 4563425..22aa775 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -66,6 +66,8 @@ import type { McpServerUpdate, CreateMcpServer, CloneRepoParams, + SupabaseBranch, + SetSupabaseAppProjectParams, } from "./ipc_types"; import type { Template } from "../shared/templates"; import type { @@ -961,14 +963,16 @@ export class IpcClient { return this.ipcRenderer.invoke("supabase:list-projects"); } + public async listSupabaseBranches(params: { + projectId: string; + }): Promise { + return this.ipcRenderer.invoke("supabase:list-branches", params); + } + public async setSupabaseAppProject( - project: string, - app: number, + params: SetSupabaseAppProjectParams, ): Promise { - await this.ipcRenderer.invoke("supabase:set-app-project", { - project, - app, - }); + await this.ipcRenderer.invoke("supabase:set-app-project", params); } public async unsetSupabaseAppProject(app: number): Promise { diff --git a/src/ipc/ipc_types.ts b/src/ipc/ipc_types.ts index a236b96..28bc544 100644 --- a/src/ipc/ipc_types.ts +++ b/src/ipc/ipc_types.ts @@ -90,6 +90,7 @@ export interface App { githubRepo: string | null; githubBranch: string | null; supabaseProjectId: string | null; + supabaseParentProjectId: string | null; supabaseProjectName: string | null; neonProjectId: string | null; neonDevelopmentBranchId: string | null; @@ -508,3 +509,17 @@ export type CloneRepoReturnType = | { error: string; }; + +export interface SupabaseBranch { + id: string; + name: string; + isDefault: boolean; + projectRef: string; + parentProjectRef: string; +} + +export interface SetSupabaseAppProjectParams { + projectId: string; + parentProjectId?: string; + appId: number; +} diff --git a/src/preload.ts b/src/preload.ts index 7843fe8..fa0bc7c 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -76,6 +76,7 @@ const validInvokeChannels = [ "reject-proposal", "get-system-debug-info", "supabase:list-projects", + "supabase:list-branches", "supabase:set-app-project", "supabase:unset-app-project", "local-models:list-ollama", diff --git a/src/supabase_admin/supabase_context.ts b/src/supabase_admin/supabase_context.ts index 5dcbd99..5b602d4 100644 --- a/src/supabase_admin/supabase_context.ts +++ b/src/supabase_admin/supabase_context.ts @@ -56,6 +56,9 @@ export async function getSupabaseContext({ supabaseProjectId: string; }) { if (IS_TEST_BUILD) { + if (supabaseProjectId === "test-branch-project-id") { + return "1234".repeat(200_000); + } return "[[TEST_BUILD_SUPABASE_CONTEXT]]"; } diff --git a/src/supabase_admin/supabase_management_client.ts b/src/supabase_admin/supabase_management_client.ts index 9555dff..de86dc5 100644 --- a/src/supabase_admin/supabase_management_client.ts +++ b/src/supabase_admin/supabase_management_client.ts @@ -168,6 +168,61 @@ export async function deleteSupabaseFunction({ ); } +export async function listSupabaseBranches({ + supabaseProjectId, +}: { + supabaseProjectId: string; +}): Promise< + Array<{ + id: string; + name: string; + is_default: boolean; + project_ref: string; + parent_project_ref: string; + }> +> { + if (IS_TEST_BUILD) { + return [ + { + id: "default-branch-id", + name: "Default Branch", + is_default: true, + project_ref: "fake-project-id", + parent_project_ref: "fake-project-id", + }, + + { + id: "test-branch-id", + name: "Test Branch", + is_default: false, + project_ref: "test-branch-project-id", + parent_project_ref: "fake-project-id", + }, + ]; + } + + logger.info(`Listing Supabase branches for project: ${supabaseProjectId}`); + const supabase = await getSupabaseClient(); + + const response = await fetch( + `https://api.supabase.com/v1/projects/${supabaseProjectId}/branches`, + { + method: "GET", + headers: { + Authorization: `Bearer ${(supabase as any).options.accessToken}`, + }, + }, + ); + + if (response.status !== 200) { + throw await createResponseError(response, "list branches"); + } + + logger.info(`Listed Supabase branches for project: ${supabaseProjectId}`); + const jsonResponse = await response.json(); + return jsonResponse; +} + export async function deploySupabaseFunctions({ supabaseProjectId, functionName,