tabs.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. import { useParams } from 'common'
  2. import { partition } from 'lodash'
  3. import { type NextRouter } from 'next/router'
  4. import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'
  5. import { proxy, subscribe, useSnapshot } from 'valtio'
  6. import { buildTableEditorUrl } from '@/components/grid/BrivenGrid.utils'
  7. import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
  8. export const editorEntityTypes = {
  9. table: ['r', 'v', 'm', 'f', 'p'],
  10. sql: ['sql'],
  11. }
  12. export type TabType = ENTITY_TYPE | 'sql'
  13. type CreateTabIdParams = {
  14. r: { id: number }
  15. v: { id: number }
  16. m: { id: number }
  17. f: { id: number }
  18. p: { id: number }
  19. sql: { id: string }
  20. schema: { schema: string }
  21. view: never
  22. function: never
  23. new: never
  24. }
  25. export interface Tab {
  26. id: string
  27. type: TabType
  28. label?: string
  29. metadata?: {
  30. schema?: string
  31. name?: string
  32. tableId?: number
  33. sqlId?: string
  34. scrollTop?: number
  35. }
  36. isPreview?: boolean
  37. createdAt?: Date
  38. updatedAt?: Date
  39. }
  40. const MAX_RECENT_ITEMS = 8
  41. export interface RecentItem {
  42. id: string
  43. type: TabType
  44. label: string
  45. timestamp: number
  46. metadata?: {
  47. schema?: string
  48. name?: string
  49. tableId?: number
  50. sqlId?: string
  51. }
  52. }
  53. const RECENT_ITEMS_STORAGE_KEY = 'briven_recent_items'
  54. const getRecentItemsStorageKey = (ref: string) => `${RECENT_ITEMS_STORAGE_KEY}_${ref}`
  55. function getSavedRecentItems(ref: string): RecentItem[] {
  56. if (typeof window === 'undefined' || !ref) return []
  57. const stored = localStorage.getItem(getRecentItemsStorageKey(ref))
  58. try {
  59. return JSON.parse(stored ?? '{"items": []}').items
  60. } catch (error) {
  61. return []
  62. }
  63. }
  64. const DEFAULT_TABS_STATE = {
  65. activeTab: null as string | null,
  66. openTabs: [] as string[],
  67. tabsMap: {} as Record<string, Tab>,
  68. previewTabId: undefined as string | undefined,
  69. recentItems: [],
  70. }
  71. const TABS_STORAGE_KEY = 'briven_studio_tabs'
  72. const getTabsStorageKey = (ref: string) => `${TABS_STORAGE_KEY}_${ref}`
  73. function getSavedTabs(ref: string) {
  74. if (typeof window === 'undefined' || !ref) return DEFAULT_TABS_STATE
  75. const stored = localStorage.getItem(getTabsStorageKey(ref))
  76. if (!stored) return DEFAULT_TABS_STATE
  77. try {
  78. const parsed = JSON.parse(
  79. stored ?? JSON.stringify(DEFAULT_TABS_STATE)
  80. ) as typeof DEFAULT_TABS_STATE
  81. if (
  82. !parsed.openTabs ||
  83. !Array.isArray(parsed.openTabs) ||
  84. !parsed.tabsMap ||
  85. typeof parsed.tabsMap !== 'object'
  86. ) {
  87. return DEFAULT_TABS_STATE
  88. }
  89. return parsed
  90. } catch (error) {
  91. return DEFAULT_TABS_STATE
  92. }
  93. }
  94. const getRecentItemLabel = (tab: Pick<Tab, 'label' | 'metadata'>) =>
  95. tab.label || tab.metadata?.name || 'Untitled'
  96. const syncRecentItemWithTab = (item: RecentItem, tab: Pick<Tab, 'label' | 'metadata'>) => {
  97. const nextLabel = getRecentItemLabel(tab)
  98. item.label = nextLabel
  99. item.metadata = {
  100. ...item.metadata,
  101. ...tab.metadata,
  102. name: nextLabel,
  103. }
  104. }
  105. export function createTabsState(projectRef: string) {
  106. const recentItems = getSavedRecentItems(projectRef)
  107. const { openTabs, activeTab, tabsMap, previewTabId } = getSavedTabs(projectRef)
  108. const store = proxy({
  109. // RECENT ITEMS
  110. recentItems,
  111. addRecentItem: (tab: Tab) => {
  112. // Check if an item with the same ID already exists
  113. const existingItem = store.recentItems.find((item) => item.id === tab.id)
  114. if (existingItem) {
  115. // If it exists, update its timestamp
  116. existingItem.timestamp = Date.now()
  117. syncRecentItemWithTab(existingItem, tab)
  118. return // Exit the function
  119. }
  120. // If it doesn't exist, create and add a new item
  121. const recentItem: RecentItem = {
  122. id: tab.id, // Set the ID
  123. type: tab.type, // Set the type
  124. label: getRecentItemLabel(tab), // Set the label or default to 'Untitled'
  125. timestamp: Date.now(), // Set the current timestamp
  126. metadata: tab.metadata, // Set the metadata
  127. }
  128. // Add the new recent item to the beginning of the list
  129. store.recentItems.unshift(recentItem)
  130. // Ensure that there's only up to max of MAX_RECENT_ITEMS items per tab type
  131. const [itemsOfSameType, itemsOfDifferentType] = partition(store.recentItems, (item) => {
  132. if (editorEntityTypes.table.includes(item.type)) return item
  133. })
  134. store.recentItems = [...itemsOfSameType.slice(0, MAX_RECENT_ITEMS), ...itemsOfDifferentType]
  135. },
  136. clearRecentItems: () => {
  137. store.recentItems = []
  138. },
  139. removeRecentItem: (itemId: string) => {
  140. store.recentItems = store.recentItems.filter((item) => item.id !== itemId)
  141. },
  142. removeRecentItems: (itemIds: string[]) => {
  143. store.recentItems = store.recentItems.filter((item) => !itemIds.includes(item.id))
  144. },
  145. removeRecentItemsByType: (type: TabType) => {
  146. store.recentItems = store.recentItems.filter((item) => item.type !== type)
  147. },
  148. getRecentItemsByType: (type: TabType) => {
  149. return store.recentItems.filter((item) => item.type === type)
  150. },
  151. // TABS
  152. activeTab,
  153. openTabs,
  154. tabsMap,
  155. previewTabId,
  156. hasTab: (id: string) => {
  157. return !!store.tabsMap[id]
  158. },
  159. addTab: (tab: Tab) => {
  160. // If tab exists and is active, don't do anything
  161. if (store.tabsMap[tab.id] && store.activeTab === tab.id) {
  162. return
  163. }
  164. // If tab exists but isn't active, just make it active
  165. if (store.tabsMap[tab.id]) {
  166. store.activeTab = tab.id
  167. if (!tab.isPreview) store.addRecentItem(tab)
  168. return
  169. }
  170. // If this tab should be permanent, add it normally
  171. if (tab.isPreview === false) {
  172. store.openTabs = [...store.openTabs, tab.id]
  173. store.tabsMap[tab.id] = tab
  174. store.activeTab = tab.id
  175. // Add to recent items when creating permanent tab
  176. store.addRecentItem(tab)
  177. return
  178. }
  179. // Remove any existing preview tab
  180. if (store.previewTabId) {
  181. store.openTabs = store.openTabs.filter((id) => id !== store.previewTabId)
  182. delete store.tabsMap[store.previewTabId]
  183. }
  184. // Add new preview tab
  185. store.tabsMap[tab.id] = { ...tab, isPreview: true }
  186. store.openTabs = [...store.openTabs, tab.id]
  187. store.previewTabId = tab.id
  188. store.activeTab = tab.id
  189. },
  190. updateTab: (id: string, updates: { label?: string; scrollTop?: number }) => {
  191. if (!!store.tabsMap[id]) {
  192. if ('label' in updates) {
  193. store.tabsMap[id].label = updates.label
  194. // Keep the persisted name aligned with the visible label so browser titles
  195. // and tab state recover cleanly after entity renames.
  196. if (typeof updates.label === 'string' && store.tabsMap[id].metadata) {
  197. store.tabsMap[id].metadata.name = updates.label
  198. }
  199. const recentItem = store.recentItems.find((item) => item.id === id)
  200. if (recentItem) syncRecentItemWithTab(recentItem, store.tabsMap[id])
  201. }
  202. if ('scrollTop' in updates && store.tabsMap[id].metadata) {
  203. store.tabsMap[id].metadata.scrollTop = updates.scrollTop
  204. }
  205. }
  206. },
  207. // Function to remove a tab from the store
  208. // this is used for removing tabs from the localstorage state
  209. // for handling a manual tab removal with a close action, use handleTabClose()
  210. removeTab: (id: string) => {
  211. const idx = store.openTabs.indexOf(id)
  212. store.openTabs = store.openTabs.filter((tabId) => tabId !== id)
  213. delete store.tabsMap[id]
  214. // Update active tab if the removed tab was active
  215. if (id === store.activeTab) {
  216. store.activeTab = store.openTabs[idx - 1] || store.openTabs[idx + 1] || null
  217. }
  218. },
  219. // Function to remove multiple tabs from the store
  220. // this is used for removing tabs from the localstorage state
  221. // for handling a manual tab removal with a close action, use handleTabClose()
  222. removeTabs: (ids: string[]) => {
  223. if (!ids.length) return
  224. ids.forEach((id) => store.removeTab(id))
  225. },
  226. reorderTabs: (oldIndex: number, newIndex: number) => {
  227. const newOpenTabs = [...store.openTabs]
  228. const [removedTab] = newOpenTabs.splice(oldIndex, 1)
  229. newOpenTabs.splice(newIndex, 0, removedTab)
  230. store.openTabs = newOpenTabs
  231. },
  232. makeTabActive: (tabId: string) => {
  233. const tab = store.tabsMap[tabId]
  234. if (!tab) return
  235. store.activeTab = tab.id
  236. },
  237. makeTabPermanent: (tabId: string) => {
  238. const tab = store.tabsMap[tabId]
  239. if (tab?.isPreview) {
  240. tab.isPreview = false
  241. store.previewTabId = undefined
  242. // Add to recent items when preview tab becomes permanent
  243. store.addRecentItem(tab)
  244. }
  245. },
  246. makeActiveTabPermanent: () => {
  247. if (store.activeTab && store.tabsMap[store.activeTab]?.isPreview) {
  248. store.makeTabPermanent(store.activeTab)
  249. return true
  250. }
  251. return false
  252. },
  253. // TABS HANDLERS
  254. handleTabNavigation: (id: string, router: NextRouter) => {
  255. const tab = store.tabsMap[id]
  256. if (!tab) return
  257. store.activeTab = id
  258. // Add to recent items when navigating to a non-preview, non-new tab
  259. if (!tab.isPreview) store.addRecentItem(tab)
  260. switch (tab.type) {
  261. case 'sql':
  262. const schema = (router.query.schema as string) || 'public'
  263. router.push(`/project/${router.query.ref}/sql/${tab.metadata?.sqlId}?schema=${schema}`)
  264. break
  265. case 'r':
  266. case 'v':
  267. case 'm':
  268. case 'f':
  269. case 'p':
  270. router.push(
  271. buildTableEditorUrl({
  272. projectRef: router.query.ref as string,
  273. tableId: tab.metadata?.tableId!,
  274. schema: tab.metadata?.schema,
  275. })
  276. )
  277. break
  278. }
  279. },
  280. handleTabClose: ({
  281. id,
  282. router,
  283. editor,
  284. onClose,
  285. onClearDashboardHistory,
  286. }: {
  287. id: string
  288. router: NextRouter
  289. editor?: 'sql' | 'table'
  290. onClose?: (id: string) => void
  291. onClearDashboardHistory: () => void
  292. }) => {
  293. const tabBeingClosed = store.tabsMap[id]
  294. const editorTabIds = (
  295. editor
  296. ? Object.values(store.tabsMap).filter((tab) =>
  297. editorEntityTypes[editor]?.includes(tab.type)
  298. )
  299. : []
  300. ).map((tab) => tab.id)
  301. const tabIndexBeingClosed = editorTabIds.indexOf(id)
  302. const isLastTabBeingClosed = tabIndexBeingClosed === editorTabIds.length - 1
  303. const nextTabId =
  304. editorTabIds.length === 1
  305. ? undefined
  306. : isLastTabBeingClosed
  307. ? editorTabIds[tabIndexBeingClosed - 1]
  308. : editorTabIds[tabIndexBeingClosed + 1]
  309. const { [id]: value, ...otherTabs } = store.tabsMap
  310. store.tabsMap = otherTabs
  311. if (tabBeingClosed) {
  312. const updatedOpenTabs = [...store.openTabs].filter((x) => x !== id)
  313. store.openTabs = updatedOpenTabs
  314. }
  315. // Remove the preview tab if it matches the tab being closed
  316. if (store.previewTabId === id) {
  317. store.previewTabId = undefined
  318. }
  319. // [Joshen] Only navigate away if we're closing the tab that's currently in focus
  320. if (store.activeTab === id || id === 'new') {
  321. if (nextTabId) {
  322. store.activeTab = nextTabId
  323. store.handleTabNavigation(nextTabId, router)
  324. } else {
  325. onClearDashboardHistory()
  326. // If no tabs of same type, go to the home of the current section
  327. switch (tabBeingClosed?.type) {
  328. case 'sql':
  329. router.push(`/project/${router.query.ref}/sql`)
  330. break
  331. case 'r':
  332. case 'v':
  333. case 'm':
  334. case 'f':
  335. case 'p':
  336. router.push(`/project/${router.query.ref}/editor`)
  337. break
  338. default:
  339. router.push(`/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}`)
  340. }
  341. }
  342. }
  343. onClose?.(id)
  344. },
  345. handleTabCloseAll: ({
  346. editor,
  347. router,
  348. onClearDashboardHistory,
  349. }: {
  350. editor: 'sql' | 'table'
  351. router: NextRouter
  352. onClearDashboardHistory: () => void
  353. }) => {
  354. const tabsToClose =
  355. editor === 'table'
  356. ? store.openTabs.filter((x) => !x.startsWith('sql'))
  357. : store.openTabs.filter((x) => x.startsWith('sql'))
  358. store.removeTabs(tabsToClose)
  359. onClearDashboardHistory()
  360. router.push(`/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}`)
  361. },
  362. handleTabDragEnd: (oldIndex: number, newIndex: number, tabId: string, router: NextRouter) => {
  363. // Make permanent if needed
  364. const draggedTab = store.tabsMap[tabId]
  365. if (draggedTab?.isPreview) {
  366. store.makeTabPermanent(tabId)
  367. }
  368. // Reorder tabs
  369. const newOpenTabs = [...store.openTabs]
  370. newOpenTabs.splice(oldIndex, 1)
  371. newOpenTabs.splice(newIndex, 0, tabId)
  372. store.openTabs = newOpenTabs
  373. store.activeTab = tabId
  374. // Handle navigation
  375. store.handleTabNavigation(tabId, router)
  376. },
  377. })
  378. return store
  379. }
  380. export type TabsState = ReturnType<typeof createTabsState>
  381. export const TabsStateContext = createContext<TabsState>(createTabsState(''))
  382. export const TabsStateContextProvider = ({ children }: PropsWithChildren) => {
  383. const { ref: projectRef } = useParams()
  384. const [state, setState] = useState(createTabsState(projectRef ?? ''))
  385. useEffect(() => {
  386. if (typeof window !== 'undefined' && !!projectRef) {
  387. setState(createTabsState(projectRef ?? ''))
  388. }
  389. }, [projectRef])
  390. useEffect(() => {
  391. if (typeof window !== 'undefined' && projectRef) {
  392. return subscribe(state, () => {
  393. localStorage.setItem(
  394. getTabsStorageKey(projectRef),
  395. JSON.stringify({
  396. activeTab: state.activeTab,
  397. openTabs: state.openTabs,
  398. tabsMap: state.tabsMap,
  399. previewTabId: state.previewTabId,
  400. })
  401. )
  402. localStorage.setItem(
  403. getRecentItemsStorageKey(projectRef),
  404. JSON.stringify({
  405. items: state.recentItems,
  406. })
  407. )
  408. })
  409. }
  410. }, [projectRef, state])
  411. return <TabsStateContext.Provider value={state}>{children}</TabsStateContext.Provider>
  412. }
  413. export const useTabsStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) => {
  414. const state = useContext(TabsStateContext)
  415. return useSnapshot(state, options)
  416. }
  417. export function createTabId<T extends TabType>(type: T, params: CreateTabIdParams[T]): string {
  418. switch (type) {
  419. case 'r':
  420. return `r-${(params as CreateTabIdParams['r']).id}`
  421. case 'v':
  422. return `v-${(params as CreateTabIdParams['v']).id}`
  423. case 'm':
  424. return `m-${(params as CreateTabIdParams['m']).id}`
  425. case 'f':
  426. return `f-${(params as CreateTabIdParams['f']).id}`
  427. case 'p':
  428. return `p-${(params as CreateTabIdParams['p']).id}`
  429. case 'sql':
  430. return `sql-${(params as CreateTabIdParams['sql']).id}`
  431. default:
  432. return ''
  433. }
  434. }