rail.tsx 10 KB

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