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