diff --git a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx index d4125fe3b2b47..d34f51cd77c11 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 (
@@ -354,18 +366,12 @@ 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 - } - tooltip="Run notebook" - loading={isRunningNotebook} - disabled={queryCellIds.length === 0} - onClick={handleRunNotebook} - /> } @@ -397,10 +403,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/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, 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 } }, 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, 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) 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 {