Tabs.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import {
  2. DndContext,
  3. DragEndEvent,
  4. DragOverlay,
  5. PointerSensor,
  6. useSensor,
  7. useSensors,
  8. } from '@dnd-kit/core'
  9. import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'
  10. import { useParams } from 'common'
  11. import { AnimatePresence, motion } from 'framer-motion'
  12. import { Plus, X } from 'lucide-react'
  13. import { useRouter } from 'next/router'
  14. import {
  15. cn,
  16. ContextMenu,
  17. ContextMenuContent,
  18. ContextMenuItem,
  19. ContextMenuTrigger,
  20. Tabs_Shadcn_,
  21. TabsList_Shadcn_,
  22. TabsTrigger_Shadcn_,
  23. } from 'ui'
  24. import { useEditorType } from '../editors/EditorsLayout.hooks'
  25. import { CollapseButton } from './CollapseButton'
  26. import { SortableTab } from './SortableTab'
  27. import { TabPreview } from './TabPreview'
  28. import { useTabsScroll } from './Tabs.utils'
  29. import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory'
  30. import { editorEntityTypes, useTabsStateSnapshot, type Tab } from '@/state/tabs'
  31. export const EditorTabs = () => {
  32. const { ref, id } = useParams()
  33. const router = useRouter()
  34. const { setLastVisitedSnippet, setLastVisitedTable } = useDashboardHistory()
  35. const editor = useEditorType()
  36. const tabs = useTabsStateSnapshot()
  37. const sensors = useSensors(
  38. useSensor(PointerSensor, {
  39. activationConstraint: {
  40. distance: 1, // Start with a very small distance
  41. },
  42. })
  43. )
  44. const openTabs = tabs.openTabs
  45. .map((id) => tabs.tabsMap[id])
  46. .filter((tab) => tab !== undefined) as Tab[]
  47. const hasNewTab = router.asPath.includes('/new')
  48. // Filter by editor type - only show SQL tabs for SQL editor and table tabs for table editor
  49. const editorTabs = !!editor
  50. ? openTabs.filter((tab) => editorEntityTypes[editor]?.includes(tab.type))
  51. : []
  52. const handleDragEnd = (event: DragEndEvent) => {
  53. const { active, over } = event
  54. if (!over || active.id === over.id) return
  55. const oldIndex = tabs.openTabs.indexOf(active.id.toString())
  56. const newIndex = tabs.openTabs.indexOf(over.id.toString())
  57. if (oldIndex !== newIndex) {
  58. tabs.handleTabDragEnd(oldIndex, newIndex, active.id.toString(), router)
  59. }
  60. }
  61. const onClearDashboardHistory = () => {
  62. if (editor === 'table') {
  63. setLastVisitedTable(undefined)
  64. } else if (editor === 'sql') {
  65. setLastVisitedSnippet(undefined)
  66. }
  67. }
  68. const handleClose = (tabId: string) => {
  69. tabs.handleTabClose({ id: tabId, router, editor, onClearDashboardHistory })
  70. }
  71. const handleCloseAll = () => {
  72. if (editor) {
  73. const tabsToClose =
  74. editor === 'table'
  75. ? tabs.openTabs.filter((x) => !x.startsWith('sql'))
  76. : tabs.openTabs.filter((x) => x.startsWith('sql'))
  77. tabs.removeTabs(tabsToClose)
  78. onClearDashboardHistory()
  79. router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}`)
  80. }
  81. }
  82. const handleCloseOthers = (tabId: string) => {
  83. if (editor) {
  84. const tabsToClose =
  85. editor === 'table'
  86. ? tabs.openTabs.filter((x) => !x.startsWith('sql') && x !== tabId)
  87. : tabs.openTabs.filter((x) => x.startsWith('sql') && x !== tabId)
  88. tabs.removeTabs(tabsToClose)
  89. onClearDashboardHistory()
  90. const entityId = editor === 'table' ? tabId.split('-')[1] : tabId.split('sql-')[1]
  91. if (id !== entityId) {
  92. router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}/${entityId}`)
  93. }
  94. }
  95. }
  96. const handleCloseRight = (tabId: string) => {
  97. if (editor) {
  98. const openedTabs =
  99. editor === 'table'
  100. ? tabs.openTabs.filter((x) => !x.startsWith('sql'))
  101. : tabs.openTabs.filter((x) => x.startsWith('sql'))
  102. const tabIdx = openedTabs.indexOf(tabId)
  103. const activeTabIdx = openedTabs.indexOf(tabs.activeTab!)
  104. const tabsToClose = openedTabs.slice(tabIdx + 1)
  105. tabs.removeTabs(tabsToClose)
  106. const isActiveTabClosed = tabIdx < activeTabIdx
  107. if (isActiveTabClosed) {
  108. const id = editor === 'table' ? tabId.split('-')[1] : tabId.split('sql-')[1]
  109. router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}/${id}`)
  110. }
  111. }
  112. }
  113. const handleTabChange = (id: string) => {
  114. tabs.handleTabNavigation(id, router)
  115. }
  116. const { tabsListRef } = useTabsScroll({ activeTab: tabs.activeTab, tabCount: editorTabs.length })
  117. return (
  118. <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
  119. <Tabs_Shadcn_
  120. className="w-full flex"
  121. value={hasNewTab ? 'new' : (tabs.activeTab ?? undefined)}
  122. onValueChange={handleTabChange}
  123. >
  124. <CollapseButton hideTabs={false} />
  125. <TabsList_Shadcn_
  126. ref={tabsListRef}
  127. className={cn(
  128. 'rounded-b-none gap-0 min-h-(--header-height) flex items-center w-full z-1',
  129. 'bg-surface-200 dark:bg-alternative border-none text-clip overflow-x-auto'
  130. )}
  131. >
  132. <SortableContext
  133. items={editorTabs.map((tab) => tab.id)}
  134. strategy={horizontalListSortingStrategy}
  135. >
  136. {editorTabs.map((tab, index) => (
  137. <ContextMenu key={tab.id}>
  138. <ContextMenuTrigger>
  139. <SortableTab
  140. key={tab.id}
  141. tab={tab}
  142. index={index}
  143. openTabs={openTabs}
  144. onClose={() => handleClose(tab.id)}
  145. />
  146. </ContextMenuTrigger>
  147. <ContextMenuContent>
  148. <ContextMenuItem onClick={() => handleClose(tab.id)}>Close</ContextMenuItem>
  149. <ContextMenuItem onClick={() => handleCloseOthers(tab.id)}>
  150. Close Others
  151. </ContextMenuItem>
  152. <ContextMenuItem onClick={() => handleCloseRight(tab.id)}>
  153. Close to the Right
  154. </ContextMenuItem>
  155. <ContextMenuItem onClick={handleCloseAll}>Close All</ContextMenuItem>
  156. </ContextMenuContent>
  157. </ContextMenu>
  158. ))}
  159. </SortableContext>
  160. {/* Non-draggable new tab */}
  161. {hasNewTab && (
  162. <TabsTrigger_Shadcn_
  163. value="new"
  164. className={cn(
  165. 'flex items-center gap-2 px-3 text-xs',
  166. 'bg-dash-sidebar/50 dark:bg-surface-100/50',
  167. 'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100',
  168. 'relative group h-full border-t-2 border-b-0!',
  169. 'hover:bg-surface-300 dark:hover:bg-surface-100'
  170. )}
  171. >
  172. <Plus size={16} strokeWidth={1.5} className={'text-foreground-lighter'} />
  173. <div className="flex items-center gap-0">
  174. <span>New</span>
  175. </div>
  176. <span
  177. role="button"
  178. onClick={(e) => {
  179. e.preventDefault()
  180. e.stopPropagation()
  181. }}
  182. className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-xs cursor-pointer"
  183. onMouseDown={(e) => {
  184. e.preventDefault()
  185. e.stopPropagation()
  186. }}
  187. onPointerDown={(e) => {
  188. e.preventDefault()
  189. e.stopPropagation()
  190. handleClose('new')
  191. }}
  192. >
  193. <X size={12} className="text-foreground-light" />
  194. </span>{' '}
  195. <div className="absolute w-full -bottom-px left-0 right-0 h-px bg-dash-sidebar dark:bg-surface-100 opacity-0 group-data-[state=active]:opacity-100" />
  196. </TabsTrigger_Shadcn_>
  197. )}
  198. <AnimatePresence initial={false}>
  199. {!hasNewTab && (
  200. <motion.button
  201. className="flex items-center justify-center w-10 min-h-(--header-height) hover:bg-surface-100 shrink-0 border-b"
  202. onClick={() =>
  203. router.push(
  204. `/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}/new?skip=true`
  205. )
  206. }
  207. initial={{ opacity: 0, scale: 0.8, x: -10 }}
  208. animate={{ opacity: 1, scale: 1, x: 0 }}
  209. transition={{ duration: 0.2 }}
  210. >
  211. <Plus
  212. size={16}
  213. strokeWidth={1.5}
  214. className="text-foreground-lighter hover:text-foreground-light"
  215. />
  216. </motion.button>
  217. )}
  218. </AnimatePresence>
  219. <div className="grow h-full border-b pr-6" />
  220. </TabsList_Shadcn_>
  221. </Tabs_Shadcn_>
  222. <DragOverlay dropAnimation={null}>
  223. {tabs.activeTab ? <TabPreview tab={tabs.activeTab} /> : null}
  224. </DragOverlay>
  225. </DndContext>
  226. )
  227. }