| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498 |
- import { useParams } from 'common'
- import { partition } from 'lodash'
- import { type NextRouter } from 'next/router'
- import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'
- import { proxy, subscribe, useSnapshot } from 'valtio'
- import { buildTableEditorUrl } from '@/components/grid/BrivenGrid.utils'
- import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
- export const editorEntityTypes = {
- table: ['r', 'v', 'm', 'f', 'p'],
- sql: ['sql'],
- }
- export type TabType = ENTITY_TYPE | 'sql'
- type CreateTabIdParams = {
- r: { id: number }
- v: { id: number }
- m: { id: number }
- f: { id: number }
- p: { id: number }
- sql: { id: string }
- schema: { schema: string }
- view: never
- function: never
- new: never
- }
- export interface Tab {
- id: string
- type: TabType
- label?: string
- metadata?: {
- schema?: string
- name?: string
- tableId?: number
- sqlId?: string
- scrollTop?: number
- }
- isPreview?: boolean
- createdAt?: Date
- updatedAt?: Date
- }
- 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
- }
- }
- const RECENT_ITEMS_STORAGE_KEY = 'briven_recent_items'
- const getRecentItemsStorageKey = (ref: string) => `${RECENT_ITEMS_STORAGE_KEY}_${ref}`
- function getSavedRecentItems(ref: string): RecentItem[] {
- if (typeof window === 'undefined' || !ref) return []
- const stored = localStorage.getItem(getRecentItemsStorageKey(ref))
- try {
- return JSON.parse(stored ?? '{"items": []}').items
- } catch (error) {
- return []
- }
- }
- const DEFAULT_TABS_STATE = {
- activeTab: null as string | null,
- openTabs: [] as string[],
- tabsMap: {} as Record<string, Tab>,
- previewTabId: undefined as string | undefined,
- recentItems: [],
- }
- const TABS_STORAGE_KEY = 'briven_studio_tabs'
- const getTabsStorageKey = (ref: string) => `${TABS_STORAGE_KEY}_${ref}`
- function getSavedTabs(ref: string) {
- if (typeof window === 'undefined' || !ref) return DEFAULT_TABS_STATE
- const stored = localStorage.getItem(getTabsStorageKey(ref))
- if (!stored) return DEFAULT_TABS_STATE
- try {
- const parsed = JSON.parse(
- stored ?? JSON.stringify(DEFAULT_TABS_STATE)
- ) as typeof DEFAULT_TABS_STATE
- if (
- !parsed.openTabs ||
- !Array.isArray(parsed.openTabs) ||
- !parsed.tabsMap ||
- typeof parsed.tabsMap !== 'object'
- ) {
- return DEFAULT_TABS_STATE
- }
- return parsed
- } catch (error) {
- return DEFAULT_TABS_STATE
- }
- }
- 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)
- 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
- },
- 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
- }
- 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
- }
- }
- },
- // 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) => {
- const idx = store.openTabs.indexOf(id)
- store.openTabs = store.openTabs.filter((tabId) => tabId !== id)
- delete store.tabsMap[id]
- // 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
- if (!tab.isPreview) 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 '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
- }
- },
- handleTabClose: ({
- id,
- router,
- editor,
- onClose,
- onClearDashboardHistory,
- }: {
- id: string
- router: NextRouter
- editor?: 'sql' | 'table'
- 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 '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)
- },
- handleTabCloseAll: ({
- editor,
- router,
- onClearDashboardHistory,
- }: {
- editor: 'sql' | 'table'
- router: NextRouter
- onClearDashboardHistory: () => void
- }) => {
- const tabsToClose =
- editor === 'table'
- ? store.openTabs.filter((x) => !x.startsWith('sql'))
- : store.openTabs.filter((x) => x.startsWith('sql'))
- store.removeTabs(tabsToClose)
- onClearDashboardHistory()
- router.push(`/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}`)
- },
- 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, () => {
- localStorage.setItem(
- getTabsStorageKey(projectRef),
- JSON.stringify({
- activeTab: state.activeTab,
- openTabs: state.openTabs,
- tabsMap: state.tabsMap,
- previewTabId: state.previewTabId,
- })
- )
- localStorage.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}`
- default:
- return ''
- }
- }
|