rail.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. import { FC, forwardRef, ReactElement, useCallback, useMemo } from 'react'
  2. import { useTranslation } from 'react-i18next'
  3. import { Nav, NavLink, Tab, TabContainer } from 'react-bootstrap'
  4. import MaterialIcon, {
  5. AvailableUnfilledIcon,
  6. } from '@/shared/components/material-icon'
  7. import { Panel } from 'react-resizable-panels'
  8. import { useLayoutContext } from '@/shared/context/layout-context'
  9. import ErrorIndicator from './error-logs/error-indicator'
  10. import {
  11. RailModalKey,
  12. RailTabKey,
  13. useRailContext,
  14. } from '../contexts/rail-context'
  15. import FileTreeOutlinePanel from './file-tree/file-tree-outline-panel'
  16. import { ChatIndicator, ChatPane } from './chat/chat'
  17. import getMeta from '@/utils/meta'
  18. import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
  19. import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
  20. import classNames from 'classnames'
  21. import IntegrationsPanel from './integrations-panel/integrations-panel'
  22. import {
  23. Dropdown,
  24. DropdownDivider,
  25. DropdownItem,
  26. DropdownMenu,
  27. DropdownToggle,
  28. } from '@/features/ui/components/bootstrap-5/dropdown-menu'
  29. import { RailHelpShowHotkeysModal } from './help/keyboard-shortcuts'
  30. import { RailHelpContactUsModal } from './help/contact-us'
  31. import { HistorySidebar } from '@/features/ide-react/components/history-sidebar'
  32. import DictionarySettingsModal from './settings/editor-settings/dictionary-settings-modal'
  33. import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
  34. import { useChatContext } from '@/features/chat/context/chat-context'
  35. import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
  36. import {
  37. FullProjectSearchPanel,
  38. hasFullProjectSearch,
  39. } from './full-project-search-panel'
  40. import { sendSearchEvent } from '@/features/event-tracking/search-events'
  41. import ErrorLogsPanel from './error-logs/error-logs-panel'
  42. import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
  43. import OldErrorPane from './error-logs/old-error-pane'
  44. import { useFeatureFlag } from '@/shared/context/split-test-context'
  45. import { useSurveyUrl } from '../hooks/use-survey-url'
  46. import { useProjectContext } from '@/shared/context/project-context'
  47. import usePreviousValue from '@/shared/hooks/use-previous-value'
  48. import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
  49. type RailElement = {
  50. icon: AvailableUnfilledIcon
  51. key: RailTabKey
  52. component: ReactElement | null
  53. indicator?: ReactElement
  54. title: string
  55. hide?: boolean
  56. disabled?: boolean
  57. mountOnFirstLoad?: boolean
  58. }
  59. type RailActionButton = {
  60. key: string
  61. icon: AvailableUnfilledIcon
  62. title: string
  63. action: () => void
  64. }
  65. type RailDropdown = {
  66. key: string
  67. icon: AvailableUnfilledIcon
  68. title: string
  69. dropdown: ReactElement
  70. }
  71. type RailAction = RailDropdown | RailActionButton
  72. const RAIL_MODALS: {
  73. key: RailModalKey
  74. modalComponentFunction: FC<{ show: boolean }>
  75. }[] = [
  76. {
  77. key: 'keyboard-shortcuts',
  78. modalComponentFunction: RailHelpShowHotkeysModal,
  79. },
  80. {
  81. key: 'contact-us',
  82. modalComponentFunction: RailHelpContactUsModal,
  83. },
  84. {
  85. key: 'dictionary',
  86. modalComponentFunction: DictionarySettingsModal,
  87. },
  88. ]
  89. export const RailLayout = () => {
  90. const { sendEvent } = useEditorAnalytics()
  91. const { t } = useTranslation()
  92. const {
  93. activeModal,
  94. selectedTab,
  95. openTab,
  96. isOpen,
  97. setIsOpen,
  98. panelRef,
  99. handlePaneCollapse,
  100. handlePaneExpand,
  101. togglePane,
  102. setResizing,
  103. } = useRailContext()
  104. const { logEntries } = useCompileContext()
  105. const { features } = useProjectContext()
  106. const errorLogsDisabled = !logEntries
  107. const { view, setLeftMenuShown } = useLayoutContext()
  108. const { markMessagesAsRead } = useChatContext()
  109. const isHistoryView = view === 'history'
  110. const newErrorlogs = useFeatureFlag('new-editor-error-logs-redesign')
  111. const railTabs: RailElement[] = useMemo(
  112. () => [
  113. {
  114. key: 'file-tree',
  115. icon: 'description',
  116. title: t('file_tree'),
  117. component: <FileTreeOutlinePanel />,
  118. // NOTE: We always need to mount the file tree on first load
  119. // since it is responsible for opening the initial document.
  120. mountOnFirstLoad: true,
  121. },
  122. {
  123. key: 'full-project-search',
  124. icon: 'search',
  125. title: t('project_search'),
  126. component: <FullProjectSearchPanel />,
  127. hide: !hasFullProjectSearch,
  128. },
  129. {
  130. key: 'integrations',
  131. icon: 'integration_instructions',
  132. title: t('integrations'),
  133. component: <IntegrationsPanel />,
  134. },
  135. {
  136. key: 'review-panel',
  137. icon: 'rate_review',
  138. title: t('review_panel'),
  139. component: null,
  140. hide: !features.trackChangesVisible,
  141. disabled: view !== 'editor',
  142. },
  143. {
  144. key: 'chat',
  145. icon: 'forum',
  146. component: <ChatPane />,
  147. indicator: <ChatIndicator />,
  148. title: t('chat'),
  149. hide: !getMeta('ol-capabilities')?.includes('chat'),
  150. },
  151. {
  152. key: 'errors',
  153. icon: 'report',
  154. title: t('error_log'),
  155. component: newErrorlogs ? <ErrorLogsPanel /> : <OldErrorPane />,
  156. indicator: <ErrorIndicator />,
  157. disabled: errorLogsDisabled,
  158. },
  159. ],
  160. [t, features.trackChangesVisible, newErrorlogs, errorLogsDisabled, view]
  161. )
  162. const railActions: RailAction[] = useMemo(
  163. () => [
  164. {
  165. key: 'support',
  166. icon: 'help',
  167. title: t('help'),
  168. dropdown: <RailHelpDropdown />,
  169. },
  170. {
  171. key: 'settings',
  172. icon: 'settings',
  173. title: t('settings'),
  174. action: () => {
  175. sendEvent('rail-click', { tab: 'settings' })
  176. setLeftMenuShown(true)
  177. },
  178. },
  179. ],
  180. [setLeftMenuShown, t, sendEvent]
  181. )
  182. useCommandProvider(
  183. () => [
  184. {
  185. id: 'open-settings',
  186. handler: () => {
  187. setLeftMenuShown(true)
  188. },
  189. label: t('settings'),
  190. },
  191. ],
  192. [t, setLeftMenuShown]
  193. )
  194. const onTabSelect = useCallback(
  195. (key: string | null) => {
  196. if (key === selectedTab) {
  197. togglePane()
  198. sendEvent('rail-click', { tab: key, type: 'toggle' })
  199. } else {
  200. // HACK: Apparently the onSelect event is triggered with href attributes
  201. // from DropdownItems
  202. if (!railTabs.some(tab => !tab.hide && tab.key === key)) {
  203. // Attempting to open a non-existent tab
  204. return
  205. }
  206. const keyOrDefault = (key ?? 'file-tree') as RailTabKey
  207. // Change the selected tab and make sure it's open
  208. openTab(keyOrDefault)
  209. sendEvent('rail-click', { tab: keyOrDefault })
  210. if (keyOrDefault === 'full-project-search') {
  211. sendSearchEvent('search-open', {
  212. searchType: 'full-project',
  213. method: 'button',
  214. location: 'rail',
  215. })
  216. }
  217. if (key === 'chat') {
  218. markMessagesAsRead()
  219. }
  220. }
  221. },
  222. [openTab, togglePane, selectedTab, railTabs, sendEvent, markMessagesAsRead]
  223. )
  224. const isReviewPanelOpen = selectedTab === 'review-panel'
  225. const prevTab = usePreviousValue(selectedTab)
  226. const tabHasChanged = useMemo(() => {
  227. return prevTab !== selectedTab
  228. }, [prevTab, selectedTab])
  229. const onCollapse = useCallback(() => {
  230. if (!tabHasChanged) {
  231. handlePaneCollapse()
  232. }
  233. }, [tabHasChanged, handlePaneCollapse])
  234. return (
  235. <TabContainer
  236. mountOnEnter // Only render when necessary (so that we can lazy load tab content)
  237. unmountOnExit={false} // TODO: Should we unmount the tabs when they're not used?
  238. transition={false}
  239. activeKey={selectedTab}
  240. onSelect={onTabSelect}
  241. id="ide-rail-tabs"
  242. >
  243. {/* The <Nav> element is a "div" and has a "role="tablist"".
  244. But it should be identified as a navigation landmark.
  245. Therefore, we nest them: the parent <nav> is the landmark, and its child gets the "role="tablist"". */}
  246. <nav
  247. className={classNames('ide-rail', { hidden: isHistoryView })}
  248. aria-label={t('files_collaboration_integrations_logs')}
  249. >
  250. <Nav activeKey={selectedTab} className="ide-rail-tabs-nav">
  251. {railTabs
  252. .filter(({ hide }) => !hide)
  253. .map(({ icon, key, indicator, title, disabled }) => (
  254. <RailTab
  255. open={isOpen && selectedTab === key}
  256. key={key}
  257. eventKey={key}
  258. icon={icon}
  259. indicator={indicator}
  260. title={title}
  261. disabled={disabled}
  262. />
  263. ))}
  264. <div className="flex-grow-1" />
  265. <nav aria-label={t('help_editor_settings')}>
  266. {railActions?.map(action => (
  267. <RailActionElement key={action.key} action={action} />
  268. ))}
  269. </nav>
  270. </Nav>
  271. </nav>
  272. <Panel
  273. id={
  274. newErrorlogs
  275. ? `ide-redesign-sidebar-panel-${selectedTab}`
  276. : 'ide-redesign-sidebar-panel'
  277. }
  278. className={classNames({ hidden: isReviewPanelOpen })}
  279. order={1}
  280. defaultSize={15}
  281. minSize={5}
  282. maxSize={80}
  283. ref={panelRef}
  284. collapsible
  285. onCollapse={onCollapse}
  286. onExpand={handlePaneExpand}
  287. >
  288. {isHistoryView && <HistorySidebar />}
  289. <div
  290. className={classNames('ide-rail-content', {
  291. hidden: isHistoryView,
  292. })}
  293. >
  294. <Tab.Content className="ide-rail-tab-content">
  295. {railTabs
  296. .filter(({ hide }) => !hide)
  297. .map(({ key, component, mountOnFirstLoad }) => (
  298. <Tab.Pane
  299. eventKey={key}
  300. key={key}
  301. mountOnEnter={!mountOnFirstLoad}
  302. >
  303. {component}
  304. </Tab.Pane>
  305. ))}
  306. </Tab.Content>
  307. </div>
  308. </Panel>
  309. <HorizontalResizeHandle
  310. className={classNames({ hidden: isReviewPanelOpen })}
  311. resizable
  312. hitAreaMargins={{ coarse: 0, fine: 0 }}
  313. onDoubleClick={togglePane}
  314. onDragging={setResizing}
  315. >
  316. <HorizontalToggler
  317. id="ide-redesign-sidebar-panel"
  318. togglerType="west"
  319. isOpen={isOpen}
  320. setIsOpen={setIsOpen}
  321. tooltipWhenOpen={t('tooltip_hide_panel')}
  322. tooltipWhenClosed={t('tooltip_show_panel')}
  323. />
  324. </HorizontalResizeHandle>
  325. {RAIL_MODALS.map(({ key, modalComponentFunction: Component }) => (
  326. <Component key={key} show={activeModal === key} />
  327. ))}
  328. </TabContainer>
  329. )
  330. }
  331. const RailTab = forwardRef<
  332. HTMLAnchorElement,
  333. {
  334. icon: AvailableUnfilledIcon
  335. eventKey: string
  336. open: boolean
  337. indicator?: ReactElement
  338. title: string
  339. disabled?: boolean
  340. }
  341. >(({ icon, eventKey, open, indicator, title, disabled = false }, ref) => {
  342. return (
  343. <OLTooltip
  344. id={`rail-tab-tooltip-${eventKey}`}
  345. description={title}
  346. overlayProps={{ delay: 0, placement: 'right' }}
  347. >
  348. <NavLink
  349. ref={ref}
  350. eventKey={eventKey}
  351. className={classNames('ide-rail-tab-link', {
  352. 'open-rail': open,
  353. })}
  354. disabled={disabled}
  355. >
  356. {open ? (
  357. <MaterialIcon
  358. className="ide-rail-tab-link-icon"
  359. type={icon}
  360. accessibilityLabel={title}
  361. />
  362. ) : (
  363. <MaterialIcon
  364. className="ide-rail-tab-link-icon"
  365. type={icon}
  366. accessibilityLabel={title}
  367. unfilled
  368. />
  369. )}
  370. {indicator}
  371. </NavLink>
  372. </OLTooltip>
  373. )
  374. })
  375. RailTab.displayName = 'RailTab'
  376. const RailActionElement = ({ action }: { action: RailAction }) => {
  377. const onActionClick = useCallback(() => {
  378. if ('action' in action) {
  379. action.action()
  380. }
  381. }, [action])
  382. if ('dropdown' in action) {
  383. return (
  384. <Dropdown align="end" drop="end">
  385. <OLTooltip
  386. id={`rail-dropdown-tooltip-${action.key}`}
  387. description={action.title}
  388. overlayProps={{ delay: 0, placement: 'right' }}
  389. >
  390. <span>
  391. <DropdownToggle
  392. id="rail-help-dropdown-btn"
  393. className="ide-rail-tab-link ide-rail-tab-button ide-rail-tab-dropdown"
  394. as="button"
  395. aria-label={action.title}
  396. >
  397. <MaterialIcon
  398. className="ide-rail-tab-link-icon"
  399. type={action.icon}
  400. unfilled
  401. />
  402. </DropdownToggle>
  403. </span>
  404. </OLTooltip>
  405. {action.dropdown}
  406. </Dropdown>
  407. )
  408. } else {
  409. return (
  410. <OLTooltip
  411. id={`rail-tab-tooltip-${action.key}`}
  412. description={action.title}
  413. overlayProps={{ delay: 0, placement: 'right' }}
  414. >
  415. <button
  416. onClick={onActionClick}
  417. className="ide-rail-tab-link ide-rail-tab-button"
  418. aria-label={action.title}
  419. >
  420. <MaterialIcon
  421. className="ide-rail-tab-link-icon"
  422. type={action.icon}
  423. unfilled
  424. />
  425. </button>
  426. </OLTooltip>
  427. )
  428. }
  429. }
  430. const RailHelpDropdown = () => {
  431. const showSupport = getMeta('ol-showSupport')
  432. const { t } = useTranslation()
  433. const { setActiveModal } = useRailContext()
  434. const openKeyboardShortcutsModal = useCallback(() => {
  435. setActiveModal('keyboard-shortcuts')
  436. }, [setActiveModal])
  437. const openContactUsModal = useCallback(() => {
  438. setActiveModal('contact-us')
  439. }, [setActiveModal])
  440. const surveyURL = useSurveyUrl()
  441. return (
  442. <DropdownMenu>
  443. <DropdownItem onClick={openKeyboardShortcutsModal}>
  444. {t('keyboard_shortcuts')}
  445. </DropdownItem>
  446. <DropdownItem
  447. href="/learn"
  448. role="menuitem"
  449. target="_blank"
  450. rel="noopener noreferrer"
  451. >
  452. {t('documentation')}
  453. </DropdownItem>
  454. <DropdownDivider />
  455. {showSupport && (
  456. <DropdownItem onClick={openContactUsModal}>
  457. {t('contact_us')}
  458. </DropdownItem>
  459. )}
  460. <DropdownItem
  461. href={surveyURL}
  462. role="menuitem"
  463. target="_blank"
  464. rel="noopener noreferrer"
  465. >
  466. {t('give_feedback')}
  467. </DropdownItem>
  468. </DropdownMenu>
  469. )
  470. }