tabs-context.tsx 6.2 KB

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