| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494 |
- import { FC, forwardRef, ReactElement, useCallback, useMemo } from 'react'
- import { useTranslation } from 'react-i18next'
- import { Nav, NavLink, Tab, TabContainer } from 'react-bootstrap'
- import MaterialIcon, {
- AvailableUnfilledIcon,
- } from '@/shared/components/material-icon'
- import { Panel } from 'react-resizable-panels'
- import { useLayoutContext } from '@/shared/context/layout-context'
- import ErrorIndicator from './error-logs/error-indicator'
- import {
- RailModalKey,
- RailTabKey,
- useRailContext,
- } from '../contexts/rail-context'
- import FileTreeOutlinePanel from './file-tree/file-tree-outline-panel'
- import { ChatIndicator, ChatPane } from './chat/chat'
- import getMeta from '@/utils/meta'
- import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
- import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
- import classNames from 'classnames'
- import IntegrationsPanel from './integrations-panel/integrations-panel'
- import {
- Dropdown,
- DropdownDivider,
- DropdownItem,
- DropdownMenu,
- DropdownToggle,
- } from '@/features/ui/components/bootstrap-5/dropdown-menu'
- import { RailHelpShowHotkeysModal } from './help/keyboard-shortcuts'
- import { RailHelpContactUsModal } from './help/contact-us'
- import { HistorySidebar } from '@/features/ide-react/components/history-sidebar'
- import DictionarySettingsModal from './settings/editor-settings/dictionary-settings-modal'
- import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
- import { useChatContext } from '@/features/chat/context/chat-context'
- import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
- import {
- FullProjectSearchPanel,
- hasFullProjectSearch,
- } from './full-project-search-panel'
- import { sendSearchEvent } from '@/features/event-tracking/search-events'
- import ErrorLogsPanel from './error-logs/error-logs-panel'
- import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
- import OldErrorPane from './error-logs/old-error-pane'
- import { useFeatureFlag } from '@/shared/context/split-test-context'
- import { useSurveyUrl } from '../hooks/use-survey-url'
- import { useProjectContext } from '@/shared/context/project-context'
- import usePreviousValue from '@/shared/hooks/use-previous-value'
- import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
- type RailElement = {
- icon: AvailableUnfilledIcon
- key: RailTabKey
- component: ReactElement | null
- indicator?: ReactElement
- title: string
- hide?: boolean
- disabled?: boolean
- mountOnFirstLoad?: boolean
- }
- type RailActionButton = {
- key: string
- icon: AvailableUnfilledIcon
- title: string
- action: () => void
- }
- type RailDropdown = {
- key: string
- icon: AvailableUnfilledIcon
- title: string
- dropdown: ReactElement
- }
- type RailAction = RailDropdown | RailActionButton
- const RAIL_MODALS: {
- key: RailModalKey
- modalComponentFunction: FC<{ show: boolean }>
- }[] = [
- {
- key: 'keyboard-shortcuts',
- modalComponentFunction: RailHelpShowHotkeysModal,
- },
- {
- key: 'contact-us',
- modalComponentFunction: RailHelpContactUsModal,
- },
- {
- key: 'dictionary',
- modalComponentFunction: DictionarySettingsModal,
- },
- ]
- export const RailLayout = () => {
- const { sendEvent } = useEditorAnalytics()
- const { t } = useTranslation()
- const {
- activeModal,
- selectedTab,
- openTab,
- isOpen,
- setIsOpen,
- panelRef,
- handlePaneCollapse,
- handlePaneExpand,
- togglePane,
- setResizing,
- } = useRailContext()
- const { logEntries } = useCompileContext()
- const { features } = useProjectContext()
- const errorLogsDisabled = !logEntries
- const { view, setLeftMenuShown } = useLayoutContext()
- const { markMessagesAsRead } = useChatContext()
- const isHistoryView = view === 'history'
- const newErrorlogs = useFeatureFlag('new-editor-error-logs-redesign')
- const railTabs: RailElement[] = useMemo(
- () => [
- {
- key: 'file-tree',
- icon: 'description',
- title: t('file_tree'),
- component: <FileTreeOutlinePanel />,
- // NOTE: We always need to mount the file tree on first load
- // since it is responsible for opening the initial document.
- mountOnFirstLoad: true,
- },
- {
- key: 'full-project-search',
- icon: 'search',
- title: t('project_search'),
- component: <FullProjectSearchPanel />,
- hide: !hasFullProjectSearch,
- },
- {
- key: 'integrations',
- icon: 'integration_instructions',
- title: t('integrations'),
- component: <IntegrationsPanel />,
- },
- {
- key: 'review-panel',
- icon: 'rate_review',
- title: t('review_panel'),
- component: null,
- hide: !features.trackChangesVisible,
- disabled: view !== 'editor',
- },
- {
- key: 'chat',
- icon: 'forum',
- component: <ChatPane />,
- indicator: <ChatIndicator />,
- title: t('chat'),
- hide: !getMeta('ol-capabilities')?.includes('chat'),
- },
- {
- key: 'errors',
- icon: 'report',
- title: t('error_log'),
- component: newErrorlogs ? <ErrorLogsPanel /> : <OldErrorPane />,
- indicator: <ErrorIndicator />,
- disabled: errorLogsDisabled,
- },
- ],
- [t, features.trackChangesVisible, newErrorlogs, errorLogsDisabled, view]
- )
- const railActions: RailAction[] = useMemo(
- () => [
- {
- key: 'support',
- icon: 'help',
- title: t('help'),
- dropdown: <RailHelpDropdown />,
- },
- {
- key: 'settings',
- icon: 'settings',
- title: t('settings'),
- action: () => {
- sendEvent('rail-click', { tab: 'settings' })
- setLeftMenuShown(true)
- },
- },
- ],
- [setLeftMenuShown, t, sendEvent]
- )
- useCommandProvider(
- () => [
- {
- id: 'open-settings',
- handler: () => {
- setLeftMenuShown(true)
- },
- label: t('settings'),
- },
- ],
- [t, setLeftMenuShown]
- )
- const onTabSelect = useCallback(
- (key: string | null) => {
- if (key === selectedTab) {
- togglePane()
- sendEvent('rail-click', { tab: key, type: 'toggle' })
- } else {
- // HACK: Apparently the onSelect event is triggered with href attributes
- // from DropdownItems
- if (!railTabs.some(tab => !tab.hide && tab.key === key)) {
- // Attempting to open a non-existent tab
- return
- }
- const keyOrDefault = (key ?? 'file-tree') as RailTabKey
- // Change the selected tab and make sure it's open
- openTab(keyOrDefault)
- sendEvent('rail-click', { tab: keyOrDefault })
- if (keyOrDefault === 'full-project-search') {
- sendSearchEvent('search-open', {
- searchType: 'full-project',
- method: 'button',
- location: 'rail',
- })
- }
- if (key === 'chat') {
- markMessagesAsRead()
- }
- }
- },
- [openTab, togglePane, selectedTab, railTabs, sendEvent, markMessagesAsRead]
- )
- const isReviewPanelOpen = selectedTab === 'review-panel'
- const prevTab = usePreviousValue(selectedTab)
- const tabHasChanged = useMemo(() => {
- return prevTab !== selectedTab
- }, [prevTab, selectedTab])
- const onCollapse = useCallback(() => {
- if (!tabHasChanged) {
- handlePaneCollapse()
- }
- }, [tabHasChanged, handlePaneCollapse])
- return (
- <TabContainer
- mountOnEnter // Only render when necessary (so that we can lazy load tab content)
- unmountOnExit={false} // TODO: Should we unmount the tabs when they're not used?
- transition={false}
- activeKey={selectedTab}
- onSelect={onTabSelect}
- id="ide-rail-tabs"
- >
- {/* The <Nav> element is a "div" and has a "role="tablist"".
- But it should be identified as a navigation landmark.
- Therefore, we nest them: the parent <nav> is the landmark, and its child gets the "role="tablist"". */}
- <nav
- className={classNames('ide-rail', { hidden: isHistoryView })}
- aria-label={t('files_collaboration_integrations_logs')}
- >
- <Nav activeKey={selectedTab} className="ide-rail-tabs-nav">
- {railTabs
- .filter(({ hide }) => !hide)
- .map(({ icon, key, indicator, title, disabled }) => (
- <RailTab
- open={isOpen && selectedTab === key}
- key={key}
- eventKey={key}
- icon={icon}
- indicator={indicator}
- title={title}
- disabled={disabled}
- />
- ))}
- <div className="flex-grow-1" />
- <nav aria-label={t('help_editor_settings')}>
- {railActions?.map(action => (
- <RailActionElement key={action.key} action={action} />
- ))}
- </nav>
- </Nav>
- </nav>
- <Panel
- id={
- newErrorlogs
- ? `ide-redesign-sidebar-panel-${selectedTab}`
- : 'ide-redesign-sidebar-panel'
- }
- className={classNames({ hidden: isReviewPanelOpen })}
- order={1}
- defaultSize={15}
- minSize={5}
- maxSize={80}
- ref={panelRef}
- collapsible
- onCollapse={onCollapse}
- onExpand={handlePaneExpand}
- >
- {isHistoryView && <HistorySidebar />}
- <div
- className={classNames('ide-rail-content', {
- hidden: isHistoryView,
- })}
- >
- <Tab.Content className="ide-rail-tab-content">
- {railTabs
- .filter(({ hide }) => !hide)
- .map(({ key, component, mountOnFirstLoad }) => (
- <Tab.Pane
- eventKey={key}
- key={key}
- mountOnEnter={!mountOnFirstLoad}
- >
- {component}
- </Tab.Pane>
- ))}
- </Tab.Content>
- </div>
- </Panel>
- <HorizontalResizeHandle
- className={classNames({ hidden: isReviewPanelOpen })}
- resizable
- hitAreaMargins={{ coarse: 0, fine: 0 }}
- onDoubleClick={togglePane}
- onDragging={setResizing}
- >
- <HorizontalToggler
- id="ide-redesign-sidebar-panel"
- togglerType="west"
- isOpen={isOpen}
- setIsOpen={setIsOpen}
- tooltipWhenOpen={t('tooltip_hide_panel')}
- tooltipWhenClosed={t('tooltip_show_panel')}
- />
- </HorizontalResizeHandle>
- {RAIL_MODALS.map(({ key, modalComponentFunction: Component }) => (
- <Component key={key} show={activeModal === key} />
- ))}
- </TabContainer>
- )
- }
- const RailTab = forwardRef<
- HTMLAnchorElement,
- {
- icon: AvailableUnfilledIcon
- eventKey: string
- open: boolean
- indicator?: ReactElement
- title: string
- disabled?: boolean
- }
- >(({ icon, eventKey, open, indicator, title, disabled = false }, ref) => {
- return (
- <OLTooltip
- id={`rail-tab-tooltip-${eventKey}`}
- description={title}
- overlayProps={{ delay: 0, placement: 'right' }}
- >
- <NavLink
- ref={ref}
- eventKey={eventKey}
- className={classNames('ide-rail-tab-link', {
- 'open-rail': open,
- })}
- disabled={disabled}
- >
- {open ? (
- <MaterialIcon
- className="ide-rail-tab-link-icon"
- type={icon}
- accessibilityLabel={title}
- />
- ) : (
- <MaterialIcon
- className="ide-rail-tab-link-icon"
- type={icon}
- accessibilityLabel={title}
- unfilled
- />
- )}
- {indicator}
- </NavLink>
- </OLTooltip>
- )
- })
- RailTab.displayName = 'RailTab'
- const RailActionElement = ({ action }: { action: RailAction }) => {
- const onActionClick = useCallback(() => {
- if ('action' in action) {
- action.action()
- }
- }, [action])
- if ('dropdown' in action) {
- return (
- <Dropdown align="end" drop="end">
- <OLTooltip
- id={`rail-dropdown-tooltip-${action.key}`}
- description={action.title}
- overlayProps={{ delay: 0, placement: 'right' }}
- >
- <span>
- <DropdownToggle
- id="rail-help-dropdown-btn"
- className="ide-rail-tab-link ide-rail-tab-button ide-rail-tab-dropdown"
- as="button"
- aria-label={action.title}
- >
- <MaterialIcon
- className="ide-rail-tab-link-icon"
- type={action.icon}
- unfilled
- />
- </DropdownToggle>
- </span>
- </OLTooltip>
- {action.dropdown}
- </Dropdown>
- )
- } else {
- return (
- <OLTooltip
- id={`rail-tab-tooltip-${action.key}`}
- description={action.title}
- overlayProps={{ delay: 0, placement: 'right' }}
- >
- <button
- onClick={onActionClick}
- className="ide-rail-tab-link ide-rail-tab-button"
- aria-label={action.title}
- >
- <MaterialIcon
- className="ide-rail-tab-link-icon"
- type={action.icon}
- unfilled
- />
- </button>
- </OLTooltip>
- )
- }
- }
- const RailHelpDropdown = () => {
- const showSupport = getMeta('ol-showSupport')
- const { t } = useTranslation()
- const { setActiveModal } = useRailContext()
- const openKeyboardShortcutsModal = useCallback(() => {
- setActiveModal('keyboard-shortcuts')
- }, [setActiveModal])
- const openContactUsModal = useCallback(() => {
- setActiveModal('contact-us')
- }, [setActiveModal])
- const surveyURL = useSurveyUrl()
- return (
- <DropdownMenu>
- <DropdownItem onClick={openKeyboardShortcutsModal}>
- {t('keyboard_shortcuts')}
- </DropdownItem>
- <DropdownItem
- href="/learn"
- role="menuitem"
- target="_blank"
- rel="noopener noreferrer"
- >
- {t('documentation')}
- </DropdownItem>
- <DropdownDivider />
- {showSupport && (
- <DropdownItem onClick={openContactUsModal}>
- {t('contact_us')}
- </DropdownItem>
- )}
- <DropdownItem
- href={surveyURL}
- role="menuitem"
- target="_blank"
- rel="noopener noreferrer"
- >
- {t('give_feedback')}
- </DropdownItem>
- </DropdownMenu>
- )
- }
|