tabs-context.tsx 6.4 KB

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