tabs-context.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  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, { FC, useCallback, useContext, useEffect, useMemo } from 'react'
  6. import { useFileTreeOpenContext } from './file-tree-open-context'
  7. import { useEditorManagerContext } from './editor-manager-context'
  8. import { debugConsole } from '@/utils/debugging'
  9. import { isInExperiment } from '@/utils/labs-utils'
  10. import { disambiguatePaths } from '../util/disambiguate-paths'
  11. import { isSplitTestEnabled } from '@/utils/splitTestUtils'
  12. type PersistedTabInfo = { id: string; lifetime: Lifetime }
  13. type Lifetime = 'permanent' | 'temporary'
  14. export type EditorFileTab = {
  15. id: string
  16. displayPath: string
  17. lifetime: Lifetime
  18. }
  19. export const TAB_TRANSFER_TYPE = 'text/x.tab-id'
  20. const TabsContext = React.createContext<
  21. | {
  22. tabs: EditorFileTab[]
  23. openTab: (id: string) => void
  24. closeTab: (id: string) => void
  25. makeTabPermanent: (id: string) => void
  26. moveTab: (
  27. sourceTabId: string,
  28. targetTabId: string,
  29. position: 'left' | 'right'
  30. ) => void
  31. }
  32. | undefined
  33. >(undefined)
  34. export const TabsProvider: FC<React.PropsWithChildren> = ({ children }) => {
  35. const { projectId } = useProjectContext()
  36. const { fileTreeData } = useFileTreeData()
  37. const { openEntity } = useFileTreeOpenContext()
  38. const { openDocWithId, openFileWithId } = useEditorManagerContext()
  39. const tabsEnabled =
  40. isInExperiment('editor-tabs') && isSplitTestEnabled('editor-tabs')
  41. const [openTabs, setOpenTabs] = usePersistedState<PersistedTabInfo[]>(
  42. `open-tabs:${projectId}`,
  43. []
  44. )
  45. const tabs = useMemo(() => {
  46. if (!tabsEnabled) {
  47. return []
  48. }
  49. if (!fileTreeData) {
  50. return []
  51. }
  52. const tabsFileTreeLookup = openTabs
  53. .map(tab => ({
  54. lifetime: tab.lifetime,
  55. result: findInTree(fileTreeData, tab.id),
  56. }))
  57. .filter(x => !!x.result)
  58. const pathLookup = disambiguatePaths(
  59. tabsFileTreeLookup.map(tab => tab.result!),
  60. fileTreeData
  61. )
  62. return tabsFileTreeLookup.map(tab => ({
  63. id: tab.result!.entity._id,
  64. displayPath:
  65. pathLookup.get(tab.result!.entity._id) || tab.result!.entity.name,
  66. lifetime: tab.lifetime,
  67. }))
  68. }, [fileTreeData, openTabs, tabsEnabled])
  69. const openTab = useCallback(
  70. async (id: string) => {
  71. if (!fileTreeData) {
  72. return
  73. }
  74. const file = findInTree(fileTreeData, id)
  75. if (!file) {
  76. return
  77. }
  78. if (file.type === 'doc') {
  79. await openDocWithId(file.entity._id)
  80. } else if (file.type === 'fileRef') {
  81. openFileWithId(file.entity._id)
  82. } else {
  83. debugConsole.error('Attempting to open invalid entity type')
  84. }
  85. },
  86. [fileTreeData, openDocWithId, openFileWithId]
  87. )
  88. const closeTab = useCallback(
  89. async (id: string) => {
  90. if (openTabs.length <= 1) {
  91. // Can't close last file
  92. return
  93. }
  94. if (id === openEntity?.entity._id) {
  95. const currentIndex = openTabs.findIndex(tab => tab.id === id)
  96. if (currentIndex === -1) {
  97. debugConsole.warn('Attempting to close tab that is not open')
  98. return
  99. }
  100. const nextTab = openTabs[currentIndex + 1] || openTabs[currentIndex - 1]
  101. if (!nextTab) {
  102. debugConsole.warn('No next tab to switch to on close')
  103. return
  104. }
  105. await openTab(nextTab.id)
  106. }
  107. setOpenTabs(current => current.filter(tab => tab.id !== id))
  108. },
  109. [openTabs, openEntity, setOpenTabs, openTab]
  110. )
  111. const moveTab = useCallback(
  112. (sourceTabId: string, targetTabId: string, position: 'left' | 'right') => {
  113. debugConsole.log({ sourceTabId, targetTabId, position })
  114. if (sourceTabId === targetTabId) {
  115. debugConsole.debug(
  116. 'Source and target tab ids are the same for moving tab'
  117. )
  118. return
  119. }
  120. setOpenTabs(current => {
  121. const sourceTabIndex = current.findIndex(tab => tab.id === sourceTabId)
  122. const targetTabIndex = current.findIndex(tab => tab.id === targetTabId)
  123. if (sourceTabIndex === -1 || targetTabIndex === -1) {
  124. debugConsole.warn('Invalid tab ids for moving tab')
  125. return current
  126. }
  127. if (
  128. (position === 'right' && targetTabIndex === sourceTabIndex - 1) ||
  129. (position === 'left' && targetTabIndex === sourceTabIndex + 1)
  130. ) {
  131. debugConsole.debug(
  132. 'Source and target tab are already adjacent for move'
  133. )
  134. return current
  135. }
  136. return arrayMove(current, sourceTabIndex, targetTabIndex, position)
  137. })
  138. },
  139. [setOpenTabs]
  140. )
  141. const makeTabPermanent = useCallback(
  142. (id: string) => {
  143. setOpenTabs(current =>
  144. current.map(tab =>
  145. tab.id === id ? { ...tab, lifetime: 'permanent' } : tab
  146. )
  147. )
  148. },
  149. [setOpenTabs]
  150. )
  151. useEffect(() => {
  152. if (!tabsEnabled) {
  153. return
  154. }
  155. if (!openEntity) {
  156. return
  157. }
  158. setOpenTabs(current => {
  159. if (current.find(t => t.id === openEntity?.entity._id)) {
  160. return current
  161. }
  162. return [
  163. ...current.filter(tab => tab.lifetime !== 'temporary'),
  164. { id: openEntity.entity._id, lifetime: 'temporary' },
  165. ]
  166. })
  167. }, [openEntity, setOpenTabs, tabsEnabled])
  168. const value = useMemo(
  169. () => ({ tabs, openTab, closeTab, moveTab, makeTabPermanent }),
  170. [tabs, openTab, closeTab, moveTab, makeTabPermanent]
  171. )
  172. return <TabsContext.Provider value={value}>{children}</TabsContext.Provider>
  173. }
  174. export const useTabsContext = () => {
  175. const value = useContext(TabsContext)
  176. if (!value) {
  177. throw new Error('useTabsContext can only be used inside TabsProvider')
  178. }
  179. return value
  180. }
  181. function arrayMove<T>(
  182. array: T[],
  183. sourceIndex: number,
  184. targetIndex: number,
  185. side: 'left' | 'right'
  186. ): T[] {
  187. const result = [...array]
  188. const [movedItem] = result.splice(sourceIndex, 1)
  189. let newIndex = targetIndex
  190. if (sourceIndex < targetIndex) {
  191. newIndex -= 1
  192. }
  193. if (side === 'right') {
  194. newIndex += 1
  195. }
  196. result.splice(newIndex, 0, movedItem)
  197. return result
  198. }