Tabs.tsx251 lines · main
| 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 | |
| 25 | import { useEditorType } from '../editors/EditorsLayout.hooks' |
| 26 | import { CollapseButton } from './CollapseButton' |
| 27 | import { SortableTab } from './SortableTab' |
| 28 | import { TabPreview } from './TabPreview' |
| 29 | import { useTabsScroll } from './Tabs.utils' |
| 30 | import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory' |
| 31 | import { editorEntityTypes, useTabsStateSnapshot, type Tab } from '@/state/tabs' |
| 32 | |
| 33 | export 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 | } |