From c4fe153fd8c4bf66fffa8424bd8e599d36e52636 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 28 Aug 2026 20:21:16 +0800 Subject: [PATCH] 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 d4125fe3b2b..556e2f130cd 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 3e46de3d5c5..8974a780ee0 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 486399fadb1..96a7b716151 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)