rail.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. import { FC, RefObject, useCallback, useEffect, useMemo, useRef } from 'react'
  2. import { useTranslation } from 'react-i18next'
  3. import { Nav, TabContainer } from 'react-bootstrap'
  4. import { useLayoutContext } from '@/shared/context/layout-context'
  5. import {
  6. RailTabKey,
  7. useRailContext,
  8. } from '@/features/ide-react/context/rail-context'
  9. import FileTreeOutlinePanel from '@/features/ide-redesign/components/file-tree/file-tree-outline-panel'
  10. import {
  11. ChatIndicator,
  12. ChatPane,
  13. } from '@/features/ide-redesign/components/chat/chat'
  14. import getMeta from '@/utils/meta'
  15. import classNames from 'classnames'
  16. import IntegrationsPanel from '@/features/ide-redesign/components/integrations-panel/integrations-panel'
  17. import { useChatContext } from '@/features/chat/context/chat-context'
  18. import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
  19. import {
  20. FullProjectSearchPanel,
  21. hasFullProjectSearch,
  22. } from '@/features/ide-redesign/components/full-project-search-panel'
  23. import { sendSearchEvent } from '@/features/event-tracking/search-events'
  24. import { useProjectContext } from '@/shared/context/project-context'
  25. import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
  26. import RailHelpDropdown from './rail-help-dropdown'
  27. import RailTab from './rail-tab'
  28. import RailActionElement, { RailAction } from './rail-action-element'
  29. import { RailElement } from '@/features/ide-react/util/rail-types'
  30. import RailPanel from './rail-panel'
  31. import RailResizeHandle from './rail-resize-handle'
  32. import RailModals from './rail-modals'
  33. import RailOverflowDropdown from './rail-overflow-dropdown'
  34. import useRailOverflow from '@/features/ide-redesign/hooks/use-rail-overflow'
  35. import EditorTourRailTooltip from '@/features/ide-redesign/components/editor-tour/editor-tour-rail-tooltip'
  36. import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
  37. import EditorTourThemeTooltip from '@/features/ide-redesign/components/editor-tour/editor-tour-theme-tooltip'
  38. import EditorTourGotQuestionsTooltip from '@/features/ide-redesign/components/editor-tour/editor-tour-got-questions'
  39. import { shouldIncludeElement } from '@/features/ide-react/util/rail-utils'
  40. import { useEditorContext } from '@/shared/context/editor-context'
  41. import useEventListener from '@/shared/hooks/use-event-listener'
  42. const moduleRailEntries = (
  43. importOverleafModules('railEntries') as {
  44. import: { default: RailElement }
  45. path: string
  46. }[]
  47. ).map(({ import: { default: element } }) => element)
  48. const moduleRailPopovers = (
  49. importOverleafModules('railPopovers') as {
  50. import: {
  51. default: {
  52. key: string
  53. Component: FC<{ ref: RefObject<HTMLButtonElement> }>
  54. ref: RefObject<HTMLButtonElement>
  55. hide: boolean | (() => boolean)
  56. }
  57. }
  58. path: string
  59. }[]
  60. ).map(({ import: { default: element } }) => element)
  61. export const RailLayout = () => {
  62. const { sendEvent } = useEditorAnalytics()
  63. const { t } = useTranslation()
  64. const { selectedTab, openTab, isOpen, setIsOpen, togglePane, selectTab } =
  65. useRailContext()
  66. const { features } = useProjectContext()
  67. const { isRestrictedTokenMember } = useEditorContext()
  68. const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
  69. const { isOverleaf } = getMeta('ol-ExposedSettings')
  70. const { view, setLeftMenuShown } = useLayoutContext()
  71. const { markMessagesAsRead } = useChatContext()
  72. const isHistoryView = view === 'history'
  73. const fileTreeRef = useRef<HTMLButtonElement>(null)
  74. const settingsRef = useRef<HTMLButtonElement>(null)
  75. useEventListener(
  76. 'ui:select-rail-tab',
  77. useCallback(
  78. (event: Event) => {
  79. const {
  80. detail: { tab, open },
  81. } = event as CustomEvent<{
  82. tab: RailTabKey
  83. open: boolean
  84. }>
  85. selectTab(tab)
  86. setIsOpen(open)
  87. },
  88. [selectTab, setIsOpen]
  89. )
  90. )
  91. const railTabs: RailElement[] = useMemo(
  92. () => [
  93. {
  94. key: 'file-tree',
  95. icon: 'description',
  96. title: t('file_tree'),
  97. component: <FileTreeOutlinePanel />,
  98. // NOTE: We always need to mount the file tree on first load
  99. // since it is responsible for opening the initial document.
  100. mountOnFirstLoad: true,
  101. ref: fileTreeRef,
  102. },
  103. {
  104. key: 'full-project-search',
  105. icon: 'search',
  106. title: t('project_search'),
  107. component: <FullProjectSearchPanel />,
  108. hide: !hasFullProjectSearch,
  109. },
  110. {
  111. key: 'integrations',
  112. icon: 'integration_instructions',
  113. title: t('integrations'),
  114. component: <IntegrationsPanel />,
  115. hide: !isOverleaf && !gitBridgeEnabled,
  116. },
  117. {
  118. key: 'review-panel',
  119. icon: 'rate_review',
  120. title: t('review_panel'),
  121. component: null,
  122. hide: !features.trackChangesVisible,
  123. disabled: view !== 'editor',
  124. },
  125. {
  126. key: 'chat',
  127. icon: 'forum',
  128. component: <ChatPane />,
  129. indicator: <ChatIndicator />,
  130. title: t('chat'),
  131. hide:
  132. !getMeta('ol-capabilities')?.includes('chat') ||
  133. isRestrictedTokenMember,
  134. },
  135. ...moduleRailEntries,
  136. ],
  137. [
  138. t,
  139. features.trackChangesVisible,
  140. view,
  141. isRestrictedTokenMember,
  142. isOverleaf,
  143. gitBridgeEnabled,
  144. ]
  145. )
  146. const railActions: RailAction[] = useMemo(
  147. () => [
  148. {
  149. key: 'support',
  150. icon: 'help',
  151. title: t('help'),
  152. dropdown: <RailHelpDropdown />,
  153. },
  154. {
  155. key: 'settings',
  156. icon: 'settings',
  157. title: t('settings'),
  158. action: () => {
  159. sendEvent('rail-click', { tab: 'settings' })
  160. setLeftMenuShown(true)
  161. },
  162. ref: settingsRef,
  163. },
  164. ],
  165. [setLeftMenuShown, t, sendEvent]
  166. )
  167. useCommandProvider(
  168. () => [
  169. {
  170. id: 'open-settings',
  171. handler: () => {
  172. setLeftMenuShown(true)
  173. },
  174. label: t('settings'),
  175. },
  176. ],
  177. [t, setLeftMenuShown]
  178. )
  179. const onTabSelect = useCallback(
  180. (key: string | null) => {
  181. if (key === selectedTab) {
  182. togglePane()
  183. sendEvent('rail-click', { tab: key, type: 'toggle' })
  184. } else {
  185. // HACK: Apparently the onSelect event is triggered with href attributes
  186. // from DropdownItems
  187. if (
  188. !railTabs.some(tab =>
  189. typeof tab.hide === 'function'
  190. ? !tab.hide()
  191. : !tab.hide && tab.key === key
  192. )
  193. ) {
  194. // Attempting to open a non-existent tab
  195. return
  196. }
  197. const keyOrDefault = (key ?? 'file-tree') as RailTabKey
  198. // Change the selected tab and make sure it's open
  199. openTab(keyOrDefault)
  200. sendEvent('rail-click', { tab: keyOrDefault })
  201. if (keyOrDefault === 'full-project-search') {
  202. sendSearchEvent('search-open', {
  203. searchType: 'full-project',
  204. method: 'button',
  205. location: 'rail',
  206. })
  207. }
  208. if (key === 'chat') {
  209. markMessagesAsRead()
  210. }
  211. }
  212. },
  213. [openTab, togglePane, selectedTab, railTabs, sendEvent, markMessagesAsRead]
  214. )
  215. useEffect(() => {
  216. const validTabKeys = railTabs
  217. .filter(shouldIncludeElement)
  218. .map(tab => tab.key)
  219. if (!validTabKeys.includes(selectedTab) && isOpen) {
  220. // If the selected tab is no longer valid (e.g. due to permissions changes),
  221. // switch back to the file tree
  222. openTab('file-tree')
  223. }
  224. }, [railTabs, selectedTab, openTab, isOpen])
  225. const isReviewPanelOpen =
  226. selectedTab === 'review-panel' && isOpen && !isHistoryView
  227. const { tabsInRail, tabsInOverflow, tabWrapperRef } =
  228. useRailOverflow(railTabs)
  229. const moreOptionsAction: RailAction = useMemo(() => {
  230. return {
  231. key: 'more-options',
  232. icon: 'more_vert',
  233. title: t('more_options'),
  234. hide: tabsInOverflow.length === 0,
  235. dropdown: (
  236. <RailOverflowDropdown
  237. tabs={tabsInOverflow}
  238. isOpen={isOpen}
  239. selectedTab={selectedTab}
  240. />
  241. ),
  242. }
  243. }, [t, isOpen, selectedTab, tabsInOverflow])
  244. return (
  245. <TabContainer
  246. mountOnEnter // Only render when necessary (so that we can lazy load tab content)
  247. unmountOnExit={false} // TODO: Should we unmount the tabs when they're not used?
  248. transition={false}
  249. activeKey={selectedTab}
  250. onSelect={onTabSelect}
  251. id="ide-rail-tabs"
  252. >
  253. {/* The <Nav> element is a "div" and has a "role="tablist"".
  254. But it should be identified as a navigation landmark.
  255. Therefore, we nest them: the parent <nav> is the landmark, and its child gets the "role="tablist"". */}
  256. <nav
  257. className={classNames('ide-rail', { hidden: isHistoryView })}
  258. aria-label={t('sidebar')}
  259. >
  260. <Nav activeKey={selectedTab} className="ide-rail-tabs-nav">
  261. <div className="ide-rail-tabs-wrapper" ref={tabWrapperRef}>
  262. {tabsInRail
  263. .filter(shouldIncludeElement)
  264. .map(({ icon, key, indicator, title, disabled, ref, tab }) => {
  265. const Component = tab ?? RailTab
  266. return (
  267. <Component
  268. open={isOpen && selectedTab === key}
  269. key={key}
  270. eventKey={key}
  271. icon={icon}
  272. indicator={indicator}
  273. title={title}
  274. disabled={disabled}
  275. ref={ref}
  276. />
  277. )
  278. })}
  279. <RailActionElement key="more-options" action={moreOptionsAction} />
  280. </div>
  281. <nav aria-label={t('help_editor_settings')}>
  282. {railActions.map(action => (
  283. <RailActionElement
  284. key={action.key}
  285. action={action}
  286. ref={action.ref}
  287. />
  288. ))}
  289. </nav>
  290. </Nav>
  291. </nav>
  292. <EditorTourRailTooltip target={fileTreeRef.current} />
  293. <EditorTourThemeTooltip target={settingsRef.current} />
  294. <EditorTourGotQuestionsTooltip target={settingsRef.current} />
  295. {moduleRailPopovers
  296. .filter(shouldIncludeElement)
  297. .map(({ key, Component, ref }) => (
  298. <Component key={key} ref={ref} />
  299. ))}
  300. <RailPanel
  301. isReviewPanelOpen={isReviewPanelOpen}
  302. isHistoryView={isHistoryView}
  303. railTabs={railTabs}
  304. />
  305. <RailResizeHandle isReviewPanelOpen={isReviewPanelOpen} />
  306. <RailModals />
  307. </TabContainer>
  308. )
  309. }