From b3c5c9fc049051ca656d732872497f41521e7364 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:02:39 -0400 Subject: [PATCH] feat(studio): logs snippets in SQL editor nav, search, and tabs (#48457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) ✓ ## 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. --- .../SQLEditorNavV2/LogsSnippetsSection.tsx | 136 ++++++++++++ .../SQLEditorNavV2/SQLEditorNav.constants.ts | 2 + .../SQLEditorNavV2/SQLEditorNav.tsx | 115 ++++++++-- .../SQLEditorNavV2/SQLEditorNav.utils.ts | 21 ++ .../SQLEditorNavV2/SQLEditorTreeViewItem.tsx | 11 + .../SQLEditorNavV2/SearchList.tsx | 203 ++++++++++-------- .../SQLEditorNavV2/SqlSnippetTree.tsx | 122 +++++++++++ .../components/layouts/Tabs/RecentItems.tsx | 2 +- .../components/layouts/Tabs/SortableTab.tsx | 2 +- .../components/layouts/Tabs/TabPreview.tsx | 2 +- .../layouts/Tabs/Tabs.utils.test.tsx | 91 ++++++++ .../components/layouts/Tabs/Tabs.utils.ts | 93 +++++--- apps/studio/components/ui/EntityTypeIcon.tsx | 41 +++- apps/studio/data/content/keys.ts | 1 + .../studio/data/content/sql-snippets-query.ts | 22 +- apps/studio/pages/project/[ref]/sql/[id].tsx | 25 ++- apps/studio/state/tabs.test.ts | 40 ++++ apps/studio/state/tabs.tsx | 51 +++-- 18 files changed, 805 insertions(+), 175 deletions(-) create mode 100644 apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/LogsSnippetsSection.tsx create mode 100644 apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SqlSnippetTree.tsx create mode 100644 apps/studio/components/layouts/Tabs/Tabs.utils.test.tsx diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/LogsSnippetsSection.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/LogsSnippetsSection.tsx new file mode 100644 index 00000000000..1caf2bff1f9 --- /dev/null +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/LogsSnippetsSection.tsx @@ -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( + 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 ( + + 0 ? ` (${numSnippets})` : ''}`} + /> + + {isLoading && } + {!isLoading && snippets.length === 0 && ( + + )} + {!isLoading && snippets.length > 0 && ( + + )} + + + ) +} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.constants.ts b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.constants.ts index 72f80c44725..623bc4119c4 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.constants.ts +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.constants.ts @@ -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, } diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index 9a536480ccb..57ef4715aea 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -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) => { + {canShowLogsSection && ( + <> + + 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) + }} + /> + + + + )} + diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.utils.ts b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.utils.ts index 65f8dbd2861..f0f12761928 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.utils.ts +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.utils.ts @@ -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( + 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() diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx index 21ae2a5e129..615eecc6aad 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx @@ -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 ? ( + + ) : undefined + } onEditSubmit={(value) => { if (onEditSave !== undefined) onEditSave(value) }} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx index 9d6a349b730..a9a9c89f336 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx @@ -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 }) => ( +

{children}

+) + +/** + * 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() @@ -32,120 +48,121 @@ export const SearchList = ({ search }: SearchListProps) => { const [selectedSnippetToRename, setSelectedSnippetToRename] = useState() const [selectedSnippetToDelete, setSelectedSnippetToDelete] = useState() + // 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 ( <>
- {isLoadingCount ? ( + {isLoadingCounts && (
- ) : !!count ? ( + )} + {!isLoadingCounts && hasCounts && (

- {totalNumber} result{totalNumber > 1 ? 's' : ''} found + {totalNumber} result{totalNumber === 1 ? '' : 's'} found

- ) : null} - {isLoading ? ( + )} + {isSearchLoading && (
- ) : ( - { - 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 ( - - {element.name} - {!!visibility && ( - {visibility} - )} - - ), - }} - 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 && ( +

No queries found

+ )} + {!isSearchLoading && hasDatabaseResults && ( + <> + {showGroupHeadings && Database} + + + )} + {!isSearchLoading && canShowLogsSection && hasLogsResults && ( + <> + {showGroupHeadings && Logs} + + )}
diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SqlSnippetTree.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SqlSnippetTree.tsx new file mode 100644 index 00000000000..4ee68e623c2 --- /dev/null +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SqlSnippetTree.tsx @@ -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 + /** 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 ( + { + 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 ( + + {element.name} + {!!sublabel && ( + {sublabel} + )} + + ), + } + : 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) + }} + /> + ) + }} + /> + ) +} diff --git a/apps/studio/components/layouts/Tabs/RecentItems.tsx b/apps/studio/components/layouts/Tabs/RecentItems.tsx index 165dd480436..2bae3f19acb 100644 --- a/apps/studio/components/layouts/Tabs/RecentItems.tsx +++ b/apps/studio/components/layouts/Tabs/RecentItems.tsx @@ -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" >
- +
diff --git a/apps/studio/components/layouts/Tabs/SortableTab.tsx b/apps/studio/components/layouts/Tabs/SortableTab.tsx index c66a3a003a1..8c3eccdd38c 100644 --- a/apps/studio/components/layouts/Tabs/SortableTab.tsx +++ b/apps/studio/components/layouts/Tabs/SortableTab.tsx @@ -104,7 +104,7 @@ export const SortableTab = ({ )} {...listeners} > - +
{shouldShowSchema && ( diff --git a/apps/studio/components/layouts/Tabs/TabPreview.tsx b/apps/studio/components/layouts/Tabs/TabPreview.tsx index f2fa1073a0c..ef5825f49ba 100644 --- a/apps/studio/components/layouts/Tabs/TabPreview.tsx +++ b/apps/studio/components/layouts/Tabs/TabPreview.tsx @@ -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" > - + {tabData.label || 'Untitled'}
diff --git a/apps/studio/components/layouts/Tabs/Tabs.utils.test.tsx b/apps/studio/components/layouts/Tabs/Tabs.utils.test.tsx new file mode 100644 index 00000000000..8bb69423320 --- /dev/null +++ b/apps/studio/components/layouts/Tabs/Tabs.utils.test.tsx @@ -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) { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + 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') + }) +}) diff --git a/apps/studio/components/layouts/Tabs/Tabs.utils.ts b/apps/studio/components/layouts/Tabs/Tabs.utils.ts index eae4578697f..ad95bf40784 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.utils.ts +++ b/apps/studio/components/layouts/Tabs/Tabs.utils.ts @@ -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 { diff --git a/apps/studio/components/ui/EntityTypeIcon.tsx b/apps/studio/components/ui/EntityTypeIcon.tsx index f62f616ba10..2dc05a6df58 100644 --- a/apps/studio/components/ui/EntityTypeIcon.tsx +++ b/apps/studio/components/ui/EntityTypeIcon.tsx @@ -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 +}) => ( + +) + 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 ( + + ) + } + if (type === 'sql') { return ( +/** 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> export type SqlSnippetsError = unknown export const useSqlSnippetsQuery = ( - { projectRef, sort, name, visibility, favorite }: Omit, + { + projectRef, + type = 'sql', + sort, + name, + visibility, + favorite, + }: Omit, { enabled = true, ...options @@ -73,9 +85,9 @@ export const useSqlSnippetsQuery = ( > = {} ) => 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) { diff --git a/apps/studio/pages/project/[ref]/sql/[id].tsx b/apps/studio/pages/project/[ref]/sql/[id].tsx index f40ea989fbf..0f1514f93f2 100644 --- a/apps/studio/pages/project/[ref]/sql/[id].tsx +++ b/apps/studio/pages/project/[ref]/sql/[id].tsx @@ -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 diff --git a/apps/studio/state/tabs.test.ts b/apps/studio/state/tabs.test.ts index 9cbfa766a10..000814297f7 100644 --- a/apps/studio/state/tabs.test.ts +++ b/apps/studio/state/tabs.test.ts @@ -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() diff --git a/apps/studio/state/tabs.tsx b/apps/studio/state/tabs.tsx index c0ef9b95fe2..85c1dc9d56a 100644 --- a/apps/studio/state/tabs.tsx +++ b/apps/studio/state/tabs.tsx @@ -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