Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -108,6 +108,7 @@ export const ExplorerNotebookTab = () => {
const [skipMutatingCells, setSkipMutatingCells] = useState(false)
const queryCellRefs = useRef(new Map<string, QueryEditorHandle>())
const savedContentRef = useRef<typeof content>(undefined)
const scrollContainerRef = useRef<HTMLDivElement>(null)

const { mutate: updateNotebook, isPending: isUpdating } = useUpsertNotebookMutation({
onSuccess: () => {
Expand Down Expand Up @@ -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 (
<div className="p-4 h-full bg-surface-100">
Expand Down Expand Up @@ -354,18 +366,12 @@ export const ExplorerNotebookTab = () => {
<ExplorerToolbarAction
icon={<AiIconAnimation size={16} />}
loading={isCreating}
disabled={cells.length === 0}
tooltip={cells.length === 0 ? 'Add a cell to the notebook to analyze it' : undefined}
onClick={handleClickAnalyze}
>
Analyze
</ExplorerToolbarAction>
<ExplorerToolbarAction
aria-label="Run notebook"
icon={<Play />}
tooltip="Run notebook"
loading={isRunningNotebook}
disabled={queryCellIds.length === 0}
onClick={handleRunNotebook}
/>
<ExplorerToolbarAction
aria-label="Save changes"
icon={<Save />}
Expand Down Expand Up @@ -397,10 +403,20 @@ export const ExplorerNotebookTab = () => {
</DropdownMenuContent>
</DropdownMenu>
</ExplorerToolbarActions>
<ExplorerToolbarAction
aria-label="Run notebook"
icon={<Play />}
tooltip="Run notebook"
loading={isRunningNotebook}
disabled={queryCellIds.length === 0}
onClick={handleRunNotebook}
>
Run
</ExplorerToolbarAction>
</ExplorerToolbarActions>
</ExplorerToolbar>

<div className="w-full mx-auto flex-grow min-h-0 overflow-y-auto">
<div ref={scrollContainerRef} className="w-full mx-auto flex-grow min-h-0 overflow-y-auto">
<div className="p-4 pb-10">
{cells.length === 0 && (
<EmptyStatePresentational
Expand Down
149 changes: 144 additions & 5 deletions apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,69 @@
import { useParams } from 'common'
import { Loader2, SquareCode } from 'lucide-react'
import { useDebounce } from '@uidotdev/usehooks'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { Check, Keyboard, Loader2, MoreVertical, Save, SquareCode } from 'lucide-react'
import { useRouter } from 'next/router'
import { useCallback, useContext, useEffect, useState } from 'react'
import { Button } from 'ui'
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'

import { ExplorerToolbarAction } from './ExplorerToolbar'
import { useCreateNotebook } from './hooks'
import { QueryEditor, type ExplorerQueryModel } from './QueryEditor'
import { type QueryDisplay, type QueryResult } from './types'
import { createQueryCellSkeleton } from './utils'
import { getNotebook } from '@/data/content/notebooks/notebook-query'
import { useNotebooksInfiniteQuery } from '@/data/content/notebooks/notebooks-infinite-query'
import { toQuerySourceBinding } from '@/data/query-sources/query-source-registry'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { explorerQueryState, useExplorerQueryStateSnapshot } from '@/state/explorer-query'
import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
import { useControlledRoleImpersonationState } from '@/state/role-impersonation-state'
import { createTabId, TabsStateContext } from '@/state/tabs'

/** Query-tab lifecycle adapter around the shared QueryEditor. */
export const ExplorerQueryTab = () => {
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<string>()
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
Expand Down Expand Up @@ -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 (
<QueryEditor
id={id}
Expand Down Expand Up @@ -128,6 +198,75 @@ export const ExplorerQueryTab = () => {
persistTab()
explorerQueryState.setDisplay({ id, display })
}}
toolbarActions={
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ExplorerToolbarAction icon={<Save />} tooltip="Save query" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="end">
<DropdownMenuSub>
<DropdownMenuSubTrigger>Add to existing notebook</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="p-0">
<Command shouldFilter={false}>
<CommandInput
autoFocus
placeholder="Search notebooks..."
className="text-xs"
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandGroup>
{isPending ? (
<div className="flex flex-col p-1 gap-y-1">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
</div>
) : !notebooks?.length ? (
<p className="text-xs text-center text-foreground-lighter py-3">
No notebooks found
</p>
) : null}
{notebooks?.map((notebook) => (
<CommandItem
key={notebook.id}
value={notebook.id}
className="cursor-pointer"
onSelect={() => onAddToExistingNotebook(notebook.id)}
>
{notebook.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem onClick={onAddToNewNotebook}>
Create a new notebook
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ExplorerToolbarAction icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="justify-between"
onClick={() => setIsIntellisenseEnabled(!isIntellisenseEnabled)}
>
<div className="flex items-center gap-x-2">
<Keyboard size={14} />
<span>Intellisense enabled</span>
</div>
{isIntellisenseEnabled && <Check className="text-brand" size={16} />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
}
/>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -366,13 +366,12 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
return (
<>
<Shell className={cn(variant === 'embedded' && 'mx-auto max-w-6xl', className)}>
<ExplorerToolbar>
<ExplorerToolbar className={cn(variant === 'viewport' && 'px-4')}>
<ExplorerToolbarIcon>
<CodeSquare size={14} />
</ExplorerToolbarIcon>
<ExplorerToolbarTitle onSaveTitle={onTitleChange}>{title}</ExplorerToolbarTitle>
<ExplorerToolbarActions>
{toolbarActions}
{onSourceChange && (
<QuerySourceMenu
disabled={pendingProposal !== null}
Expand All @@ -386,6 +385,7 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
roleImpersonationState={roleImpersonationState}
/>
)}

{display && onDisplayChange && (
<DisplaySettingsButton
result={result}
Expand All @@ -401,9 +401,12 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
tooltip={showQuery ? 'Hide query' : 'Show query'}
onClick={() => onShowQueryChange(!showQuery)}
/>

{toolbarActions}

<ExplorerToolbarAction
loading={isExecuting}
icon={<Play />}
loading={isExecuting}
tooltip={hasSelection ? 'Run selected query' : 'Run query'}
disabled={
isBusy || pendingProposal !== null || isRunDisabled || sql.trim().length === 0
Expand Down
35 changes: 35 additions & 0 deletions apps/studio/data/content/notebooks/notebook-operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down
3 changes: 3 additions & 0 deletions apps/studio/data/content/notebooks/notebook-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/studio/data/projects/project-detail-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down
3 changes: 3 additions & 0 deletions apps/studio/lib/ai/ai-details.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ describe('getAIDetails', () => {
hasAccessToAdvanceModel: false,
hasHipaaAddon: false,
orgId: 1,
orgSlug: ORG_SLUG,
planId: 'pro',
region: 'us-east-1',
isSensitive: false,
Expand Down Expand Up @@ -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()
})

Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading