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