tabs-context.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  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. moveTab: (
  46. sourceTabId: string,
  47. targetTabId: string,
  48. position: 'left' | 'right'
  49. ) => void
  50. contextMenuTarget: TabsContextMenuTarget | null
  51. setContextMenuTarget: React.Dispatch<
  52. React.SetStateAction<TabsContextMenuTarget | null>
  53. >
  54. }
  55. | undefined
  56. >(undefined)
  57. export const TabsProvider: FC<React.PropsWithChildren> = ({ children }) => {
  58. const { projectId } = useProjectContext()
  59. const { fileTreeData } = useFileTreeData()
  60. const { openEntity } = useFileTreeOpenContext()
  61. const { openDocWithId, openFileWithId } = useEditorManagerContext()
  62. const tabsEnabled = isSplitTestEnabled('editor-tabs')
  63. const { userSettings } = useUserSettingsContext()
  64. const { previewTabs } = userSettings
  65. const [openTabs, setOpenTabs] = usePersistedState<PersistedTabInfo[]>(
  66. `open-tabs:${projectId}`,
  67. []
  68. )
  69. const [contextMenuTarget, setContextMenuTarget] =
  70. useState<TabsContextMenuTarget | null>(null)
  71. const tabs = useMemo(() => {
  72. if (!tabsEnabled) {
  73. return []
  74. }
  75. if (!fileTreeData) {
  76. return []
  77. }
  78. const tabsFileTreeLookup = openTabs
  79. .map(tab => ({
  80. lifetime: tab.lifetime,
  81. result: findInTree(fileTreeData, tab.id),
  82. }))
  83. .filter(x => !!x.result) as {
  84. lifetime: Lifetime
  85. result: FileTreeFindResult
  86. }[]
  87. const pathLookup = disambiguatePaths(
  88. tabsFileTreeLookup.map(tab => tab.result),
  89. fileTreeData
  90. )
  91. return tabsFileTreeLookup.map(tab => {
  92. const entity = tab.result.entity
  93. return {
  94. id: entity._id,
  95. name: entity.name,
  96. displayPath: pathLookup.get(entity._id) || entity.name,
  97. isLinkedFile:
  98. isFileRefResult(tab.result) &&
  99. !!tab.result.entity.linkedFileData?.provider,
  100. lifetime: tab.lifetime,
  101. }
  102. })
  103. }, [fileTreeData, openTabs, tabsEnabled])
  104. const openTab = useCallback(
  105. async (id: string) => {
  106. if (!fileTreeData) {
  107. return
  108. }
  109. const file = findInTree(fileTreeData, id)
  110. if (!file) {
  111. return
  112. }
  113. if (file.type === 'doc') {
  114. await openDocWithId(file.entity._id)
  115. } else if (file.type === 'fileRef') {
  116. openFileWithId(file.entity._id)
  117. } else {
  118. debugConsole.error('Attempting to open invalid entity type')
  119. }
  120. },
  121. [fileTreeData, openDocWithId, openFileWithId]
  122. )
  123. const closeTab = useCallback(
  124. async (id: string) => {
  125. if (openTabs.length <= 1) {
  126. // Can't close last file
  127. return
  128. }
  129. if (id === openEntity?.entity._id) {
  130. const currentIndex = openTabs.findIndex(tab => tab.id === id)
  131. if (currentIndex === -1) {
  132. debugConsole.warn('Attempting to close tab that is not open')
  133. return
  134. }
  135. const nextTab = openTabs[currentIndex + 1] || openTabs[currentIndex - 1]
  136. if (!nextTab) {
  137. debugConsole.warn('No next tab to switch to on close')
  138. return
  139. }
  140. await openTab(nextTab.id)
  141. }
  142. setOpenTabs(current => current.filter(tab => tab.id !== id))
  143. },
  144. [openTabs, openEntity, setOpenTabs, openTab]
  145. )
  146. const closeOtherTabs = useCallback(
  147. async (id: string) => {
  148. if (id !== openEntity?.entity._id) {
  149. await openTab(id)
  150. }
  151. setOpenTabs(current => current.filter(tab => tab.id === id))
  152. },
  153. [openEntity, openTab, setOpenTabs]
  154. )
  155. const moveTab = useCallback(
  156. (sourceTabId: string, targetTabId: string, position: 'left' | 'right') => {
  157. debugConsole.log({ sourceTabId, targetTabId, position })
  158. if (sourceTabId === targetTabId) {
  159. debugConsole.debug(
  160. 'Source and target tab ids are the same for moving tab'
  161. )
  162. return
  163. }
  164. setOpenTabs(current => {
  165. const sourceTabIndex = current.findIndex(tab => tab.id === sourceTabId)
  166. const targetTabIndex = current.findIndex(tab => tab.id === targetTabId)
  167. if (sourceTabIndex === -1 || targetTabIndex === -1) {
  168. debugConsole.warn('Invalid tab ids for moving tab')
  169. return current
  170. }
  171. if (
  172. (position === 'right' && targetTabIndex === sourceTabIndex - 1) ||
  173. (position === 'left' && targetTabIndex === sourceTabIndex + 1)
  174. ) {
  175. debugConsole.debug(
  176. 'Source and target tab are already adjacent for move'
  177. )
  178. return current
  179. }
  180. return arrayMove(current, sourceTabIndex, targetTabIndex, position)
  181. })
  182. },
  183. [setOpenTabs]
  184. )
  185. const makeTabPermanent = useCallback(
  186. (id: string) => {
  187. setOpenTabs(current =>
  188. current.map(tab =>
  189. tab.id === id ? { ...tab, lifetime: 'permanent' } : tab
  190. )
  191. )
  192. },
  193. [setOpenTabs]
  194. )
  195. useEffect(() => {
  196. if (!tabsEnabled) {
  197. return
  198. }
  199. if (!openEntity) {
  200. return
  201. }
  202. setOpenTabs(current => {
  203. if (current.find(t => t.id === openEntity?.entity._id)) {
  204. return current
  205. }
  206. return [
  207. ...current.filter(tab => tab.lifetime !== 'temporary'),
  208. {
  209. id: openEntity.entity._id,
  210. lifetime: previewTabs ? 'temporary' : 'permanent',
  211. },
  212. ]
  213. })
  214. }, [openEntity, previewTabs, setOpenTabs, tabsEnabled])
  215. const value = useMemo(
  216. () => ({
  217. tabs,
  218. openTab,
  219. closeTab,
  220. closeOtherTabs,
  221. moveTab,
  222. makeTabPermanent,
  223. contextMenuTarget,
  224. setContextMenuTarget,
  225. }),
  226. [
  227. tabs,
  228. openTab,
  229. closeTab,
  230. closeOtherTabs,
  231. moveTab,
  232. makeTabPermanent,
  233. contextMenuTarget,
  234. setContextMenuTarget,
  235. ]
  236. )
  237. return <TabsContext.Provider value={value}>{children}</TabsContext.Provider>
  238. }
  239. export const useTabsContext = () => {
  240. const value = useContext(TabsContext)
  241. if (!value) {
  242. throw new Error('useTabsContext can only be used inside TabsProvider')
  243. }
  244. return value
  245. }
  246. function arrayMove<T>(
  247. array: T[],
  248. sourceIndex: number,
  249. targetIndex: number,
  250. side: 'left' | 'right'
  251. ): T[] {
  252. const result = [...array]
  253. const [movedItem] = result.splice(sourceIndex, 1)
  254. let newIndex = targetIndex
  255. if (sourceIndex < targetIndex) {
  256. newIndex -= 1
  257. }
  258. if (side === 'right') {
  259. newIndex += 1
  260. }
  261. result.splice(newIndex, 0, movedItem)
  262. return result
  263. }