tabs.tsx498 lines · main
| 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 | |
| 7 | import { buildTableEditorUrl } from '@/components/grid/BrivenGrid.utils' |
| 8 | import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' |
| 9 | |
| 10 | export const editorEntityTypes = { |
| 11 | table: ['r', 'v', 'm', 'f', 'p'], |
| 12 | sql: ['sql'], |
| 13 | } |
| 14 | |
| 15 | export type TabType = ENTITY_TYPE | 'sql' |
| 16 | |
| 17 | type 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 | |
| 30 | export 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 | |
| 46 | const MAX_RECENT_ITEMS = 8 |
| 47 | |
| 48 | export 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 | |
| 61 | const RECENT_ITEMS_STORAGE_KEY = 'briven_recent_items' |
| 62 | const getRecentItemsStorageKey = (ref: string) => `${RECENT_ITEMS_STORAGE_KEY}_${ref}` |
| 63 | |
| 64 | function 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 | |
| 76 | const 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 | } |
| 83 | const TABS_STORAGE_KEY = 'briven_studio_tabs' |
| 84 | const getTabsStorageKey = (ref: string) => `${TABS_STORAGE_KEY}_${ref}` |
| 85 | |
| 86 | function 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 | |
| 113 | const getRecentItemLabel = (tab: Pick<Tab, 'label' | 'metadata'>) => |
| 114 | tab.label || tab.metadata?.name || 'Untitled' |
| 115 | |
| 116 | const 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 | |
| 127 | export 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 | |
| 437 | export type TabsState = ReturnType<typeof createTabsState> |
| 438 | |
| 439 | export const TabsStateContext = createContext<TabsState>(createTabsState('')) |
| 440 | |
| 441 | export 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 | |
| 476 | export const useTabsStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) => { |
| 477 | const state = useContext(TabsStateContext) |
| 478 | return useSnapshot(state, options) |
| 479 | } |
| 480 | |
| 481 | export 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 | } |