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