From f5f897a29b0f2da952867e36b4017bf0b30e2252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Barroso?= Date: Fri, 28 Aug 2026 12:32:30 +0200 Subject: [PATCH 1/6] feat(functions): inject env var function slug (#49617) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Feature (self-hosted Edge Functions) ## What is the current behavior? The self-hosted Edge Functions router (`docker/volumes/functions/main/index.ts`) doesn't tell a function which slug a request resolved to. As a result, `@supabase/server`'s `withOAuthProtectedResource` can't derive its canonical resource URL and falls back to reconstructing it from the request path against the internal `api-gw` origin, so the advertised OAuth Protected Resource is /wrong for self-hosted deployments. ## What is the new behavior? `main/index.ts` now injects `SUPABASE_FUNCTION_SLUG: service_name` per request (after the `Deno.env.toObject()` snapshot, so nothing in the container env can shadow it). Combined with the operator's `SUPABASE_PUBLIC_URL`, the advertised resource is the correct external `{SUPABASE_PUBLIC_URL}/functions/v1/{slug}`, not the internal `http://api-gw:8000`. Verified on the docker stack: the slug is injected per-function, the resource origin resolves to `SUPABASE_PUBLIC_URL`, and the `401` `www-authenticate` carries the right `resource_metadata`. ## Additional context Fixes AI-1128 Companion to `@supabase/server` [PR #117](https://github.com/supabase/server/pull/117) and the [CLI slug injection](https://github.com/supabase/cli/pull/6345) ## Summary by CodeRabbit * **Bug Fixes** * Edge workers now receive the correct function slug in their runtime environment, improving per-function request handling. --- docker/volumes/functions/main/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docker/volumes/functions/main/index.ts b/docker/volumes/functions/main/index.ts index 8d00102c0ecb0..fee63d44e7596 100644 --- a/docker/volumes/functions/main/index.ts +++ b/docker/volumes/functions/main/index.ts @@ -151,7 +151,10 @@ Deno.serve(async (req: Request) => { // Using a common Import Map for all functions // to use a scope 'deno.json' it must be dinamically resolved base on the 'service_name' const importMapPath = `/home/deno/functions/deno.jsonc` - const envVarsObj = Deno.env.toObject() + // SUPABASE_FUNCTION_SLUG is listed after the container env snapshot so + // nothing in it can shadow the value, and it is per-request because only this + // worker knows which function the request resolved to. + const envVarsObj = { ...Deno.env.toObject(), SUPABASE_FUNCTION_SLUG: service_name } const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]) try { From c4fe153fd8c4bf66fffa8424bd8e599d36e52636 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 28 Aug 2026 20:21:16 +0800 Subject: [PATCH 2/6] Joshenlim/fe 4290 add save to notebook button in query tabs (#49667) ## Context Adds a "Save" action for query tabs in the explorer, which opts for 2 options to either add to an existing notebook, or create a new notebook. Both of these actions just opens the notebook in a new tab with unsaved changes - the changes will only be persisted in the DB when the user hits "Save" on the notebook. For adding to an existing notebook, the snippet will be appended to the bottom of the notebook - UI will scroll to the bottom after navigating to the notebook. image --- .../Explorer/ExplorerNotebookTab.tsx | 34 ++-- .../interfaces/Explorer/ExplorerQueryTab.tsx | 149 +++++++++++++++++- .../interfaces/Explorer/QueryEditor/index.tsx | 9 +- .../studio/state/notebooks/notebooks-state.ts | 15 ++ 4 files changed, 189 insertions(+), 18 deletions(-) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx index d4125fe3b2b47..556e2f130cdc4 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx @@ -29,7 +29,7 @@ import { Trash, } from 'lucide-react' import { useRouter } from 'next/router' -import { useRef, useState } from 'react' +import { useEffect, useEffectEvent, useRef, useState } from 'react' import { toast } from 'sonner' import { AiIconAnimation, @@ -108,6 +108,7 @@ export const ExplorerNotebookTab = () => { const [skipMutatingCells, setSkipMutatingCells] = useState(false) const queryCellRefs = useRef(new Map()) const savedContentRef = useRef(undefined) + const scrollContainerRef = useRef(null) const { mutate: updateNotebook, isPending: isUpdating } = useUpsertNotebookMutation({ onSuccess: () => { @@ -322,6 +323,17 @@ export const ExplorerNotebookTab = () => { snap.insertCellAfter({ id: notebookId, cellId: lastCellId, cell }) } + const scrollToBottomIfPending = useEffectEvent(() => { + if (!id || snap.pendingScrollToBottom !== id || !scrollContainerRef.current) return + + scrollContainerRef.current.scrollTo({ + top: scrollContainerRef.current.scrollHeight, + }) + snap.clearPendingScrollToBottom() + }) + + useEffect(() => scrollToBottomIfPending(), [id, snap.pendingScrollToBottom, content]) + if (isNotFound) { return (
@@ -358,14 +370,6 @@ export const ExplorerNotebookTab = () => { > Analyze - } - tooltip="Run notebook" - loading={isRunningNotebook} - disabled={queryCellIds.length === 0} - onClick={handleRunNotebook} - /> } @@ -397,10 +401,20 @@ export const ExplorerNotebookTab = () => { + } + tooltip="Run notebook" + loading={isRunningNotebook} + disabled={queryCellIds.length === 0} + onClick={handleRunNotebook} + > + Run + -
+
{cells.length === 0 && ( { - const { id, ref } = useParams() const router = useRouter() + const { id, ref } = useParams() const tabs = useContext(TabsStateContext) const querySnap = useExplorerQueryStateSnapshot() + const { createNotebook } = useCreateNotebook() + const notebooksSnap = useNotebooksStateSnapshot() + + const [isIntellisenseEnabled, setIsIntellisenseEnabled] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE, + true + ) + const [restoredQueryKey, setRestoredQueryKey] = useState() const [showQuery, setShowQuery] = useState(true) + const [search, setSearch] = useState('') + const debouncedSearch = useDebounce(search, 500) + + const { data: notebooksData, isPending } = useNotebooksInfiniteQuery({ + projectRef: ref, + limit: 100, + name: search.length === 0 ? search : debouncedSearch, + }) + const notebooks = useMemo(() => { + const items = notebooksData?.pages.flatMap((page) => page.content) ?? [] + return items + }, [notebooksData?.pages]) const stateDraft = id ? querySnap.drafts[id] : undefined const draft = stateDraft?.projectRef === ref ? stateDraft : undefined @@ -94,6 +138,32 @@ export const ExplorerQueryTab = () => { }) } + const onAddToNewNotebook = () => { + createNotebook({ + cells: [createQueryCellSkeleton({ title: draft.name, sql: draft.uncheckedSql })], + }) + } + + const onAddToExistingNotebook = async (notebookId: string) => { + if (!ref) return + try { + if (!notebooksSnap.notebooks[notebookId]?.notebook.content) { + const notebook = await getNotebook({ projectRef: ref, id: notebookId }) + notebooksSnap.setNotebook({ projectRef: ref, notebook }) + } + + notebooksSnap.insertCellAfter({ + id: notebookId, + cell: createQueryCellSkeleton({ title: draft.name, sql: draft.uncheckedSql }), + }) + notebooksSnap.requestScrollToBottom(notebookId) + + router.push(`/project/${ref}/explorer/notebook/${notebookId}`) + } catch (error) { + toast.error('Failed to add query to notebook') + } + } + return ( { persistTab() explorerQueryState.setDisplay({ id, display }) }} + toolbarActions={ + <> + + + } tooltip="Save query" /> + + + + Add to existing notebook + + + + + + {isPending ? ( +
+ + +
+ ) : !notebooks?.length ? ( +

+ No notebooks found +

+ ) : null} + {notebooks?.map((notebook) => ( + onAddToExistingNotebook(notebook.id)} + > + {notebook.name} + + ))} +
+
+
+
+
+ + Create a new notebook + +
+
+ + + } /> + + + setIsIntellisenseEnabled(!isIntellisenseEnabled)} + > +
+ + Intellisense enabled +
+ {isIntellisenseEnabled && } +
+
+
+ + } /> ) } diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx index 3e46de3d5c57d..8974a780ee014 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx @@ -366,13 +366,12 @@ export const QueryEditor = forwardRef(funct return ( <> - + {title} - {toolbarActions} {onSourceChange && ( (funct roleImpersonationState={roleImpersonationState} /> )} + {display && onDisplayChange && ( (funct tooltip={showQuery ? 'Hide query' : 'Show query'} onClick={() => onShowQueryChange(!showQuery)} /> + + {toolbarActions} + } + loading={isExecuting} tooltip={hasSelection ? 'Run selected query' : 'Run query'} disabled={ isBusy || pendingProposal !== null || isRunDisabled || sql.trim().length === 0 diff --git a/apps/studio/state/notebooks/notebooks-state.ts b/apps/studio/state/notebooks/notebooks-state.ts index 486399fadb152..96a7b7161512f 100644 --- a/apps/studio/state/notebooks/notebooks-state.ts +++ b/apps/studio/state/notebooks/notebooks-state.ts @@ -25,6 +25,13 @@ export const notebooksState = proxy({ cellLocalState: proxyMap([]), /** Session-only conflicts where an assistant changed the server while local edits remain. */ serverDivergedWhileDirty: proxyMap([]), + /** + * Id of the notebook the tab should scroll to the bottom of once rendered — + * set by a surface that adds a cell to a notebook it's about to navigate to + * (e.g. "Add to existing notebook" from a query tab), so the newly added + * cell lands in view. + */ + pendingScrollToBottom: undefined as string | undefined, /** * Load notebook into the Valtio store. No-ops if already present. @@ -242,6 +249,14 @@ export const notebooksState = proxy({ }, addNeedsSaving: (id: string) => notebooksState.needsSaving.set(id, true), + + requestScrollToBottom: (id: string) => { + notebooksState.pendingScrollToBottom = id + }, + + clearPendingScrollToBottom: () => { + notebooksState.pendingScrollToBottom = undefined + }, }) export const getNotebooksStateSnapshot = () => snapshot(notebooksState) From f1e0808223737e5fadf15576517c1e32536c501b Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 28 Aug 2026 21:47:36 +0800 Subject: [PATCH 3/6] Disable analyze button for notebooks if notebook is empty (#49674) ## Context As per PR title - just disables the analyze button if the notebook is empty image ## Summary by CodeRabbit * **Bug Fixes** * Disabled the Analyze action for empty notebooks. * Added guidance prompting users to add a cell before starting analysis. --- .../components/interfaces/Explorer/ExplorerNotebookTab.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx index 556e2f130cdc4..d34f51cd77c11 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx @@ -366,6 +366,8 @@ export const ExplorerNotebookTab = () => { } loading={isCreating} + disabled={cells.length === 0} + tooltip={cells.length === 0 ? 'Add a cell to the notebook to analyze it' : undefined} onClick={handleClickAnalyze} > Analyze From 95954ab81baf9b1e02735640429102ccef8c4e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Og=C3=B3rek?= Date: Fri, 28 Aug 2026 17:31:32 +0200 Subject: [PATCH 4/6] fix: attempt project wake only if its in ACTIVE_HEALTHY state (#49693) There is no point in trying to wake up a project that is not `ACTIVE_HEALTHY` as it will always fail. ## Summary by CodeRabbit * **Bug Fixes** * Improved project wake-up behavior by limiting automatic wake-ups to hibernating projects with a healthy active status. * Prevented unnecessary wake-up attempts for projects in other states. --- apps/studio/data/projects/project-detail-query.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/data/projects/project-detail-query.ts b/apps/studio/data/projects/project-detail-query.ts index a7b8a58cde6c9..2bb05a43e695f 100644 --- a/apps/studio/data/projects/project-detail-query.ts +++ b/apps/studio/data/projects/project-detail-query.ts @@ -43,7 +43,7 @@ export async function getProjectDetail( * To prevent odd side effects like pg-meta queries failing or the likes, we wake up the project proactively and wait for it * to be back online before returning the project details. */ - if (data?.is_hibernating && !skipWake) { + if (data?.status === 'ACTIVE_HEALTHY' && data?.is_hibernating && !skipWake) { // In case project was scaled down, explicitly wake it up before continuing to return the project details const { error: errorWaking, data: wakeResponse } = await post('/platform/projects/{ref}/wake', { params: { path: { ref } }, From 8790e657e999e4cf3396a0c7d8d35a9388ed9bda Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:48:38 -0400 Subject: [PATCH 5/6] feat(ai): include org slug in Assistant Braintrust span metadata (#49692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Requested by **Matt Rossman** · [Slack thread](https://supabase.slack.com/archives/D0A79RYJKRB/p1787926891744399)_ # Problem Assistant spans in Braintrust record only the numeric `orgId`, whereas support tickets show org slug. This incurs an extra manual step to resolve the ID through admin studio before the trace can be found. # Fix Adds `orgSlug` to spans, sourced from the same verified org lookup that produces `orgId`. Renamed the request body's `orgSlug` to `rawOrgSlug` to distinguish the verified slug from getAIDetails, following the existing rawRequestedModel / requestedModel pattern. ## How to review See sample trace [94863b6d-aaa9-449a-a9c9-981ad40e614a](https://www.braintrust.dev/app/supabase.io/p/Assistant/trace?object_type=project_logs&object_id=5a8d02e5-b3b6-40cc-ba76-ecee286478f4&r=223112cd-33f4-45c4-a273-8d3781689448&s=223112cd-33f4-45c4-a273-8d3781689448) produced from sending a chat from the [Preview](https://studio-staging-git-mattrossman-ai-1149-include-698a5f-supabase.vercel.app/dashboard/org) on this PR. Note it now includes the org slug in span metadata: CleanShot 2026-08-28 at 10 59 49@2x If desired you can test yourself too by chatting with Assistant in the preview and looking up the corresponding Chat ID from Braintrust [logs](https://www.braintrust.dev/app/supabase.io/p/Assistant/logs). Closes AI-1149 --- _Generated by [Claude Code](https://claude.ai/code/session_01N2ziJech9dV19pJ9MisYdX)_ --------- Co-authored-by: Claude --- apps/studio/lib/ai/ai-details.test.ts | 3 +++ apps/studio/lib/ai/ai-details.ts | 3 +++ apps/studio/lib/ai/generate-assistant-response.ts | 3 +++ apps/studio/pages/api/ai/sql/generate-v4.ts | 9 ++++++--- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/studio/lib/ai/ai-details.test.ts b/apps/studio/lib/ai/ai-details.test.ts index 56e4b61b3a2b9..343d36ea6eb32 100644 --- a/apps/studio/lib/ai/ai-details.test.ts +++ b/apps/studio/lib/ai/ai-details.test.ts @@ -91,6 +91,7 @@ describe('getAIDetails', () => { hasAccessToAdvanceModel: false, hasHipaaAddon: false, orgId: 1, + orgSlug: ORG_SLUG, planId: 'pro', region: 'us-east-1', isSensitive: false, @@ -189,6 +190,7 @@ describe('getAIDetails', () => { expect(result.aiOptInLevel).toBe('disabled') expect(result.hasAccessToAdvanceModel).toBe(false) expect(result.orgId).toBeUndefined() + expect(result.orgSlug).toBeUndefined() expect(result.planId).toBeUndefined() }) @@ -216,6 +218,7 @@ describe('getAIDetails', () => { expect(result.aiOptInLevel).toBe('disabled') expect(result.orgId).toBeUndefined() + expect(result.orgSlug).toBeUndefined() }) it('falls back to the most restrictive posture when project detail is unavailable', async () => { diff --git a/apps/studio/lib/ai/ai-details.ts b/apps/studio/lib/ai/ai-details.ts index be61660368dca..9a4fe56875c59 100644 --- a/apps/studio/lib/ai/ai-details.ts +++ b/apps/studio/lib/ai/ai-details.ts @@ -11,6 +11,7 @@ export type AIDetails = { hasAccessToAdvanceModel: boolean hasHipaaAddon: boolean | undefined orgId: number | undefined + orgSlug: string | undefined planId: string | undefined region: string | undefined isSensitive: boolean | null | undefined @@ -56,6 +57,7 @@ export const getAIDetails = async ({ // Undefined rather than false so isTracingAllowed fails closed hasHipaaAddon: undefined, orgId: undefined, + orgSlug: undefined, planId: undefined, region, isSensitive, @@ -72,6 +74,7 @@ export const getAIDetails = async ({ hasAccessToAdvanceModel: advanceModelAccess.hasAccess, hasHipaaAddon, orgId: selectedOrg.id, + orgSlug: selectedOrg.slug, planId: selectedOrg.plan.id, region, isSensitive, diff --git a/apps/studio/lib/ai/generate-assistant-response.ts b/apps/studio/lib/ai/generate-assistant-response.ts index a46b4c6f89e8e..ccb02cc28669b 100644 --- a/apps/studio/lib/ai/generate-assistant-response.ts +++ b/apps/studio/lib/ai/generate-assistant-response.ts @@ -39,6 +39,7 @@ export async function generateAssistantResponse({ supportMode, userId, orgId, + orgSlug, planId, includesLogsSnippets, isExplorerEnabled, @@ -60,6 +61,7 @@ export async function generateAssistantResponse({ supportMode?: boolean userId?: string orgId?: number + orgSlug?: string planId?: string /** Whether any user message in the conversation attached a logs (ClickHouse) query. */ includesLogsSnippets?: boolean @@ -171,6 +173,7 @@ export async function generateAssistantResponse({ aiOptInLevel, userId, orgId, + orgSlug, planId, requestedModel, gitBranch: process.env.VERCEL_GIT_COMMIT_REF, diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index 1b056af7c6440..bd16a812d6c81 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -96,7 +96,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw messages: rawMessages, projectRef, connectionString, - orgSlug, + orgSlug: rawOrgSlug, chatId, chatName, model: rawRequestedModel, @@ -126,6 +126,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw let projectIsSensitive: boolean | null | undefined let projectRegion: string | undefined let orgId: number | undefined + let orgSlug: string | undefined let planId: string | undefined if (!IS_PLATFORM) { @@ -133,14 +134,15 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw hasAccessToAdvanceModel = true } - if (IS_PLATFORM && orgSlug && authorization && projectRef) { + if (IS_PLATFORM && rawOrgSlug && authorization && projectRef) { try { - const aiDetails = await getAIDetails({ orgSlug, projectRef, authorization }) + const aiDetails = await getAIDetails({ orgSlug: rawOrgSlug, projectRef, authorization }) aiOptInLevel = aiDetails.aiOptInLevel hasAccessToAdvanceModel = aiDetails.hasAccessToAdvanceModel orgHasHipaaAddon = aiDetails.hasHipaaAddon orgId = aiDetails.orgId + orgSlug = aiDetails.orgSlug planId = aiDetails.planId projectIsSensitive = aiDetails.isSensitive projectRegion = aiDetails.region @@ -234,6 +236,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw supportMode, userId, orgId, + orgSlug, planId, includesLogsSnippets, isExplorerEnabled: explorerEnabled, From 86c813ec03e340ffbe4aeb97cd0c5bee7a0ead94 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:12:04 -0400 Subject: [PATCH 6/6] fix(notebooks): reset insert offset when anchor cell moves (#49694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Bug fix ## What is the current behavior? When a cell gets moved via `move_cell` operation in `deriveNotebookDiff`, the `insertedAfter` offset map is not cleared for that anchor cell. This causes later `insert_cell` operations anchored on the same (now-moved) cell to apply the stale offset on top of the correct current-position lookup, resulting in the new cell landing after the wrong position. ## What is the new behavior? The offset for an anchor cell is now cleared from `insertedAfter` when it gets moved, since cells previously inserted after it stay behind at its old location and should not affect subsequent inserts at its new position. A regression test has been added that reproduces the exact ticket scenario (insert after cell-1, move cell-1 after cell-3, insert after cell-1 again) and verifies the correct final cell order. ## Additional context Fixes: https://linear.app/supabase/issue/FE-4308/insert-anchored-to-a-previously-moved-cell-lands-after-the-wrong-cell ## Summary by CodeRabbit * **Bug Fixes** * Fixed notebook cell insertions after moving an anchor cell, ensuring new inserts appear relative to the anchor’s updated position. * Preserved the placement of inserts made before the anchor cell was moved. --- .../notebooks/notebook-operations.test.ts | 35 +++++++++++++++++++ .../content/notebooks/notebook-operations.ts | 3 ++ 2 files changed, 38 insertions(+) diff --git a/apps/studio/data/content/notebooks/notebook-operations.test.ts b/apps/studio/data/content/notebooks/notebook-operations.test.ts index 77a5ba0a80497..1194884333ac5 100644 --- a/apps/studio/data/content/notebooks/notebook-operations.test.ts +++ b/apps/studio/data/content/notebooks/notebook-operations.test.ts @@ -121,6 +121,41 @@ describe('applyNotebookOperations', () => { ]) }) + it('resets the insert offset for an anchor cell once it gets moved', () => { + // cell-1 gets an insert right after it, then cell-1 itself moves after cell-3. A later + // insert anchored on cell-1 must land right after its *new* position — the first insert + // stayed behind at cell-1's old spot, so it shouldn't count toward this offset anymore. + const notebook: NotebookWire = { + schema_version: 1, + cells: [ + { _tag: 'markdown_cell', _id: 'cell-1', text: '1' }, + { _tag: 'markdown_cell', _id: 'cell-2', text: '2' }, + { _tag: 'markdown_cell', _id: 'cell-3', text: '3' }, + { _tag: 'markdown_cell', _id: 'cell-4', text: '4' }, + ], + } + const ops: NotebookOperation[] = [ + { + _tag: 'insert_cell', + after_cell_id: 'cell-1', + cell: { _tag: 'markdown_cell', text: 'first' }, + }, + { _tag: 'move_cell', cell_id: 'cell-1', after_cell_id: 'cell-3' }, + { + _tag: 'insert_cell', + after_cell_id: 'cell-1', + cell: { _tag: 'markdown_cell', text: 'second' }, + }, + ] + + const result = applyNotebookOperations(notebook, ops) + + expect(result.success).toBe(true) + if (!result.success) return + const texts = result.notebook.cells.map((cell) => ('text' in cell ? cell.text : undefined)) + expect(texts).toEqual(['first', '2', '3', '1', 'second', '4']) + }) + it('resolves a move anchored on another moved cell using its new position', () => { // cell-1 moves after cell-3 first, landing at [cell-2, cell-3, cell-1]; cell-2 then moves // after cell-1's *new* position, giving [cell-3, cell-1, cell-2]. diff --git a/apps/studio/data/content/notebooks/notebook-operations.ts b/apps/studio/data/content/notebooks/notebook-operations.ts index 60d2dcb27d250..2a2b0404de433 100644 --- a/apps/studio/data/content/notebooks/notebook-operations.ts +++ b/apps/studio/data/content/notebooks/notebook-operations.ts @@ -256,6 +256,9 @@ export function deriveNotebookDiff( } entries.splice(found.index, 1) + // Cells already inserted after this anchor stay behind at its old position, so a + // later insert anchored on it should start counting from its new position again. + insertedAfter.delete(operation.cell_id) const error = insertAfter(operation.after_cell_id, { _tag: 'moved', cell: found.cell,