rail.tsx 13 KB

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