tabs-context.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. import { findInTree } from '@/features/file-tree/util/find-in-tree'
  2. import { useFileTreeData } from '@/shared/context/file-tree-data-context'
  3. import { useProjectContext } from '@/shared/context/project-context'
  4. import usePersistedState from '@/shared/hooks/use-persisted-state'
  5. import React, {
  6. FC,
  7. useCallback,
  8. useContext,
  9. useEffect,
  10. useMemo,
  11. useState,
  12. } from 'react'
  13. import { useFileTreeOpenContext } from './file-tree-open-context'
  14. import { useEditorManagerContext } from './editor-manager-context'
  15. import { debugConsole } from '@/utils/debugging'
  16. import { disambiguatePaths } from '../util/disambiguate-paths'
  17. import { isSplitTestEnabled } from '@/utils/splitTestUtils'
  18. import { useUserSettingsContext } from '@/shared/context/user-settings-context'
  19. import {
  20. FileTreeFindResult,
  21. isFileRefResult,
  22. } from '@/features/ide-react/types/file-tree'
  23. type PersistedTabInfo = { id: string; lifetime: Lifetime }
  24. type Lifetime = 'permanent' | 'temporary'
  25. export type EditorFileTab = {
  26. id: string
  27. name: string
  28. displayPath: string
  29. isLinkedFile: boolean
  30. lifetime: Lifetime
  31. }
  32. export const TAB_TRANSFER_TYPE = 'text/x.tab-id'
  33. export type TabsContextMenuTarget = {
  34. top: number
  35. left: number
  36. tabId: string
  37. }
  38. const TabsContext = React.createContext<
  39. | {
  40. tabs: EditorFileTab[]
  41. openTab: (id: string) => void
  42. closeTab: (id: string) => void
  43. closeOtherTabs: (id: string) => void
  44. makeTabPermanent: (id: string) => void
  45. closeToRight: (id: string) => void
  46. moveTab: (
  47. sourceTabId: string,
  48. targetTabId: string,
  49. position: 'left' | 'right'
  50. ) => void
  51. contextMenuTarget: TabsContextMenuTarget | null
  52. setContextMenuTarget: React.Dispatch<
  53. React.SetStateAction<TabsContextMenuTarget | null>
  54. >
  55. }
  56. | undefined
  57. >(undefined)
  58. export const TabsProvider: FC<React.PropsWithChildren> = ({ children }) => {
  59. const { projectId } = useProjectContext()
  60. const { fileTreeData } = useFileTreeData()
  61. const { openEntity } = useFileTreeOpenContext()
  62. const { openDocWithId, openFileWithId } = useEditorManagerContext()
  63. const tabsEnabled = isSplitTestEnabled('editor-tabs')
  64. const { userSettings } = useUserSettingsContext()
  65. const { previewTabs } = userSettings
  66. const [openTabs, setOpenTabs] = usePersistedState<PersistedTabInfo[]>(
  67. `open-tabs:${projectId}`,
  68. []
  69. )
  70. const [contextMenuTarget, setContextMenuTarget] =
  71. useState<TabsContextMenuTarget | null>(null)
  72. const tabs = useMemo(() => {
  73. if (!tabsEnabled) {
  74. return []
  75. }
  76. if (!fileTreeData) {
  77. return []
  78. }
  79. const tabsFileTreeLookup = openTabs
  80. .map(tab => ({
  81. lifetime: tab.lifetime,
  82. result: findInTree(fileTreeData, tab.id),
  83. }))
  84. .filter(x => !!x.result) as {
  85. lifetime: Lifetime
  86. result: FileTreeFindResult
  87. }[]
  88. const pathLookup = disambiguatePaths(
  89. tabsFileTreeLookup.map(tab => tab.result),
  90. fileTreeData
  91. )
  92. return tabsFileTreeLookup.map(tab => {
  93. const entity = tab.result.entity
  94. return {
  95. id: entity._id,
  96. name: entity.name,
  97. displayPath: pathLookup.get(entity._id) || entity.name,
  98. isLinkedFile:
  99. isFileRefResult(tab.result) &&
  100. !!tab.result.entity.linkedFileData?.provider,
  101. lifetime: tab.lifetime,
  102. }
  103. })
  104. }, [fileTreeData, openTabs, tabsEnabled])
  105. const openTab = useCallback(
  106. async (id: string) => {
  107. if (!fileTreeData) {
  108. return
  109. }
  110. const file = findInTree(fileTreeData, id)
  111. if (!file) {
  112. return
  113. }
  114. if (file.type === 'doc') {
  115. await openDocWithId(file.entity._id)
  116. } else if (file.type === 'fileRef') {
  117. openFileWithId(file.entity._id)
  118. } else {
  119. debugConsole.error('Attempting to open invalid entity type')
  120. }
  121. },
  122. [fileTreeData, openDocWithId, openFileWithId]
  123. )
  124. const closeTab = useCallback(
  125. async (id: string) => {
  126. if (openTabs.length <= 1) {
  127. // Can't close last file
  128. return
  129. }
  130. if (id === openEntity?.entity._id) {
  131. const currentIndex = openTabs.findIndex(tab => tab.id === id)
  132. if (currentIndex === -1) {
  133. debugConsole.warn('Attempting to close tab that is not open')
  134. return
  135. }
  136. const nextTab = openTabs[currentIndex + 1] || openTabs[currentIndex - 1]
  137. if (!nextTab) {
  138. debugConsole.warn('No next tab to switch to on close')
  139. return
  140. }
  141. await openTab(nextTab.id)
  142. }
  143. setOpenTabs(current => current.filter(tab => tab.id !== id))
  144. },
  145. [openTabs, openEntity, setOpenTabs, openTab]
  146. )
  147. const closeOtherTabs = useCallback(
  148. async (id: string) => {
  149. if (id !== openEntity?.entity._id) {
  150. await openTab(id)
  151. }
  152. setOpenTabs(current => current.filter(tab => tab.id === id))
  153. },
  154. [openEntity, openTab, setOpenTabs]
  155. )
  156. const closeToRight = useCallback(
  157. async (id: string) => {
  158. const tabIndex = openTabs.findIndex(tab => tab.id === id)
  159. if (tabIndex === -1) {
  160. debugConsole.warn(
  161. 'Attempting to close to right on tab that is not open'
  162. )
  163. return
  164. }
  165. const openTabIndex = openTabs.findIndex(
  166. tab => tab.id === openEntity?.entity._id
  167. )
  168. if (openTabIndex === -1 || openTabIndex > tabIndex) {
  169. await openTab(id)
  170. }
  171. setOpenTabs(current => {
  172. const currentIndex = current.findIndex(tab => tab.id === id)
  173. if (currentIndex === -1) {
  174. return current
  175. }
  176. return current.slice(0, currentIndex + 1)
  177. })
  178. },
  179. [setOpenTabs, openTabs, openEntity, openTab]
  180. )
  181. const moveTab = useCallback(
  182. (sourceTabId: string, targetTabId: string, position: 'left' | 'right') => {
  183. debugConsole.log({ sourceTabId, targetTabId, position })
  184. if (sourceTabId === targetTabId) {
  185. debugConsole.debug(
  186. 'Source and target tab ids are the same for moving tab'
  187. )
  188. return
  189. }
  190. setOpenTabs(current => {
  191. const sourceTabIndex = current.findIndex(tab => tab.id === sourceTabId)
  192. const targetTabIndex = current.findIndex(tab => tab.id === targetTabId)
  193. if (sourceTabIndex === -1 || targetTabIndex === -1) {
  194. debugConsole.warn('Invalid tab ids for moving tab')
  195. return current
  196. }
  197. if (
  198. (position === 'right' && targetTabIndex === sourceTabIndex - 1) ||
  199. (position === 'left' && targetTabIndex === sourceTabIndex + 1)
  200. ) {
  201. debugConsole.debug(
  202. 'Source and target tab are already adjacent for move'
  203. )
  204. return current
  205. }
  206. return arrayMove(current, sourceTabIndex, targetTabIndex, position)
  207. })
  208. },
  209. [setOpenTabs]
  210. )
  211. const makeTabPermanent = useCallback(
  212. (id: string) => {
  213. setOpenTabs(current =>
  214. current.map(tab =>
  215. tab.id === id ? { ...tab, lifetime: 'permanent' } : tab
  216. )
  217. )
  218. },
  219. [setOpenTabs]
  220. )
  221. useEffect(() => {
  222. if (!tabsEnabled) {
  223. return
  224. }
  225. if (!openEntity) {
  226. return
  227. }
  228. setOpenTabs(current => {
  229. if (current.find(t => t.id === openEntity?.entity._id)) {
  230. return current
  231. }
  232. return [
  233. ...current.filter(tab => tab.lifetime !== 'temporary'),
  234. {
  235. id: openEntity.entity._id,
  236. lifetime: previewTabs ? 'temporary' : 'permanent',
  237. },
  238. ]
  239. })
  240. }, [openEntity, previewTabs, setOpenTabs, tabsEnabled])
  241. useEffect(() => {
  242. if (!tabsEnabled) {
  243. return
  244. }
  245. // Make sure file tree is ready for lookup before pruning tabs, to avoid
  246. // accidentally closing tabs that are still valid but not yet available
  247. if (!fileTreeData?._id) {
  248. return
  249. }
  250. setOpenTabs(current => {
  251. const pruned = current.filter(tab => findInTree(fileTreeData, tab.id))
  252. if (pruned.length === current.length) {
  253. return current
  254. }
  255. return pruned
  256. })
  257. }, [fileTreeData, setOpenTabs, tabsEnabled])
  258. const value = useMemo(
  259. () => ({
  260. tabs,
  261. openTab,
  262. closeTab,
  263. closeOtherTabs,
  264. moveTab,
  265. makeTabPermanent,
  266. contextMenuTarget,
  267. setContextMenuTarget,
  268. closeToRight,
  269. }),
  270. [
  271. tabs,
  272. openTab,
  273. closeTab,
  274. closeOtherTabs,
  275. moveTab,
  276. makeTabPermanent,
  277. contextMenuTarget,
  278. setContextMenuTarget,
  279. closeToRight,
  280. ]
  281. )
  282. return <TabsContext.Provider value={value}>{children}</TabsContext.Provider>
  283. }
  284. export const useTabsContext = () => {
  285. const value = useContext(TabsContext)
  286. if (!value) {
  287. throw new Error('useTabsContext can only be used inside TabsProvider')
  288. }
  289. return value
  290. }
  291. function arrayMove<T>(
  292. array: T[],
  293. sourceIndex: number,
  294. targetIndex: number,
  295. side: 'left' | 'right'
  296. ): T[] {
  297. const result = [...array]
  298. const [movedItem] = result.splice(sourceIndex, 1)
  299. let newIndex = targetIndex
  300. if (sourceIndex < targetIndex) {
  301. newIndex -= 1
  302. }
  303. if (side === 'right') {
  304. newIndex += 1
  305. }
  306. result.splice(newIndex, 0, movedItem)
  307. return result
  308. }