feat(studio): logs snippets in SQL editor nav, search, and tabs (#48457)

## What

PR 7 of the SQL-editor query-source (Database vs Logs) stack. Surfaces
`log_sql` snippets as a distinct query source across the SQL editor
sidebar. Stacked on **`charislam/toolbar-ui-creation-flow`** (PR 6 —
toolbar UI + creation flow); review/merge that first.

Nothing is user-visible until the flags roll out — every entry point
requires **both** `sqlEditorLogsSource` **and** `otelLegacyLogs`.

## Changes

- **Nav** — a flag-gated **Logs** section (`LogsSnippetsSection`) backed
by its own single-type `log_sql` query. The active snippet is injected
only into the section it belongs to, via a shared
`withActiveSnippet(snippets, active, belongsPredicate)` helper (also
DRYs the private/favorites/shared injections).
- **Search** (`SearchList`) — a **Logs** result group with a shared,
extracted `SqlSnippetTree`; the "N results found" count now sums
database + logs, with loading/empty states covering both queries.
- **Tabs** — an immutable `sqlSource` field on tab/recent-item metadata
(set at tab creation, lazily backfilled once the snippet loads via
`useEffectEvent`), and a distinct `ScrollText` icon via a shared
`LogsSnippetIcon`. Tab cleanup treats `log_sql` tabs as live and only
prunes them when logs data is authoritative (`canPruneLogsTabs`), so a
disabled/erroring logs query never wrongly deletes logs tabs or blocks
database-tab cleanup.
- **Data layer** — `useSqlSnippetsQuery` gains an optional `type` param
so logs reuse the same `SnippetWithContent` shape as the other sections
(no casts).

## Tests

- `state/tabs.test.ts` — `sqlSource` backfill + creation-time
carry-through.
- `components/layouts/Tabs/Tabs.utils.test.tsx` — cleanup prunes stale
database/logs snippets, keeps live ones, and preserves logs tabs when
logs data isn't authoritative.

## Verification

- `pnpm --filter studio typecheck` ✓
- `pnpm --filter studio run lint:ratchet` ✓
- `pnpm test:studio` (affected suites) ✓

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a collapsible Logs section to the SQL editor sidebar for
browsing, sorting, selecting, renaming, and deleting log queries.
* Expanded SQL search with separate, paginated results for database and
log queries.
* Added dedicated log-query icons across navigation, tabs, previews, and
recent items.
* **Bug Fixes**
* Improved tab and recent-item cleanup while preserving active log
queries and accurate source metadata.
* **Tests**
* Added coverage for log tab cleanup and SQL source metadata
synchronization.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Charis authored and GitHub committed 2026-08-04 09:02:39 -04:00
1 parent de5d3cd115
commit b3c5c9fc04
18 files changed
+805 -175

No files matched your search

@@ -0,0 +1,136 @@
import { keepPreviousData } from '@tanstack/react-query'
import { useParams } from 'common'
import { useEffect, useMemo } from 'react'
import {
InnerSideBarEmptyPanel,
InnerSideMenuCollapsible,
InnerSideMenuCollapsibleContent,
InnerSideMenuCollapsibleTrigger,
} from 'ui-patterns/InnerSideMenu'
import { SQLEditorLoadingSnippets } from './SQLEditorLoadingSnippets'
import {
formatFolderResponseForTreeView,
getLastItemIds,
ROOT_NODE,
withActiveSnippet,
} from './SQLEditorNav.utils'
import { SqlSnippetTree } from './SqlSnippetTree'
import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
import { useContentCountQuery } from '@/data/content/content-count-query'
import { Snippet } from '@/data/content/sql-folders-query'
import { useSqlSnippetsQuery } from '@/data/content/sql-snippets-query'
import { useLatest } from '@/hooks/misc/useLatest'
interface LogsSnippetsSectionProps {
open: boolean
onOpenChange: (open: boolean) => void
sort: 'inserted_at' | 'name'
/** The currently open snippet, surfaced in the list before the query fetches it. */
activeSnippet?: Snippet
selectedSnippetIds: string[]
/** Bubbles loaded logs snippets up for tab cleanup, mirroring onFolderContentsChange. */
onSnippetsLoaded: (info: {
snippets: Snippet[]
/**
* Whether `snippets` is the complete set of logs snippets. Only true once every
* page has been fetched — cleanup prunes tabs whose snippet is absent from the
* list, and a snippet on an unfetched page would otherwise look deleted.
*/
isComplete: boolean
isSettled: boolean
}) => void
onSelectDelete: (snippet: Snippet) => void
onSelectRename: (snippet: Snippet) => void
}
/**
* A separate single-type `log_sql` query — logs snippets are a distinct backend and
* don't participate in folders, so they get their own flat section rather than merging
* with the cursor-paginated `sql` sections.
*/
export const LogsSnippetsSection = ({
open,
onOpenChange,
sort,
activeSnippet,
selectedSnippetIds,
onSnippetsLoaded,
onSelectDelete,
onSelectRename,
}: LogsSnippetsSectionProps) => {
const { ref: projectRef } = useParams()
const { data, isLoading, isSuccess, isError, hasNextPage, fetchNextPage, isFetchingNextPage } =
useSqlSnippetsQuery(
{ projectRef, type: 'log_sql', sort },
{ placeholderData: keepPreviousData }
)
const { data: countData } = useContentCountQuery({ projectRef, type: 'log_sql' })
const numSnippets = (countData?.private ?? 0) + (countData?.shared ?? 0)
const snippets = useMemo(() => {
const pageSnippets = data?.pages.flatMap((page) => page.contents ?? []) ?? []
return withActiveSnippet<Snippet>(
pageSnippets,
activeSnippet,
(s) => getSnippetSource(s) === 'logs'
)
.map((x) => ({ ...x, folder_id: null }))
.sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name)
return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf()
})
}, [data?.pages, activeSnippet, sort])
const treeState = useMemo(
() =>
snippets.length === 0
? [ROOT_NODE]
: formatFolderResponseForTreeView({ contents: snippets, folders: [] }),
[snippets]
)
const lastItemIds = useMemo(() => getLastItemIds(treeState), [treeState])
const onSnippetsLoadedRef = useLatest(onSnippetsLoaded)
useEffect(() => {
onSnippetsLoadedRef.current({
snippets,
isComplete: isSuccess && !hasNextPage,
isSettled: isSuccess || isError,
})
}, [snippets, isSuccess, isError, hasNextPage])
return (
<InnerSideMenuCollapsible className="px-0" open={open} onOpenChange={onOpenChange}>
<InnerSideMenuCollapsibleTrigger
title={`Logs ${numSnippets > 0 ? ` (${numSnippets})` : ''}`}
/>
<InnerSideMenuCollapsibleContent className="group-data-open:pt-2">
{isLoading && <SQLEditorLoadingSnippets />}
{!isLoading && snippets.length === 0 && (
<InnerSideBarEmptyPanel
className="mx-2"
title="No logs queries"
description="Create a logs query from the toolbar to explore your project's logs with SQL."
/>
)}
{!isLoading && snippets.length > 0 && (
<SqlSnippetTree
ariaLabel="logs-snippets"
data={treeState}
lastItemIds={lastItemIds}
selectedSnippetIds={selectedSnippetIds}
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
isFetchingNextPage={isFetchingNextPage}
onSelectDelete={onSelectDelete}
onSelectRename={onSelectRename}
/>
)}
</InnerSideMenuCollapsibleContent>
</InnerSideMenuCollapsible>
)
}
@@ -2,6 +2,7 @@ export type SectionState = {
shared: boolean
favorite: boolean
private: boolean
logs: boolean
community: boolean
}
@@ -9,5 +10,6 @@ export const DEFAULT_SECTION_STATE: SectionState = {
shared: false,
favorite: false,
private: true,
logs: false,
community: true,
}
@@ -1,5 +1,5 @@
import { keepPreviousData } from '@tanstack/react-query'
import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common'
import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common'
import { Heart } from 'lucide-react'
import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react'
@@ -15,15 +15,22 @@ import {
} from 'ui-patterns/InnerSideMenu'
import { DeleteSnippetsModal } from './DeleteSnippetsModal'
import { LogsSnippetsSection } from './LogsSnippetsSection'
import { ReferenceSnippetsSection } from './ReferenceSnippetsSection'
import { ShareSnippetModal } from './ShareSnippetModal'
import { SQLEditorLoadingSnippets } from './SQLEditorLoadingSnippets'
import { DEFAULT_SECTION_STATE, type SectionState } from './SQLEditorNav.constants'
import { formatFolderResponseForTreeView, getLastItemIds, ROOT_NODE } from './SQLEditorNav.utils'
import {
formatFolderResponseForTreeView,
getLastItemIds,
ROOT_NODE,
withActiveSnippet,
} from './SQLEditorNav.utils'
import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem'
import { UnshareSnippetModal } from './UnshareSnippetModal'
import { DownloadSnippetModal } from '@/components/interfaces/SQLEditor/DownloadSnippetModal'
import { MoveQueryModal } from '@/components/interfaces/SQLEditor/MoveQueryModal'
import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
import { RenameQueryModal } from '@/components/interfaces/SQLEditor/RenameQueryModal'
import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants'
import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils'
@@ -65,8 +72,16 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
shared: showSharedSnippets,
favorite: showFavoriteSnippets,
private: showPrivateSnippets,
logs: showLogsSnippets,
} = sectionVisibility
// Both flags gate the entry point: `sqlEditorLogsSource` enables the feature and
// `otelLegacyLogs` confirms the org's logs live in the ClickHouse backend a logs
// snippet queries.
const isLogsSourceEnabled = useFlag('sqlEditorLogsSource')
const isOtelLogsEnabled = useFlag('otelLegacyLogs')
const canShowLogsSection = isLogsSourceEnabled && isOtelLogsEnabled
const [showMoveModal, setShowMoveModal] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [showRenameModal, setShowRenameModal] = useState(false)
@@ -131,10 +146,11 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
}
)
if (snippet && snippet.visibility === 'user' && !snippetInfo.snippetIds.has(snippet.id)) {
snippetInfo.snippetIds.add(snippet.id)
snippetInfo.snippets = [...snippetInfo.snippets, snippet]
}
snippetInfo.snippets = withActiveSnippet(
snippetInfo.snippets,
snippet,
(s) => s.visibility === 'user' && getSnippetSource(s) !== 'logs'
)
return snippetInfo
}, [privateSnippetsPages?.pages, subResults, isLoading, isPlaceholderData, isFetching, snippet])
@@ -204,11 +220,11 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
)
const favoriteSnippets = useMemo(() => {
let snippets = favoriteSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? []
if (snippet && snippet.favorite && !snippets.find((x) => x.id === snippet.id)) {
snippets.push(snippet)
}
const snippets = withActiveSnippet(
favoriteSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? [],
snippet,
(s) => !!s.favorite && getSnippetSource(s) !== 'logs'
)
return (
snippets
@@ -255,11 +271,11 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
)
const sharedSnippets = useMemo(() => {
let snippets = sharedSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? []
if (snippet && snippet.visibility === 'project' && !snippets.find((x) => x.id === snippet.id)) {
snippets.push(snippet)
}
const snippets = withActiveSnippet(
sharedSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? [],
snippet,
(s) => s.visibility === 'project' && getSnippetSource(s) !== 'logs'
)
return (
snippets.sort((a, b) => {
@@ -284,12 +300,21 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
[projectSnippetsTreeState]
)
// The Logs section owns its own query and bubbles loaded snippets here so tab
// cleanup treats them as live (and prunes stale ones).
const [logsSnippetsInView, setLogsSnippetsInView] = useState<{
snippets: Snippet[]
isComplete: boolean
isSettled: boolean
}>({ snippets: [], isComplete: false, isSettled: false })
const allSnippetsInView = useMemo(
() => [
...(privateSnippetsPages?.pages.flatMap((x) => x.contents) ?? []),
...(sharedSqlSnippetsData?.pages.flatMap((x) => x.contents) ?? []),
...(privateSnippetsPages?.pages.flatMap((x) => x.contents ?? []) ?? []),
...(sharedSqlSnippetsData?.pages.flatMap((x) => x.contents ?? []) ?? []),
...logsSnippetsInView.snippets,
],
[privateSnippetsPages, sharedSqlSnippetsData]
[privateSnippetsPages, sharedSqlSnippetsData, logsSnippetsInView.snippets]
)
// ==========================
@@ -404,7 +429,12 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
useEffect(() => {
if (snippet !== undefined && isSuccess) {
if (snippet.visibility === 'project') {
// Source is checked before visibility: a logs snippet lives in the Logs section
// whatever its visibility, so branching on visibility first would open Private
// (or Shared) and leave the section the snippet is actually in collapsed.
if (getSnippetSource(snippet) === 'logs') {
setSectionVisibility({ ...sectionVisibility, logs: true })
} else if (snippet.visibility === 'project') {
setSectionVisibility({ ...sectionVisibility, shared: true })
} else if (snippet.visibility === 'user') {
setSectionVisibility({ ...sectionVisibility, private: true })
@@ -457,10 +487,24 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
const sqlEditorTabsCleanup = useSqlEditorTabsCleanup()
useEffect(() => {
if (isSuccess) {
sqlEditorTabsCleanup({ snippets: allSnippetsInView as any })
// Wait for the logs query to settle (when enabled) so a logs failure doesn't
// freeze database-tab cleanup. Logs tabs are only prunable once every logs page
// has been fetched — until then the list is partial and a tab whose snippet sits
// on an unfetched page would be pruned as stale, so they're preserved instead.
if (isSuccess && (!canShowLogsSection || logsSnippetsInView.isSettled)) {
sqlEditorTabsCleanup({
snippets: allSnippetsInView,
canPruneLogsTabs: canShowLogsSection && logsSnippetsInView.isComplete,
})
}
}, [allSnippetsInView, isSuccess, sqlEditorTabsCleanup])
}, [
allSnippetsInView,
isSuccess,
canShowLogsSection,
logsSnippetsInView.isComplete,
logsSnippetsInView.isSettled,
sqlEditorTabsCleanup,
])
return (
<>
@@ -757,6 +801,31 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
<InnerSideMenuSeparator />
{canShowLogsSection && (
<>
<LogsSnippetsSection
open={showLogsSnippets}
onOpenChange={(value) =>
setSectionVisibility({ ...(sectionVisibility ?? DEFAULT_SECTION_STATE), logs: value })
}
sort={sort}
activeSnippet={snippet}
selectedSnippetIds={selectedSnippets.map((x) => x.id)}
onSnippetsLoaded={setLogsSnippetsInView}
onSelectDelete={(snippet) => {
setShowDeleteModal(true)
setSelectedSnippets([snippet])
}}
onSelectRename={(snippet) => {
setShowRenameModal(true)
setSelectedSnippetToRename(snippet)
}}
/>
<InnerSideMenuSeparator />
</>
)}
<ReferenceSnippetsSection />
<InnerSideMenuSeparator />
@@ -53,6 +53,27 @@ export const formatFolderResponseForTreeView = (
return [root, ...formattedFolders, ...formattedContents]
}
/**
* Append the active snippet to a section's list when it belongs there (per the
* section's predicate) and isn't already present. Each nav section lists
* server-filtered pages, so a just-opened or just-created snippet may not appear
* until a refetch — this surfaces it immediately, in the one section it belongs to.
*/
export function withActiveSnippet<T extends { id: string }>(
snippets: T[],
activeSnippet: T | undefined,
belongsInSection: (snippet: T) => boolean
): T[] {
if (
activeSnippet !== undefined &&
belongsInSection(activeSnippet) &&
!snippets.some((snippet) => snippet.id === activeSnippet.id)
) {
return [...snippets, activeSnippet]
}
return snippets
}
export function getLastItemIds(items: TreeViewItemProps[]) {
let lastItemIds = new Set<string>()
@@ -30,6 +30,7 @@ import {
import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils'
import { LogsSnippetIcon } from '@/components/ui/EntityTypeIcon'
import { getContentById, getSqlSnippetById } from '@/data/content/content-id-query'
import { useSQLSnippetFolderContentsQuery } from '@/data/content/sql-folder-contents-query'
import { Snippet } from '@/data/content/sql-folders-query'
@@ -111,6 +112,8 @@ export const SQLEditorTreeViewItem = ({
const isSharedSnippet = element.metadata.visibility === 'project'
const isFavorite = element.metadata.favorite
const isLogsSnippet = getSnippetSource(element.metadata) === 'logs'
const isEditing = isFolderEditing(status)
const isSaving = isFolderSaving(status)
@@ -247,6 +250,14 @@ export const SQLEditorTreeViewItem = ({
isPreview={props.isPreview}
isEditing={isEditing}
isLoading={(isEnabled && isLoading) || isSaving}
icon={
isLogsSnippet ? (
<LogsSnippetIcon
size={16}
className="w-5 h-5 shrink-0 text-foreground-muted group-aria-selected:text-foreground"
/>
) : undefined
}
onEditSubmit={(value) => {
if (onEditSave !== undefined) onEditSave(value)
}}
@@ -1,29 +1,45 @@
import { keepPreviousData } from '@tanstack/react-query'
import { useParams } from 'common'
import { useFlag, useParams } from 'common'
import { Loader2 } from 'lucide-react'
import { useMemo, useState } from 'react'
import { TreeView } from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { DeleteSnippetsModal } from './DeleteSnippetsModal'
import { ShareSnippetModal } from './ShareSnippetModal'
import { formatFolderResponseForTreeView, getLastItemIds } from './SQLEditorNav.utils'
import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem'
import { SqlSnippetTree } from './SqlSnippetTree'
import { UnshareSnippetModal } from './UnshareSnippetModal'
import { DownloadSnippetModal } from '@/components/interfaces/SQLEditor/DownloadSnippetModal'
import { RenameQueryModal } from '@/components/interfaces/SQLEditor/RenameQueryModal'
import { useContentCountQuery } from '@/data/content/content-count-query'
import { useContentInfiniteQuery } from '@/data/content/content-infinite-query'
import { Snippet, SNIPPET_PAGE_LIMIT } from '@/data/content/sql-folders-query'
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
import { Snippet } from '@/data/content/sql-folders-query'
import { useSqlSnippetsQuery } from '@/data/content/sql-snippets-query'
interface SearchListProps {
search: string
}
/** Uppercase section heading shown above the Database and Logs result groups. */
const SearchGroupHeading = ({ children }: { children: string }) => (
<p className="px-4 pb-2 pt-1 text-xs font-medium uppercase text-foreground-muted">{children}</p>
)
/**
* Flatten paginated snippet results into the flat (folderless) tree the search view
* renders. Keyed on `pages` (a stable reference from React Query) so the flatMap and
* tree build run once per data change rather than on every render.
*/
function useSnippetSearchTree(pages: { contents: Snippet[] }[] | undefined) {
return useMemo(() => {
const flat = (pages ?? [])
.flatMap((page) => page.contents ?? [])
.map((snippet) => ({ ...snippet, folder_id: null }))
const treeState = formatFolderResponseForTreeView({ folders: [], contents: flat })
return { treeState, lastItemIds: getLastItemIds(treeState), count: flat.length }
}, [pages])
}
export const SearchList = ({ search }: SearchListProps) => {
const { id } = useParams()
const tabs = useTabsStateSnapshot()
const { ref: projectRef } = useParams()
const [selectedSnippetToShare, setSelectedSnippetToShare] = useState<Snippet>()
@@ -32,120 +48,121 @@ export const SearchList = ({ search }: SearchListProps) => {
const [selectedSnippetToRename, setSelectedSnippetToRename] = useState<Snippet>()
const [selectedSnippetToDelete, setSelectedSnippetToDelete] = useState<Snippet>()
// Logs snippets are a separate backend and don't share the `sql` cursor, so the
// search runs a second single-type query and renders them under their own group.
// Gated by both flags, mirroring the nav's Logs section.
const isLogsSourceEnabled = useFlag('sqlEditorLogsSource')
const isOtelLogsEnabled = useFlag('otelLegacyLogs')
const canShowLogsSection = isLogsSourceEnabled && isOtelLogsEnabled
const searchName = search.length === 0 ? undefined : search
const {
data,
isPending: isLoading,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useContentInfiniteQuery(
{
projectRef,
type: 'sql',
limit: SNIPPET_PAGE_LIMIT,
name: search.length === 0 ? undefined : search,
},
} = useSqlSnippetsQuery(
{ projectRef, type: 'sql', name: searchName },
{ placeholderData: keepPreviousData }
)
const { data: count, isPending: isLoadingCount } = useContentCountQuery(
{
projectRef,
type: 'sql',
name: search,
},
{ projectRef, type: 'sql', name: search },
{ placeholderData: keepPreviousData }
)
const totalNumber = count ? count.private + count.shared : 0
const snippets = useMemo(
// [Joshen] Set folder_id to null to ensure flat list
() => data?.pages.flatMap((page) => page.content.map((x) => ({ ...x, folder_id: null }))),
[data?.pages]
const { data: logsCount, isPending: isLoadingLogsCount } = useContentCountQuery(
{ projectRef, type: 'log_sql', name: search },
{ enabled: canShowLogsSection, placeholderData: keepPreviousData }
)
const treeState = formatFolderResponseForTreeView({ folders: [], contents: snippets as any })
const snippetsLastItemIds = useMemo(() => getLastItemIds(treeState), [treeState])
const databaseCount = count ? count.private + count.shared : 0
const logsResultCount = canShowLogsSection && logsCount ? logsCount.private + logsCount.shared : 0
const totalNumber = databaseCount + logsResultCount
const isLoadingCounts = isLoadingCount || (canShowLogsSection && isLoadingLogsCount)
const hasCounts = count !== undefined || (canShowLogsSection && logsCount !== undefined)
const {
data: logsData,
isPending: isLoadingLogs,
hasNextPage: hasNextLogsPage,
fetchNextPage: fetchNextLogsPage,
isFetchingNextPage: isFetchingNextLogsPage,
} = useSqlSnippetsQuery(
{ projectRef, type: 'log_sql', name: searchName },
{ enabled: canShowLogsSection, placeholderData: keepPreviousData }
)
const databaseTree = useSnippetSearchTree(data?.pages)
const logsTree = useSnippetSearchTree(logsData?.pages)
const hasDatabaseResults = databaseTree.count > 0
const hasLogsResults = logsTree.count > 0
// Label the groups only when both are present; a single group needs no heading.
const showGroupHeadings = hasDatabaseResults && hasLogsResults
// The results body is loading until both source queries (when enabled) settle.
const isSearchLoading = isLoading || (canShowLogsSection && isLoadingLogs)
return (
<>
<div className="flex flex-col grow">
{isLoadingCount ? (
{isLoadingCounts && (
<div className="px-4 py-1 pb-2.5">
<Loader2 className="animate-spin" size={14} />
</div>
) : !!count ? (
)}
{!isLoadingCounts && hasCounts && (
<p className="px-4 pb-2 text-sm text-foreground-lighter">
{totalNumber} result{totalNumber > 1 ? 's' : ''} found
{totalNumber} result{totalNumber === 1 ? '' : 's'} found
</p>
) : null}
{isLoading ? (
)}
{isSearchLoading && (
<div className="px-4 flex flex-col gap-y-1">
<ShimmeringLoader className="py-2.5" />
<ShimmeringLoader className="py-2.5 w-5/6" />
<ShimmeringLoader className="py-2.5 w-3/4" />
</div>
) : (
<TreeView
multiSelect
togglableSelect
clickAction="EXCLUSIVE_SELECT"
data={treeState}
aria-label="private-snippets"
nodeRenderer={({ element, ...props }) => {
const isOpened = Object.values(tabs.tabsMap).some(
(tab) => tab.metadata?.sqlId === element.metadata?.id
)
const tabId = createTabId('sql', {
id: element?.metadata?.id as unknown as Snippet['id'],
})
const isPreview = tabs.previewTabId === tabId
const isActive = !isPreview && element.metadata?.id === id
const visibility =
element.metadata?.visibility === 'user'
? 'Private'
: element.metadata?.visibility === 'project'
? 'Shared'
: undefined
return (
<SQLEditorTreeViewItem
{...props}
element={{
...element,
name: (
<span className="flex flex-col py-0.5">
<span className="truncate">{element.name}</span>
{!!visibility && (
<span className="text-foreground-lighter text-xs">{visibility}</span>
)}
</span>
),
}}
nameForTitle={element.name}
isBranch={false}
isOpened={isOpened && !isPreview}
isSelected={isActive}
isPreview={isPreview}
isLastItem={snippetsLastItemIds.has(element.id as string)}
status="idle"
className="items-start h-[40px] [&>svg]:translate-y-0.5"
onSelectDelete={() => setSelectedSnippetToDelete(element.metadata as Snippet)}
onSelectRename={() => setSelectedSnippetToRename(element.metadata as Snippet)}
onSelectDownload={() => setSelectedSnippetToDownload(element.metadata as Snippet)}
onSelectShare={() => setSelectedSnippetToShare(element.metadata as Snippet)}
onSelectUnshare={() => setSelectedSnippetToUnshare(element.metadata as Snippet)}
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
isFetchingNextPage={isFetchingNextPage}
onDoubleClick={(e) => {
e.preventDefault()
tabs.makeTabPermanent(tabId)
}}
/>
)
}}
/>
)}
{!isSearchLoading && !hasDatabaseResults && !hasLogsResults && (
<p className="px-4 text-sm text-foreground-lighter">No queries found</p>
)}
{!isSearchLoading && hasDatabaseResults && (
<>
{showGroupHeadings && <SearchGroupHeading>Database</SearchGroupHeading>}
<SqlSnippetTree
ariaLabel="database-snippets"
data={databaseTree.treeState}
lastItemIds={databaseTree.lastItemIds}
showVisibility
itemClassName="items-start h-[40px] [&>svg]:translate-y-0.5"
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
isFetchingNextPage={isFetchingNextPage}
onSelectDelete={setSelectedSnippetToDelete}
onSelectRename={setSelectedSnippetToRename}
onSelectDownload={setSelectedSnippetToDownload}
onSelectShare={setSelectedSnippetToShare}
onSelectUnshare={setSelectedSnippetToUnshare}
/>
</>
)}
{!isSearchLoading && canShowLogsSection && hasLogsResults && (
<>
{showGroupHeadings && <SearchGroupHeading>Logs</SearchGroupHeading>}
<SqlSnippetTree
ariaLabel="logs-snippets"
data={logsTree.treeState}
lastItemIds={logsTree.lastItemIds}
showVisibility
itemClassName="items-start h-[40px] [&>svg]:translate-y-0.5"
hasNextPage={hasNextLogsPage}
fetchNextPage={fetchNextLogsPage}
isFetchingNextPage={isFetchingNextLogsPage}
onSelectDelete={setSelectedSnippetToDelete}
onSelectRename={setSelectedSnippetToRename}
/>
</>
)}
</div>
@@ -0,0 +1,122 @@
import { useParams } from 'common'
import { TreeView } from 'ui'
import type { TreeViewItemProps } from './SQLEditorNav.utils'
import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem'
import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
import { Snippet } from '@/data/content/sql-folders-query'
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
interface SqlSnippetTreeProps {
ariaLabel: string
data: TreeViewItemProps[]
lastItemIds: Set<string>
/** Additional (multi-)selected snippet ids to highlight beyond the active one. */
selectedSnippetIds?: string[]
/** Search results show a source/visibility sublabel under each snippet name. */
showVisibility?: boolean
itemClassName?: string
hasNextPage?: boolean
fetchNextPage?: () => void
isFetchingNextPage?: boolean
onSelectDelete?: (snippet: Snippet) => void
onSelectRename?: (snippet: Snippet) => void
onSelectDownload?: (snippet: Snippet) => void
onSelectShare?: (snippet: Snippet) => void
onSelectUnshare?: (snippet: Snippet) => void
}
/**
* The sublabel under a snippet's name. Logs snippets are labeled by source rather
* than visibility: they have no share action, so "Private" says nothing that
* distinguishes them from the database query sitting right above them in the
* same result list, while "Logs" does.
*/
const snippetSublabel = (snippet: Snippet) => {
if (getSnippetSource(snippet) === 'logs') return 'Logs'
if (snippet.visibility === 'user') return 'Private'
if (snippet.visibility === 'project') return 'Shared'
return undefined
}
export const SqlSnippetTree = ({
ariaLabel,
data,
lastItemIds,
selectedSnippetIds,
showVisibility = false,
itemClassName,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
onSelectDelete,
onSelectRename,
onSelectDownload,
onSelectShare,
onSelectUnshare,
}: SqlSnippetTreeProps) => {
const { id } = useParams()
const tabs = useTabsStateSnapshot()
return (
<TreeView
multiSelect
togglableSelect
clickAction="EXCLUSIVE_SELECT"
data={data}
aria-label={ariaLabel}
nodeRenderer={({ element, ...props }) => {
const snippet = element.metadata as Snippet
const isOpened = Object.values(tabs.tabsMap).some(
(tab) => tab.metadata?.sqlId === snippet.id
)
const tabId = createTabId('sql', { id: snippet.id })
const isPreview = tabs.previewTabId === tabId
const isActive = !isPreview && snippet.id === id
const isSelected = isActive || (selectedSnippetIds?.includes(snippet.id) ?? false)
const sublabel = showVisibility ? snippetSublabel(snippet) : undefined
return (
<SQLEditorTreeViewItem
{...props}
element={
showVisibility
? {
...element,
name: (
<span className="flex flex-col py-0.5">
<span className="truncate">{element.name}</span>
{!!sublabel && (
<span className="text-foreground-lighter text-xs">{sublabel}</span>
)}
</span>
),
}
: element
}
nameForTitle={showVisibility ? (element.name as string) : undefined}
isBranch={false}
isOpened={isOpened && !isPreview}
isSelected={isSelected}
isPreview={isPreview}
isLastItem={lastItemIds.has(element.id as string)}
status="idle"
className={itemClassName}
onSelectDelete={onSelectDelete ? () => onSelectDelete(snippet) : undefined}
onSelectRename={onSelectRename ? () => onSelectRename(snippet) : undefined}
onSelectDownload={onSelectDownload ? () => onSelectDownload(snippet) : undefined}
onSelectShare={onSelectShare ? () => onSelectShare(snippet) : undefined}
onSelectUnshare={onSelectUnshare ? () => onSelectUnshare(snippet) : undefined}
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
isFetchingNextPage={isFetchingNextPage}
onDoubleClick={(e) => {
e.preventDefault()
tabs.makeTabPermanent(tabId)
}}
/>
)
}}
/>
)
}
@@ -71,7 +71,7 @@ export function RecentItems() {
className="flex items-center gap-4 rounded-lg bg-surface-100 py-2 transition-colors hover:bg-surface-200"
>
<div className="flex h-6 w-6 items-center justify-center rounded-sm bg-surface-100 border">
<EntityTypeIcon type={item.type} />
<EntityTypeIcon type={item.type} sqlSource={item.metadata?.sqlSource} />
</div>
<div className="flex flex-1 gap-5 items-center">
<span className="text-sm text-foreground">
@@ -104,7 +104,7 @@ export const SortableTab = ({
)}
{...listeners}
>
<EntityTypeIcon type={tab.type} />
<EntityTypeIcon type={tab.type} sqlSource={tab.metadata?.sqlSource} />
<div className="flex items-center gap-0">
<AnimatePresence mode="popLayout" initial>
{shouldShowSchema && (
@@ -17,7 +17,7 @@ export const TabPreview = ({ tab }: { tab: string }) => {
animate={{ opacity: 0.7 }}
className="flex relative items-center gap-2 px-3 text-xs bg-dash-sidebar dark:bg-surface-100 shadow-lg rounded-xs h-10"
>
<EntityTypeIcon type={tabData.type} />
<EntityTypeIcon type={tabData.type} sqlSource={tabData.metadata?.sqlSource} />
<span>{tabData.label || 'Untitled'}</span>
<div className="absolute w-full top-0 left-0 right-0 h-px bg-foreground-muted" />
</motion.div>
@@ -0,0 +1,91 @@
import { act, renderHook } from '@testing-library/react'
import type { ReactNode } from 'react'
import { describe, expect, it } from 'vitest'
import { useSqlEditorTabsCleanup } from './Tabs.utils'
import { createTabsState, TabsStateContext, type Tab } from '@/state/tabs'
const dbTab = (id: string): Tab => ({
id: `sql-${id}`,
type: 'sql',
label: id,
isPreview: false,
metadata: { sqlId: id, sqlSource: 'database' },
})
const logsTab = (id: string): Tab => ({
id: `sql-${id}`,
type: 'sql',
label: id,
isPreview: false,
metadata: { sqlId: id, sqlSource: 'logs' },
})
function renderCleanup(store: ReturnType<typeof createTabsState>) {
const wrapper = ({ children }: { children: ReactNode }) => (
<TabsStateContext.Provider value={store}>{children}</TabsStateContext.Provider>
)
return renderHook(() => useSqlEditorTabsCleanup(), { wrapper }).result.current
}
describe('useSqlEditorTabsCleanup', () => {
it('prunes tabs for deleted snippets (database and logs) while keeping live ones', () => {
const store = createTabsState('default')
store.addTab(dbTab('db-stale'))
store.addTab(logsTab('logs-stale'))
store.addTab(logsTab('logs-live'))
const cleanup = renderCleanup(store)
// The caller passes both `sql` and `log_sql` snippets it knows to be live; only
// `logs-live` is present, so the two stale tabs are pruned and it is kept.
act(() => cleanup({ snippets: [{ id: 'logs-live', type: 'log_sql', name: 'logs-live' }] }))
expect(store.openTabs).toEqual(['sql-logs-live'])
expect(store.tabsMap['sql-db-stale']).toBeUndefined()
expect(store.tabsMap['sql-logs-stale']).toBeUndefined()
expect(store.tabsMap['sql-logs-live']).toBeDefined()
})
it('keeps a logs tab that still exists in the snippet list', () => {
const store = createTabsState('default')
store.addTab(logsTab('logs'))
const cleanup = renderCleanup(store)
act(() => cleanup({ snippets: [{ id: 'logs', type: 'log_sql', name: 'logs' }] }))
expect(store.openTabs).toEqual(['sql-logs'])
})
it('preserves logs tabs when logs snippets are not authoritative (canPruneLogsTabs false)', () => {
const store = createTabsState('default')
store.addTab(dbTab('db-stale'))
store.addTab(logsTab('logs'))
const cleanup = renderCleanup(store)
// Logs section disabled / query errored: no logs snippets passed, and logs tabs
// must not be pruned — but stale database tabs still are.
act(() => cleanup({ snippets: [], canPruneLogsTabs: false }))
expect(store.openTabs).toEqual(['sql-logs'])
expect(store.tabsMap['sql-db-stale']).toBeUndefined()
// Recent items carry their own logs-source condition, so assert them separately.
const recentIds = store.recentItems.map((item) => item.id)
expect(recentIds).toContain('sql-logs')
expect(recentIds).not.toContain('sql-db-stale')
})
it('prunes recent items for deleted logs snippets while keeping live ones', () => {
const store = createTabsState('default')
store.addTab(logsTab('logs-stale'))
store.addTab(logsTab('logs-live'))
const cleanup = renderCleanup(store)
act(() => cleanup({ snippets: [{ id: 'logs-live', type: 'log_sql', name: 'logs-live' }] }))
const recentIds = store.recentItems.map((item) => item.id)
expect(recentIds).toContain('sql-logs-live')
expect(recentIds).not.toContain('sql-logs-stale')
})
})
@@ -65,46 +65,69 @@ export function useSqlEditorTabsCleanup() {
const tabMapRef = useLatest(tabs.tabsMap)
const openTabsRef = useLatest(tabs.openTabs)
return useCallback(({ snippets }: { snippets: { id: string; type: string; name: string }[] }) => {
// these are tabs that are static content
// these canot be removed from localstorage based on this query request
const IGNORED_TAB_IDS = ['sql-templates', 'sql-quickstarts']
return useCallback(
({
snippets,
canPruneLogsTabs = true,
}: {
snippets: { id: string; type: string; name: string }[]
// Whether `log_sql` snippets in `snippets` are authoritative. When false (the
// logs section is disabled or its query errored) we can't know which logs
// snippets exist, so logs tabs are preserved rather than pruned as stale.
canPruneLogsTabs?: boolean
}) => {
// these are tabs that are static content
// these canot be removed from localstorage based on this query request
const IGNORED_TAB_IDS = ['sql-templates', 'sql-quickstarts']
// Identify all SQL snippets / content by their tab ids
const currentContentIds = [
...snippets
.filter((content) => content.type === 'sql')
.map((content) => createTabId('sql', { id: content.id })),
// append ignored tab IDs
...IGNORED_TAB_IDS,
]
// Identify all SQL snippets / content by their tab ids. Both database (`sql`) and
// logs (`log_sql`) snippets live in the `sql-` tab id space, so both are counted as
// live. Anything not in this set is treated as removed outside the session and pruned.
const currentContentIds = [
...snippets
.filter((content) => content.type === 'sql' || content.type === 'log_sql')
.map((content) => createTabId('sql', { id: content.id })),
// append ignored tab IDs
...IGNORED_TAB_IDS,
]
// Remove any snippet tabs that might no longer be existing (removed outside of the dashboard session)
const snippetTabsToBeCleaned = openTabsRef.current.filter(
(id: string) => id.startsWith('sql') && !currentContentIds.includes(id)
)
tabs.removeTabs(snippetTabsToBeCleaned)
const isPrunable = (id: string) =>
id.startsWith('sql') &&
!currentContentIds.includes(id) &&
(canPruneLogsTabs || tabMapRef.current[id]?.metadata?.sqlSource !== 'logs')
// Remove any recent items that might no longer be existing (removed outside of the dashboard session)
const recentItems = tabs.getRecentItemsByType('sql')
tabs.removeRecentItems(
recentItems
? recentItems.filter((item) => !currentContentIds.includes(item.id)).map((item) => item.id)
: []
)
// Remove any snippet tabs that might no longer be existing (removed outside of the dashboard session)
const snippetTabsToBeCleaned = openTabsRef.current.filter(isPrunable)
tabs.removeTabs(snippetTabsToBeCleaned)
// [Joshen] Validate for opened tabs, if their label matches the snippet's name - update label if not
// As the snippets name could've been updated outside of the SQL Editor session
// e.g for a shared snippet, the owner could've updated the name of the snippet
const openSqlTabs = openTabsRef.current
.map((id) => tabMapRef.current[id])
.filter((tab) => !!tab && editorEntityTypes['sql']?.includes(tab.type))
// Remove any recent items that might no longer be existing (removed outside of the dashboard session)
const recentItems = tabs.getRecentItemsByType('sql')
tabs.removeRecentItems(
recentItems
? recentItems
.filter(
(item) =>
!currentContentIds.includes(item.id) &&
(canPruneLogsTabs || item.metadata?.sqlSource !== 'logs')
)
.map((item) => item.id)
: []
)
openSqlTabs.forEach((tab) => {
const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id)
if (!!snippet && snippet.name !== tab.label) tabs.updateTab(tab.id, { label: snippet.name })
})
}, [])
// [Joshen] Validate for opened tabs, if their label matches the snippet's name - update label if not
// As the snippets name could've been updated outside of the SQL Editor session
// e.g for a shared snippet, the owner could've updated the name of the snippet
const openSqlTabs = openTabsRef.current
.map((id) => tabMapRef.current[id])
.filter((tab) => !!tab && editorEntityTypes['sql']?.includes(tab.type))
openSqlTabs.forEach((tab) => {
const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id)
if (!!snippet && snippet.name !== tab.label) tabs.updateTab(tab.id, { label: snippet.name })
})
},
[]
)
}
interface UseTabsScrollOptions {
+40 -1
View File
@@ -1,13 +1,36 @@
import { Eye, GitBranch, Table2 } from 'lucide-react'
import { Eye, GitBranch, ScrollText, Table2 } from 'lucide-react'
import { cn, SQL_ICON } from 'ui'
import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
/**
* The single icon representing a logs (`log_sql`) snippet — reused by the tabs
* (via EntityTypeIcon) and the nav tree so the two can't drift. Callers pass the
* context-appropriate size/className; the icon and stroke stay the same.
*/
export const LogsSnippetIcon = ({
size = 15,
strokeWidth = 1.5,
className,
}: {
size?: number
strokeWidth?: number
className?: string
}) => (
<ScrollText
size={size}
strokeWidth={strokeWidth}
className={cn('transition-colors', className)}
/>
)
interface EntityTypeIconProps {
type: 'sql' | 'schema' | 'new' | 'r' | 'v' | 'm' | 'f' | 'p'
size?: number
strokeWidth?: number
isActive?: boolean
sqlSource?: SqlSnippetSource
}
export const EntityTypeIcon = ({
@@ -15,7 +38,23 @@ export const EntityTypeIcon = ({
size = 15,
strokeWidth = 1.5,
isActive,
sqlSource,
}: EntityTypeIconProps) => {
if (type === 'sql' && sqlSource === 'logs') {
return (
<LogsSnippetIcon
size={size}
strokeWidth={strokeWidth}
className={cn(
'text-foreground-muted',
'group-aria-selected:text-foreground',
'w-4 h-4',
'-ml-0.5'
)}
/>
)
}
if (type === 'sql') {
return (
<SQL_ICON
+1
View File
@@ -16,6 +16,7 @@ export const contentKeys = {
sqlSnippets: (
projectRef: string | undefined,
options?: {
type?: 'sql' | 'log_sql'
sort?: 'inserted_at' | 'name'
name?: string
visibility?: string
+17 -5
View File
@@ -9,9 +9,14 @@ import type { UseCustomInfiniteQueryOptions } from '@/types'
export type SqlSnippet = Extract<Content, { type: 'sql' }>
/** The snippet content types this query can list. Defaults to `'sql'`; the nav's
* Logs section passes `'log_sql'` to list logs snippets through the same shape. */
type SqlSnippetType = 'sql' | 'log_sql'
interface GetSqlSnippetsVariables {
projectRef?: string
cursor?: string
type?: SqlSnippetType
visibility?: SqlSnippet['visibility']
favorite?: boolean
name?: string
@@ -19,7 +24,7 @@ interface GetSqlSnippetsVariables {
}
export async function getSqlSnippets(
{ projectRef, cursor, visibility, favorite, name, sort }: GetSqlSnippetsVariables,
{ projectRef, cursor, type = 'sql', visibility, favorite, name, sort }: GetSqlSnippetsVariables,
signal?: AbortSignal
) {
if (typeof projectRef === 'undefined') {
@@ -32,7 +37,7 @@ export async function getSqlSnippets(
params: {
path: { ref: projectRef },
query: {
type: 'sql',
type,
cursor,
visibility,
favorite,
@@ -60,7 +65,14 @@ export type SqlSnippetsData = Awaited<ReturnType<typeof getSqlSnippets>>
export type SqlSnippetsError = unknown
export const useSqlSnippetsQuery = <TData = SqlSnippetsData>(
{ projectRef, sort, name, visibility, favorite }: Omit<GetSqlSnippetsVariables, 'cursor'>,
{
projectRef,
type = 'sql',
sort,
name,
visibility,
favorite,
}: Omit<GetSqlSnippetsVariables, 'cursor'>,
{
enabled = true,
...options
@@ -73,9 +85,9 @@ export const useSqlSnippetsQuery = <TData = SqlSnippetsData>(
> = {}
) =>
useInfiniteQuery({
queryKey: contentKeys.sqlSnippets(projectRef, { sort, name, visibility, favorite }),
queryKey: contentKeys.sqlSnippets(projectRef, { type, sort, name, visibility, favorite }),
queryFn: ({ signal, pageParam: cursor }) =>
getSqlSnippets({ projectRef, cursor, sort, name, visibility, favorite }, signal),
getSqlSnippets({ projectRef, cursor, type, sort, name, visibility, favorite }, signal),
enabled: enabled && typeof projectRef !== 'undefined',
initialPageParam: undefined,
getNextPageParam(lastPage) {
+24 -1
View File
@@ -2,11 +2,12 @@ import { usePrevious } from '@uidotdev/usehooks'
import { useParams } from 'common/hooks/useParams'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { useEffect, useEffectEvent } from 'react'
import { toast } from 'sonner'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
import { SQLEditor } from '@/components/interfaces/SQLEditor/SQLEditor'
import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
@@ -107,11 +108,33 @@ const SqlEditor: NextPageWithLayout = () => {
metadata: {
sqlId: id,
name: snippet?.name,
// The snippet may not be loaded yet at tab-creation time; the effect
// below backfills the source once it is. Source is immutable, so once
// set it never needs updating again.
...(snippet !== undefined && { sqlSource: getSnippetSource(snippet) }),
},
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady, id])
// Backfill the tab's source once the snippet loads. Covers both a freshly
// created tab whose snippet arrived after creation and tabs persisted before
// `sqlSource` existed (absent → filled from the loaded snippet type). Reads the
// latest tabs snapshot via useEffectEvent so the effect only re-runs on id/snippet.
const backfillTabSource = useEffectEvent(() => {
if (!id || id === 'new' || snippet === undefined) return
const tabId = createTabId('sql', { id })
const tab = tabs.tabsMap[tabId]
if (tab !== undefined && tab.metadata?.sqlSource === undefined) {
tabs.updateTab(tabId, { sqlSource: getSnippetSource(snippet) })
}
})
useEffect(() => {
backfillTabSource()
// eslint-disable-next-line react-hooks/exhaustive-deps -- useEffectEvent fn intentionally not a dep (eslint-plugin-react-hooks v5 doesn't correctly ignore useEffectEvent yet)
}, [id, snippet])
// The snippet no longer exists (e.g. deleted from another tab or session): clean up
// any stale tab and dashboard history references so navigation doesn't resurrect it,
// then fall back to a new snippet instead of rendering a dead state
+40
View File
@@ -72,6 +72,46 @@ describe('tabs recent items', () => {
})
})
describe('tabs sql source metadata', () => {
beforeEach(() => {
localStorage.clear()
})
it('backfills sqlSource onto a tab and its recent item', () => {
const store = createTabsState('default')
store.addTab({
id: 'sql-a',
type: 'sql',
label: 'Logs query',
metadata: { sqlId: 'a', name: 'Logs query' },
isPreview: false,
})
// Persisted before the field existed → absent until backfilled from the snippet.
expect(store.tabsMap['sql-a'].metadata?.sqlSource).toBeUndefined()
store.updateTab('sql-a', { sqlSource: 'logs' })
expect(store.tabsMap['sql-a'].metadata?.sqlSource).toBe('logs')
expect(store.recentItems[0].metadata?.sqlSource).toBe('logs')
})
it('carries sqlSource from a tab into its recent item on creation', () => {
const store = createTabsState('default')
store.addTab({
id: 'sql-a',
type: 'sql',
label: 'Logs query',
metadata: { sqlId: 'a', name: 'Logs query', sqlSource: 'logs' },
isPreview: false,
})
expect(store.recentItems[0].metadata?.sqlSource).toBe('logs')
})
})
describe('tabs removal', () => {
beforeEach(() => {
localStorage.clear()
+37 -14
View File
@@ -12,6 +12,7 @@ import {
import { proxy, subscribe, useSnapshot } from 'valtio'
import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils'
import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
export const editorEntityTypes = {
@@ -44,6 +45,14 @@ export interface Tab {
tableId?: number
sqlId?: string
scrollTop?: number
/**
* For SQL tabs, which backend the snippet queries (`'database'` | `'logs'`),
* so the tab can show the matching icon without re-fetching the snippet.
* Absent on tabs persisted before this field existed — treat absent as
* `'database'` and backfill from the loaded snippet (source is immutable, so
* it never goes stale once set).
*/
sqlSource?: SqlSnippetSource
}
isPreview?: boolean
createdAt?: Date
@@ -98,6 +107,7 @@ export interface RecentItem {
name?: string
tableId?: number
sqlId?: string
sqlSource?: SqlSnippetSource
}
}
@@ -270,22 +280,35 @@ export function createTabsState(projectRef: string) {
store.previewTabId = tab.id
store.activeTab = tab.id
},
updateTab: (id: string, updates: { label?: string; scrollTop?: number }) => {
if (!!store.tabsMap[id]) {
if ('label' in updates) {
store.tabsMap[id].label = updates.label
// Keep the persisted name aligned with the visible label so browser titles
// and tab state recover cleanly after entity renames.
if (typeof updates.label === 'string' && store.tabsMap[id].metadata) {
store.tabsMap[id].metadata.name = updates.label
}
updateTab: (
id: string,
updates: { label?: string; scrollTop?: number; sqlSource?: SqlSnippetSource }
) => {
const tab = store.tabsMap[id]
if (!tab) return
const recentItem = store.recentItems.find((item) => item.id === id)
if (recentItem) syncRecentItemWithTab(recentItem, store.tabsMap[id])
}
if ('scrollTop' in updates && store.tabsMap[id].metadata) {
store.tabsMap[id].metadata.scrollTop = updates.scrollTop
if ('label' in updates) {
tab.label = updates.label
// Keep the persisted name aligned with the visible label so browser titles
// and tab state recover cleanly after entity renames.
if (typeof updates.label === 'string' && tab.metadata) {
tab.metadata.name = updates.label
}
const recentItem = store.recentItems.find((item) => item.id === id)
if (recentItem) syncRecentItemWithTab(recentItem, tab)
}
if ('scrollTop' in updates && tab.metadata) {
tab.metadata.scrollTop = updates.scrollTop
}
// Backfill the immutable source onto a tab (and its recent item) that
// predates the field, so its icon resolves correctly once the snippet loads.
if (updates.sqlSource !== undefined) {
if (tab.metadata) tab.metadata.sqlSource = updates.sqlSource
else tab.metadata = { sqlSource: updates.sqlSource }
const recentItem = store.recentItems.find((item) => item.id === id)
if (recentItem) syncRecentItemWithTab(recentItem, tab)
}
},
// Function to remove a tab from the store