mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
<img width="1695" height="1042" alt="image" src="https://github.com/user-attachments/assets/b02e64ff-32ac-4aee-bda5-00b56f3a29e5" /> ## Summary - add routed Explorer chat pages backed by explicit assistant chat IDs - wait for persisted assistant state before creating chats so cold-load creation is not overwritten - register chat tabs and keep tab labels, navigation, close behavior, and missing-chat cleanup in sync - create and branch Explorer chats without changing the assistant sidebar selection - support both Next.js and TanStack Router paths This is PR 2 of 3 and is stacked on #48973. Review and merge #48973 first. The Explorer discovery, toolbar, and cross-surface entry points follow in #49032. This PR focuses purely on setting up chat tab types, routes and assistant conversation. ## To Test - Create a new assistant chat via the assistant sidebar, send a message etc - Copy the chat id - Visit /explorer/chat/[id] - Verify chat shows up, you can send more messages, chat is synced across tab and sidebar - Close the tab ## Test plan - `mise exec node@22 -- pnpm --dir apps/studio exec tsc --noEmit` - focused Vitest suite: 4 files / 30 tests covering assistant hydration, chat creation, routed chat rendering, and tab lifecycle - ESLint on changed TypeScript files - Prettier check on changed source files <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added Explorer chat pages with support for opening, selecting, and branching chats. * Added chat tabs, stable navigation, chat icons, and fallback behavior when tabs are closed. * Added chat creation that waits for assistant state to finish loading. * **Bug Fixes** * Removed tabs for deleted or unavailable chats. * Improved editor tab navigation and history clearing behavior. * **Tests** * Added coverage for chat routing, tab management, chat creation, and assistant-state loading. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
718 lines
24 KiB
TypeScript
718 lines
24 KiB
TypeScript
import { safeLocalStorage, useParams } from 'common'
|
|
import { partition } from 'lodash'
|
|
import { type NextRouter } from 'next/router'
|
|
import {
|
|
createContext,
|
|
PropsWithChildren,
|
|
useContext,
|
|
useEffect,
|
|
useState,
|
|
type ComponentType,
|
|
} from 'react'
|
|
import { proxy, subscribe, useSnapshot } from 'valtio'
|
|
|
|
import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils'
|
|
import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
|
|
import type { EditorType } from '@/components/layouts/editors/EditorsLayout.hooks'
|
|
import type { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
|
|
|
|
export const editorEntityTypes = {
|
|
table: ['r', 'v', 'm', 'f', 'p'],
|
|
sql: ['sql'],
|
|
explorer: ['notebook', 'query', 'chat'],
|
|
}
|
|
|
|
export type TabType = ENTITY_TYPE | 'sql' | 'notebook' | 'query' | 'chat' | 'explorer-home'
|
|
|
|
/** Fixed id for Explorer's pinned, non-closable Home tab. */
|
|
export const EXPLORER_HOME_TAB_ID = 'explorer-home'
|
|
|
|
/** Tab descriptor for Explorer's Home tab — shared by its trigger and its page. */
|
|
export const EXPLORER_HOME_TAB: Tab = {
|
|
id: EXPLORER_HOME_TAB_ID,
|
|
type: 'explorer-home',
|
|
label: 'Home',
|
|
isPreview: false,
|
|
closable: false,
|
|
}
|
|
|
|
type CreateTabIdParams = {
|
|
r: { id: number }
|
|
v: { id: number }
|
|
m: { id: number }
|
|
f: { id: number }
|
|
p: { id: number }
|
|
sql: { id: string }
|
|
notebook: { id: string }
|
|
query: { id: string }
|
|
chat: { id: string }
|
|
schema: { schema: string }
|
|
view: never
|
|
function: never
|
|
new: never
|
|
'explorer-home': never
|
|
}
|
|
|
|
export interface Tab {
|
|
id: string
|
|
type: TabType
|
|
label?: string
|
|
metadata?: {
|
|
schema?: string
|
|
name?: string
|
|
tableId?: number
|
|
sqlId?: string
|
|
notebookId?: string
|
|
queryId?: string
|
|
chatId?: 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
|
|
/**
|
|
* Whether the tab can be closed by the user (close button, keyboard shortcut,
|
|
* or a bulk close action). Defaults to `true` — absent on every tab except a
|
|
* pinned default (e.g. Explorer's Home tab), which sets this `false`.
|
|
*/
|
|
closable?: boolean
|
|
createdAt?: Date
|
|
updatedAt?: Date
|
|
}
|
|
|
|
/** Copy shown in the confirmation dialog before a close is allowed to proceed. */
|
|
export interface TabCloseConfirmation {
|
|
title: string
|
|
description: string
|
|
}
|
|
|
|
/**
|
|
* Per-tab-type behavior and UI the tabs layout delegates to, so the layout
|
|
* stays agnostic of what any given tab kind means. A domain (e.g. the SQL
|
|
* editor) registers a handler for its tab type via `registerTabTypeHandler`;
|
|
* tabs of types without a handler close with no extra behavior and show no
|
|
* status indicator.
|
|
*/
|
|
export interface TabTypeHandler {
|
|
/**
|
|
* Cleanup to run when the user closes a tab of this type (e.g. discarding a
|
|
* SQL snippet's unsaved local edits). Runs after the tab has been removed.
|
|
*/
|
|
onClose?: (tab: Tab) => void
|
|
/**
|
|
* Whether closing these tabs needs user confirmation. Receives the whole set
|
|
* of this type being closed (e.g. a bulk "Close Others") so the handler owns
|
|
* the dialog copy, including wording it for one vs. many. Return the copy to
|
|
* confirm first; return null/undefined to close immediately.
|
|
*/
|
|
confirmClose?: (tabs: Tab[]) => TabCloseConfirmation | null | undefined
|
|
/**
|
|
* Optional component rendered inside the tab to show type-specific status
|
|
* (e.g. a VS Code-style unsaved-changes dot for a SQL snippet). Owning the
|
|
* component here keeps the layout agnostic of what "status" means per type and
|
|
* lets the domain drive its own reactivity. Rendered only when it has
|
|
* something to show; otherwise it should render nothing.
|
|
*/
|
|
StatusIndicator?: ComponentType<{ tab: Tab }>
|
|
}
|
|
|
|
const MAX_RECENT_ITEMS = 8
|
|
|
|
export interface RecentItem {
|
|
id: string
|
|
type: TabType
|
|
label: string
|
|
timestamp: number
|
|
metadata?: {
|
|
schema?: string
|
|
name?: string
|
|
tableId?: number
|
|
sqlId?: string
|
|
notebookId?: string
|
|
queryId?: string
|
|
chatId?: string
|
|
sqlSource?: SqlSnippetSource
|
|
}
|
|
}
|
|
|
|
const RECENT_ITEMS_STORAGE_KEY = 'supabase_recent_items'
|
|
const getRecentItemsStorageKey = (ref: string) => `${RECENT_ITEMS_STORAGE_KEY}_${ref}`
|
|
|
|
function getSavedRecentItems(ref: string): RecentItem[] {
|
|
if (!ref) return []
|
|
|
|
const stored = safeLocalStorage.getItem(getRecentItemsStorageKey(ref))
|
|
|
|
try {
|
|
return JSON.parse(stored ?? '{"items": []}').items
|
|
} catch (error) {
|
|
return []
|
|
}
|
|
}
|
|
|
|
const createDefaultTabsState = () => ({
|
|
activeTab: null as string | null,
|
|
openTabs: [] as string[],
|
|
tabsMap: {} as Record<string, Tab>,
|
|
previewTabId: undefined as string | undefined,
|
|
recentItems: [],
|
|
})
|
|
const TABS_STORAGE_KEY = 'supabase_studio_tabs'
|
|
const getTabsStorageKey = (ref: string) => `${TABS_STORAGE_KEY}_${ref}`
|
|
|
|
function getSavedTabs(ref: string) {
|
|
if (!ref) return createDefaultTabsState()
|
|
|
|
const stored = safeLocalStorage.getItem(getTabsStorageKey(ref))
|
|
|
|
if (!stored) return createDefaultTabsState()
|
|
|
|
try {
|
|
const parsed = JSON.parse(stored ?? JSON.stringify(createDefaultTabsState())) as ReturnType<
|
|
typeof createDefaultTabsState
|
|
>
|
|
|
|
if (
|
|
!parsed.openTabs ||
|
|
!Array.isArray(parsed.openTabs) ||
|
|
!parsed.tabsMap ||
|
|
typeof parsed.tabsMap !== 'object'
|
|
) {
|
|
return createDefaultTabsState()
|
|
}
|
|
|
|
return parsed
|
|
} catch (error) {
|
|
return createDefaultTabsState()
|
|
}
|
|
}
|
|
|
|
const getRecentItemLabel = (tab: Pick<Tab, 'label' | 'metadata'>) =>
|
|
tab.label || tab.metadata?.name || 'Untitled'
|
|
|
|
const syncRecentItemWithTab = (item: RecentItem, tab: Pick<Tab, 'label' | 'metadata'>) => {
|
|
const nextLabel = getRecentItemLabel(tab)
|
|
|
|
item.label = nextLabel
|
|
item.metadata = {
|
|
...item.metadata,
|
|
...tab.metadata,
|
|
name: nextLabel,
|
|
}
|
|
}
|
|
|
|
export function createTabsState(projectRef: string) {
|
|
const recentItems = getSavedRecentItems(projectRef)
|
|
const { openTabs, activeTab, tabsMap, previewTabId } = getSavedTabs(projectRef)
|
|
|
|
// Per-type behavior/UI, kept outside the Valtio proxy so handler closures
|
|
// (which may capture non-serializable things like a React Query client or a
|
|
// React component) are never proxied or persisted.
|
|
const tabHandlers = new Map<TabType, TabTypeHandler>()
|
|
|
|
const store = proxy({
|
|
// RECENT ITEMS
|
|
recentItems,
|
|
|
|
addRecentItem: (tab: Tab) => {
|
|
// Check if an item with the same ID already exists
|
|
const existingItem = store.recentItems.find((item) => item.id === tab.id)
|
|
|
|
if (existingItem) {
|
|
// If it exists, update its timestamp
|
|
existingItem.timestamp = Date.now()
|
|
syncRecentItemWithTab(existingItem, tab)
|
|
return // Exit the function
|
|
}
|
|
|
|
// If it doesn't exist, create and add a new item
|
|
const recentItem: RecentItem = {
|
|
id: tab.id, // Set the ID
|
|
type: tab.type, // Set the type
|
|
label: getRecentItemLabel(tab), // Set the label or default to 'Untitled'
|
|
timestamp: Date.now(), // Set the current timestamp
|
|
metadata: tab.metadata, // Set the metadata
|
|
}
|
|
|
|
// Add the new recent item to the beginning of the list
|
|
store.recentItems.unshift(recentItem)
|
|
|
|
// Ensure that there's only up to max of MAX_RECENT_ITEMS items per tab type
|
|
const [itemsOfSameType, itemsOfDifferentType] = partition(store.recentItems, (item) => {
|
|
if (editorEntityTypes.table.includes(item.type)) return item
|
|
})
|
|
store.recentItems = [...itemsOfSameType.slice(0, MAX_RECENT_ITEMS), ...itemsOfDifferentType]
|
|
},
|
|
clearRecentItems: () => {
|
|
store.recentItems = []
|
|
},
|
|
removeRecentItem: (itemId: string) => {
|
|
store.recentItems = store.recentItems.filter((item) => item.id !== itemId)
|
|
},
|
|
removeRecentItems: (itemIds: string[]) => {
|
|
store.recentItems = store.recentItems.filter((item) => !itemIds.includes(item.id))
|
|
},
|
|
removeRecentItemsByType: (type: TabType) => {
|
|
store.recentItems = store.recentItems.filter((item) => item.type !== type)
|
|
},
|
|
|
|
getRecentItemsByType: (type: TabType) => {
|
|
return store.recentItems.filter((item) => item.type === type)
|
|
},
|
|
|
|
// TABS
|
|
activeTab,
|
|
openTabs,
|
|
tabsMap,
|
|
previewTabId,
|
|
|
|
hasTab: (id: string) => {
|
|
return !!store.tabsMap[id]
|
|
},
|
|
addTab: (tab: Tab) => {
|
|
// If tab exists and is active, don't do anything
|
|
if (store.tabsMap[tab.id] && store.activeTab === tab.id) {
|
|
return
|
|
}
|
|
|
|
// If tab exists but isn't active, just make it active
|
|
if (store.tabsMap[tab.id]) {
|
|
store.activeTab = tab.id
|
|
if (!tab.isPreview) store.addRecentItem(tab)
|
|
return
|
|
}
|
|
|
|
// If this tab should be permanent, add it normally
|
|
if (tab.isPreview === false) {
|
|
store.openTabs = [...store.openTabs, tab.id]
|
|
store.tabsMap[tab.id] = tab
|
|
store.activeTab = tab.id
|
|
// Add to recent items when creating permanent tab
|
|
store.addRecentItem(tab)
|
|
return
|
|
}
|
|
|
|
// Remove any existing preview tab
|
|
if (store.previewTabId) {
|
|
store.openTabs = store.openTabs.filter((id) => id !== store.previewTabId)
|
|
delete store.tabsMap[store.previewTabId]
|
|
}
|
|
|
|
// Add new preview tab
|
|
store.tabsMap[tab.id] = { ...tab, isPreview: true }
|
|
store.openTabs = [...store.openTabs, tab.id]
|
|
store.previewTabId = tab.id
|
|
store.activeTab = tab.id
|
|
},
|
|
// Ensures a tab that's always present, first, and outside the draggable/
|
|
// closable set (e.g. Explorer's Home tab) exists in the store, without
|
|
// touching which tab is active. Safe to call from wherever the pinned
|
|
// tab's trigger renders, regardless of which page currently owns focus.
|
|
ensurePinnedTab: (tab: Tab) => {
|
|
if (store.tabsMap[tab.id]) return
|
|
store.tabsMap[tab.id] = tab
|
|
store.openTabs = [tab.id, ...store.openTabs]
|
|
},
|
|
// Ensures a pinned tab exists (see ensurePinnedTab) and marks it active,
|
|
// without recording it in Recent Items — it isn't content to revisit,
|
|
// just a fixed destination. Call this from the page it represents, on
|
|
// mount, mirroring how regular tabs call addTab from their own page.
|
|
activatePinnedTab: (tab: Tab) => {
|
|
store.ensurePinnedTab(tab)
|
|
store.activeTab = tab.id
|
|
},
|
|
updateTab: (
|
|
id: string,
|
|
updates: { label?: string; scrollTop?: number; sqlSource?: SqlSnippetSource }
|
|
) => {
|
|
const tab = store.tabsMap[id]
|
|
if (!tab) return
|
|
|
|
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
|
|
// this is used for removing tabs from the localstorage state
|
|
// for handling a manual tab removal with a close action, use handleTabClose()
|
|
removeTab: (id: string) => {
|
|
if (store.tabsMap[id]?.closable === false) return
|
|
|
|
const idx = store.openTabs.indexOf(id)
|
|
store.openTabs = store.openTabs.filter((tabId) => tabId !== id)
|
|
delete store.tabsMap[id]
|
|
|
|
// Clear the preview tab reference if the removed tab was the preview tab,
|
|
// so it doesn't linger in (persisted) state pointing at a closed tab
|
|
if (store.previewTabId === id) {
|
|
store.previewTabId = undefined
|
|
}
|
|
|
|
// Update active tab if the removed tab was active
|
|
if (id === store.activeTab) {
|
|
store.activeTab = store.openTabs[idx - 1] || store.openTabs[idx + 1] || null
|
|
}
|
|
},
|
|
|
|
// Function to remove multiple tabs from the store
|
|
// this is used for removing tabs from the localstorage state
|
|
// for handling a manual tab removal with a close action, use handleTabClose()
|
|
removeTabs: (ids: string[]) => {
|
|
if (!ids.length) return
|
|
|
|
ids.forEach((id) => store.removeTab(id))
|
|
},
|
|
reorderTabs: (oldIndex: number, newIndex: number) => {
|
|
const newOpenTabs = [...store.openTabs]
|
|
const [removedTab] = newOpenTabs.splice(oldIndex, 1)
|
|
newOpenTabs.splice(newIndex, 0, removedTab)
|
|
store.openTabs = newOpenTabs
|
|
},
|
|
makeTabActive: (tabId: string) => {
|
|
const tab = store.tabsMap[tabId]
|
|
if (!tab) return
|
|
store.activeTab = tab.id
|
|
},
|
|
makeTabPermanent: (tabId: string) => {
|
|
const tab = store.tabsMap[tabId]
|
|
|
|
if (tab?.isPreview) {
|
|
tab.isPreview = false
|
|
store.previewTabId = undefined
|
|
// Add to recent items when preview tab becomes permanent
|
|
store.addRecentItem(tab)
|
|
}
|
|
},
|
|
makeActiveTabPermanent: () => {
|
|
if (store.activeTab && store.tabsMap[store.activeTab]?.isPreview) {
|
|
store.makeTabPermanent(store.activeTab)
|
|
return true
|
|
}
|
|
return false
|
|
},
|
|
|
|
// TABS HANDLERS
|
|
|
|
handleTabNavigation: (id: string, router: NextRouter) => {
|
|
const tab = store.tabsMap[id]
|
|
if (!tab) return
|
|
|
|
store.activeTab = id
|
|
|
|
// Add to recent items when navigating to a non-preview, non-new tab.
|
|
// Pinned tabs (e.g. Explorer's Home) are a fixed destination, not content
|
|
// to revisit, so they're excluded regardless of preview state.
|
|
if (!tab.isPreview && tab.closable !== false) store.addRecentItem(tab)
|
|
|
|
switch (tab.type) {
|
|
case 'sql':
|
|
const schema = (router.query.schema as string) || 'public'
|
|
router.push(`/project/${router.query.ref}/sql/${tab.metadata?.sqlId}?schema=${schema}`)
|
|
break
|
|
case 'notebook':
|
|
router.push(`/project/${router.query.ref}/explorer/notebook/${tab.metadata?.notebookId}`)
|
|
break
|
|
case 'query':
|
|
router.push(`/project/${router.query.ref}/explorer/query/${tab.metadata?.queryId}`)
|
|
break
|
|
case 'chat':
|
|
router.push(`/project/${router.query.ref}/explorer/chat/${tab.metadata?.chatId}`)
|
|
break
|
|
case 'explorer-home':
|
|
router.push(`/project/${router.query.ref}/explorer`)
|
|
break
|
|
case 'r':
|
|
case 'v':
|
|
case 'm':
|
|
case 'f':
|
|
case 'p':
|
|
router.push(
|
|
buildTableEditorUrl({
|
|
projectRef: router.query.ref as string,
|
|
tableId: tab.metadata?.tableId!,
|
|
schema: tab.metadata?.schema,
|
|
})
|
|
)
|
|
break
|
|
}
|
|
},
|
|
// TAB TYPE HANDLER REGISTRY
|
|
//
|
|
// Lets a domain own what a tab of its type means — how it closes and what
|
|
// status it shows — without the layout having to know. Registered per tab
|
|
// type; returns an unregister function.
|
|
//
|
|
// Bumped on every (un)register so components that render per-type UI (the
|
|
// status indicator) re-render to pick up a handler registered after they
|
|
// first rendered — handlers register in an effect, which runs after the
|
|
// tabs first paint.
|
|
handlerRegistrationVersion: 0,
|
|
registerTabTypeHandler: (type: TabType, handler: TabTypeHandler) => {
|
|
tabHandlers.set(type, handler)
|
|
store.handlerRegistrationVersion++
|
|
return () => {
|
|
if (tabHandlers.get(type) === handler) {
|
|
tabHandlers.delete(type)
|
|
store.handlerRegistrationVersion++
|
|
}
|
|
}
|
|
},
|
|
|
|
// The status-indicator component registered for a tab type, if any. Read
|
|
// `handlerRegistrationVersion` alongside this in render to stay reactive to
|
|
// late registration.
|
|
getTabStatusIndicator: (type: TabType) => tabHandlers.get(type)?.StatusIndicator,
|
|
|
|
// The confirmation to show before closing the given tabs, or null if none
|
|
// need confirming. Tabs are grouped by type and each type's handler is asked
|
|
// about its own set (so it can word the copy for one vs. many); the first
|
|
// handler that asks to confirm wins. The store authors no copy itself — that
|
|
// stays a concern of the registering domain.
|
|
getCloseConfirmation: (ids: string[]): TabCloseConfirmation | null => {
|
|
const tabsByType = new Map<TabType, Tab[]>()
|
|
for (const id of ids) {
|
|
const tab = store.tabsMap[id]
|
|
if (!tab) continue
|
|
const group = tabsByType.get(tab.type)
|
|
if (group) group.push(tab)
|
|
else tabsByType.set(tab.type, [tab])
|
|
}
|
|
|
|
for (const [type, tabs] of tabsByType) {
|
|
const confirmation = tabHandlers.get(type)?.confirmClose?.(tabs)
|
|
if (confirmation) return confirmation
|
|
}
|
|
return null
|
|
},
|
|
|
|
// Close multiple tabs as an intentional user action, running each tab type's
|
|
// close handler afterwards. Distinct from `removeTabs`, the low-level store
|
|
// mutation used for re-keying (rename/move) and stale cleanup, which must
|
|
// NOT trigger discard behavior.
|
|
closeTabs: (ids: string[]) => {
|
|
const closedTabs = ids
|
|
.map((id) => store.tabsMap[id])
|
|
.filter((tab): tab is Tab => tab !== undefined && tab.closable !== false)
|
|
store.removeTabs(closedTabs.map((tab) => tab.id))
|
|
closedTabs.forEach((tab) => tabHandlers.get(tab.type)?.onClose?.(tab))
|
|
},
|
|
|
|
handleTabClose: ({
|
|
id,
|
|
router,
|
|
editor,
|
|
onClose,
|
|
onClearDashboardHistory,
|
|
}: {
|
|
id: string
|
|
router: NextRouter
|
|
editor?: EditorType
|
|
onClose?: (id: string) => void
|
|
onClearDashboardHistory: () => void
|
|
}) => {
|
|
const tabBeingClosed = store.tabsMap[id]
|
|
|
|
const editorTabIds = (
|
|
editor
|
|
? Object.values(store.tabsMap).filter((tab) =>
|
|
editorEntityTypes[editor]?.includes(tab.type)
|
|
)
|
|
: []
|
|
).map((tab) => tab.id)
|
|
const tabIndexBeingClosed = editorTabIds.indexOf(id)
|
|
const isLastTabBeingClosed = tabIndexBeingClosed === editorTabIds.length - 1
|
|
const nextTabId =
|
|
editorTabIds.length === 1
|
|
? undefined
|
|
: isLastTabBeingClosed
|
|
? editorTabIds[tabIndexBeingClosed - 1]
|
|
: editorTabIds[tabIndexBeingClosed + 1]
|
|
|
|
const { [id]: value, ...otherTabs } = store.tabsMap
|
|
store.tabsMap = otherTabs
|
|
|
|
if (tabBeingClosed) {
|
|
const updatedOpenTabs = [...store.openTabs].filter((x) => x !== id)
|
|
store.openTabs = updatedOpenTabs
|
|
}
|
|
|
|
// Remove the preview tab if it matches the tab being closed
|
|
if (store.previewTabId === id) {
|
|
store.previewTabId = undefined
|
|
}
|
|
|
|
// [Joshen] Only navigate away if we're closing the tab that's currently in focus
|
|
if (store.activeTab === id || id === 'new') {
|
|
if (nextTabId) {
|
|
store.activeTab = nextTabId
|
|
store.handleTabNavigation(nextTabId, router)
|
|
} else {
|
|
onClearDashboardHistory()
|
|
|
|
// If no tabs of same type, go to the home of the current section
|
|
switch (tabBeingClosed?.type) {
|
|
case 'sql':
|
|
router.push(`/project/${router.query.ref}/sql`)
|
|
break
|
|
case 'notebook':
|
|
case 'query':
|
|
case 'chat':
|
|
router.push(`/project/${router.query.ref}/explorer`)
|
|
break
|
|
case 'r':
|
|
case 'v':
|
|
case 'm':
|
|
case 'f':
|
|
case 'p':
|
|
router.push(`/project/${router.query.ref}/editor`)
|
|
break
|
|
default:
|
|
router.push(`/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
onClose?.(id)
|
|
|
|
// Run the tab type's registered close behavior (e.g. discard a SQL
|
|
// snippet's unsaved edits). `tabBeingClosed` is captured before removal.
|
|
if (tabBeingClosed) {
|
|
tabHandlers.get(tabBeingClosed.type)?.onClose?.(tabBeingClosed)
|
|
}
|
|
},
|
|
handleTabCloseAll: ({
|
|
editor,
|
|
router,
|
|
onClearDashboardHistory,
|
|
}: {
|
|
editor: 'sql' | 'table' | 'explorer'
|
|
router: NextRouter
|
|
onClearDashboardHistory: () => void
|
|
}) => {
|
|
const tabsToClose = store.openTabs.filter((id) => {
|
|
const tab = store.tabsMap[id]
|
|
return tab !== undefined && editorEntityTypes[editor].includes(tab.type)
|
|
})
|
|
store.closeTabs(tabsToClose)
|
|
onClearDashboardHistory()
|
|
const editorPath = editor === 'table' ? 'editor' : editor
|
|
router.push(`/project/${router.query.ref}/${editorPath}`)
|
|
},
|
|
handleTabDragEnd: (oldIndex: number, newIndex: number, tabId: string, router: NextRouter) => {
|
|
// Make permanent if needed
|
|
const draggedTab = store.tabsMap[tabId]
|
|
if (draggedTab?.isPreview) {
|
|
store.makeTabPermanent(tabId)
|
|
}
|
|
|
|
// Reorder tabs
|
|
const newOpenTabs = [...store.openTabs]
|
|
newOpenTabs.splice(oldIndex, 1)
|
|
newOpenTabs.splice(newIndex, 0, tabId)
|
|
|
|
store.openTabs = newOpenTabs
|
|
store.activeTab = tabId
|
|
|
|
// Handle navigation
|
|
store.handleTabNavigation(tabId, router)
|
|
},
|
|
})
|
|
|
|
return store
|
|
}
|
|
|
|
export type TabsState = ReturnType<typeof createTabsState>
|
|
|
|
export const TabsStateContext = createContext<TabsState>(createTabsState(''))
|
|
|
|
export const TabsStateContextProvider = ({ children }: PropsWithChildren) => {
|
|
const { ref: projectRef } = useParams()
|
|
const [state, setState] = useState(createTabsState(projectRef ?? ''))
|
|
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined' && !!projectRef) {
|
|
setState(createTabsState(projectRef ?? ''))
|
|
}
|
|
}, [projectRef])
|
|
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined' && projectRef) {
|
|
return subscribe(state, () => {
|
|
safeLocalStorage.setItem(
|
|
getTabsStorageKey(projectRef),
|
|
JSON.stringify({
|
|
activeTab: state.activeTab,
|
|
openTabs: state.openTabs,
|
|
tabsMap: state.tabsMap,
|
|
previewTabId: state.previewTabId,
|
|
})
|
|
)
|
|
safeLocalStorage.setItem(
|
|
getRecentItemsStorageKey(projectRef),
|
|
JSON.stringify({
|
|
items: state.recentItems,
|
|
})
|
|
)
|
|
})
|
|
}
|
|
}, [projectRef, state])
|
|
|
|
return <TabsStateContext.Provider value={state}>{children}</TabsStateContext.Provider>
|
|
}
|
|
|
|
export const useTabsStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) => {
|
|
const state = useContext(TabsStateContext)
|
|
return useSnapshot(state, options)
|
|
}
|
|
|
|
export function createTabId<T extends TabType>(type: T, params: CreateTabIdParams[T]): string {
|
|
switch (type) {
|
|
case 'r':
|
|
return `r-${(params as CreateTabIdParams['r']).id}`
|
|
case 'v':
|
|
return `v-${(params as CreateTabIdParams['v']).id}`
|
|
case 'm':
|
|
return `m-${(params as CreateTabIdParams['m']).id}`
|
|
case 'f':
|
|
return `f-${(params as CreateTabIdParams['f']).id}`
|
|
case 'p':
|
|
return `p-${(params as CreateTabIdParams['p']).id}`
|
|
case 'sql':
|
|
return `sql-${(params as CreateTabIdParams['sql']).id}`
|
|
case 'notebook':
|
|
return `notebook-${(params as CreateTabIdParams['notebook']).id}`
|
|
case 'query':
|
|
return `query-${(params as CreateTabIdParams['query']).id}`
|
|
case 'chat':
|
|
return `chat-${(params as CreateTabIdParams['chat']).id}`
|
|
default:
|
|
return ''
|
|
}
|
|
}
|