Editor Redesign Cleanup: main layout, toolbar, and rail (#31031)

* feat: integrate  main layout, toolbar, and rail from redesign into main ide-react folder

* feat: remove additional files no longer used after ide redesign

GitOrigin-RevId: 8fd77f63cb9c67be91995a9dde13b0fe2376d80f
This commit is contained in:
Jimmy Domagala-Tang
2026-02-03 09:06:37 +00:00
committed by Copybot
parent c790449196
commit fc8d564320
59 changed files with 171 additions and 845 deletions
@@ -0,0 +1,98 @@
import MaterialIcon, {
AvailableUnfilledIcon,
} from '@/shared/components/material-icon'
import { forwardRef, ReactElement, useCallback } from 'react'
import {
Dropdown,
DropdownToggle,
} from '@/shared/components/dropdown/dropdown-menu'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import { DropdownToggle as BS5DropdownToggle } from 'react-bootstrap'
type RailActionButton = {
key: string
icon: AvailableUnfilledIcon
title: string
action: () => void
hide?: boolean
ref?: React.Ref<HTMLButtonElement>
}
type RailDropdown = {
key: string
icon: AvailableUnfilledIcon
title: string
dropdown: ReactElement
hide?: boolean
ref?: React.Ref<HTMLButtonElement>
}
export type RailAction = RailDropdown | RailActionButton
const RailActionElement = forwardRef<HTMLButtonElement, { action: RailAction }>(
({ action }, ref) => {
const onActionClick = useCallback(() => {
if ('action' in action) {
action.action()
}
}, [action])
if (action.hide) {
return null
}
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
ref={ref as React.ForwardedRef<typeof BS5DropdownToggle>}
id={`rail-dropdown-btn-${action.key}`}
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
ref={ref}
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>
)
}
}
)
RailActionElement.displayName = 'RailActionElement'
export default RailActionElement
@@ -0,0 +1,43 @@
import getMeta from '@/utils/meta'
import { useTranslation } from 'react-i18next'
import { useRailContext } from '@/features/ide-react/context/rail-context'
import { useCallback } from 'react'
import {
DropdownDivider,
DropdownItem,
DropdownMenu,
} from '@/shared/components/dropdown/dropdown-menu'
export default function 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])
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>
)}
</DropdownMenu>
)
}
@@ -0,0 +1,17 @@
import OLBadge from '@/shared/components/ol/ol-badge'
type RailIndicatorProps = {
type: 'danger' | 'warning' | 'info'
count: number
}
function formatNumber(num: number) {
if (num > 99) {
return '99+'
}
return Math.floor(num).toString()
}
export const RailIndicator = ({ count, type }: RailIndicatorProps) => {
return <OLBadge bg={type}>{formatNumber(count)}</OLBadge>
}
@@ -0,0 +1,34 @@
import { FC } from 'react'
import {
RailModalKey,
useRailContext,
} from '@/features/ide-react/context/rail-context'
import { RailHelpContactUsModal } from '@/features/ide-redesign/components/help/contact-us'
import { RailHelpShowHotkeysModal } from '@/features/ide-redesign/components/help/keyboard-shortcuts'
import DictionarySettingsModal from '@/features/ide-redesign/components/settings/editor-settings/dictionary-settings-modal'
const RAIL_MODALS: {
key: RailModalKey
modalComponentFunction: FC<{ show: boolean }>
}[] = [
{
key: 'keyboard-shortcuts',
modalComponentFunction: RailHelpShowHotkeysModal,
},
{
key: 'contact-us',
modalComponentFunction: RailHelpContactUsModal,
},
{
key: 'dictionary',
modalComponentFunction: DictionarySettingsModal,
},
]
export default function RailModals() {
const { activeModal } = useRailContext()
return RAIL_MODALS.map(({ key, modalComponentFunction: Component }) => (
<Component key={key} show={activeModal === key} />
))
}
@@ -0,0 +1,33 @@
import { DropdownMenu } from '@/shared/components/dropdown/dropdown-menu'
import { RailTabKey } from '@/features/ide-react/context/rail-context'
import { RailElement } from '@/features/ide-react/util/rail-types'
import RailTab from './rail-tab'
import { shouldIncludeElement } from '@/features/ide-react/util/rail-utils'
export default function RailOverflowDropdown({
tabs,
isOpen,
selectedTab,
}: {
tabs: RailElement[]
isOpen: boolean
selectedTab: RailTabKey
}) {
return (
<DropdownMenu className="ide-rail-overflow-dropdown">
{tabs
.filter(shouldIncludeElement)
.map(({ icon, key, indicator, title, disabled }) => (
<RailTab
open={isOpen && selectedTab === key}
key={key}
eventKey={key}
icon={icon}
indicator={indicator}
title={title}
disabled={disabled}
/>
))}
</DropdownMenu>
)
}
@@ -0,0 +1,48 @@
import { useTranslation } from 'react-i18next'
import { useRailContext } from '@/features/ide-react/context/rail-context'
import OLIconButton from '@/shared/components/ol/ol-icon-button'
import React, { useCallback } from 'react'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
export default function RailPanelHeader({
title,
actions,
onClose,
}: {
title: React.ReactNode
actions?: React.ReactElement
onClose?: () => void
}) {
const { t } = useTranslation()
const { handlePaneCollapse } = useRailContext()
const handleClose = useCallback(() => {
handlePaneCollapse()
if (onClose) {
onClose()
}
}, [handlePaneCollapse, onClose])
return (
<div className="rail-panel-header">
<h4 className="rail-panel-title">{title}</h4>
<div className="rail-panel-header-actions">
{actions}
<OLTooltip
id="close-rail-panel"
description={t('close')}
overlayProps={{ placement: 'bottom' }}
>
<OLIconButton
onClick={handleClose}
className="rail-panel-header-button-subdued"
icon="close"
accessibilityLabel={t('close')}
size="sm"
/>
</OLTooltip>
</div>
</div>
)
}
@@ -0,0 +1,70 @@
import { Panel } from 'react-resizable-panels'
import { useRailContext } from '@/features/ide-react/context/rail-context'
import classNames from 'classnames'
import { useCallback, useMemo } from 'react'
import usePreviousValue from '@/shared/hooks/use-previous-value'
import { HistorySidebar } from '@/features/ide-react/components/history-sidebar'
import { Tab } from 'react-bootstrap'
import { RailElement } from '@/features/ide-react/util/rail-types'
import { shouldIncludeElement } from '@/features/ide-react/util/rail-utils'
export default function RailPanel({
isReviewPanelOpen,
isHistoryView,
railTabs,
}: {
isReviewPanelOpen: boolean
isHistoryView: boolean
railTabs: RailElement[]
}) {
const { selectedTab, panelRef, handlePaneExpand, handlePaneCollapse } =
useRailContext()
const prevTab = usePreviousValue(selectedTab)
const tabHasChanged = useMemo(() => {
return prevTab !== selectedTab
}, [prevTab, selectedTab])
const onCollapse = useCallback(() => {
if (!tabHasChanged) {
handlePaneCollapse()
}
}, [tabHasChanged, handlePaneCollapse])
return (
<Panel
id={`ide-redesign-sidebar-panel-${isHistoryView ? 'file-tree' : selectedTab}`}
className={classNames({ hidden: isReviewPanelOpen })}
order={1}
defaultSize={selectedTab === 'workbench' ? 20 : 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(shouldIncludeElement)
.map(({ key, component, mountOnFirstLoad }) => (
<Tab.Pane
eventKey={key}
key={key}
mountOnEnter={!mountOnFirstLoad}
>
{component}
</Tab.Pane>
))}
</Tab.Content>
</div>
</Panel>
)
}
@@ -0,0 +1,33 @@
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 { useRailContext } from '@/features/ide-react/context/rail-context'
import { useTranslation } from 'react-i18next'
export default function RailResizeHandle({
isReviewPanelOpen,
}: {
isReviewPanelOpen: boolean
}) {
const { isOpen, setIsOpen, togglePane, setResizing } = useRailContext()
const { t } = useTranslation()
return (
<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>
)
}
@@ -0,0 +1,71 @@
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
import classNames from 'classnames'
import { ComponentProps, forwardRef, ReactElement } from 'react'
import { NavLink } from 'react-bootstrap'
import { RailElement } from '@/features/ide-react/util/rail-types'
const RailTab = forwardRef<
HTMLAnchorElement,
{
icon: RailElement['icon']
eventKey?: string
open: boolean
indicator?: ReactElement
title: string
} & ComponentProps<'button'>
>(({ icon, eventKey, className, open, indicator, title, ...props }, ref) => {
return (
<OLTooltip
id={`rail-tab-tooltip-${eventKey}`}
description={title}
overlayProps={{ delay: 0, placement: 'right' }}
>
<NavLink
{...props}
ref={ref}
eventKey={eventKey}
className={classNames('ide-rail-tab-link', className, {
'open-rail': open,
})}
>
<RailTabIcon icon={icon} title={title} open={open} />
{indicator}
</NavLink>
</OLTooltip>
)
})
const RailTabIcon = ({
icon,
title,
open,
}: {
icon: RailElement['icon']
title: string
open: boolean
}) => {
if (typeof icon === 'string') {
return open ? (
<MaterialIcon
type={icon}
className="ide-rail-tab-link-icon"
accessibilityLabel={title}
/>
) : (
<MaterialIcon
type={icon}
className="ide-rail-tab-link-icon"
unfilled
accessibilityLabel={title}
/>
)
} else {
const Component = icon
return <Component open={open} title={title} />
}
}
RailTab.displayName = 'RailTab'
export default RailTab
@@ -0,0 +1,327 @@
import { FC, RefObject, useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Nav, TabContainer } from 'react-bootstrap'
import { useLayoutContext } from '@/shared/context/layout-context'
import {
RailTabKey,
useRailContext,
} from '@/features/ide-react/context/rail-context'
import FileTreeOutlinePanel from '@/features/ide-redesign/components/file-tree/file-tree-outline-panel'
import {
ChatIndicator,
ChatPane,
} from '@/features/ide-redesign/components/chat/chat'
import getMeta from '@/utils/meta'
import classNames from 'classnames'
import IntegrationsPanel from '@/features/ide-redesign/components/integrations-panel/integrations-panel'
import { useChatContext } from '@/features/chat/context/chat-context'
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
import {
FullProjectSearchPanel,
hasFullProjectSearch,
} from '@/features/ide-redesign/components/full-project-search-panel'
import { sendSearchEvent } from '@/features/event-tracking/search-events'
import { useProjectContext } from '@/shared/context/project-context'
import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
import RailHelpDropdown from './rail-help-dropdown'
import RailTab from './rail-tab'
import RailActionElement, { RailAction } from './rail-action-element'
import { RailElement } from '@/features/ide-react/util/rail-types'
import RailPanel from './rail-panel'
import RailResizeHandle from './rail-resize-handle'
import RailModals from './rail-modals'
import RailOverflowDropdown from './rail-overflow-dropdown'
import useRailOverflow from '@/features/ide-redesign/hooks/use-rail-overflow'
import EditorTourRailTooltip from '@/features/ide-redesign/components/editor-tour/editor-tour-rail-tooltip'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
import EditorTourThemeTooltip from '@/features/ide-redesign/components/editor-tour/editor-tour-theme-tooltip'
import EditorTourGotQuestionsTooltip from '@/features/ide-redesign/components/editor-tour/editor-tour-got-questions'
import { shouldIncludeElement } from '@/features/ide-react/util/rail-utils'
import { useEditorContext } from '@/shared/context/editor-context'
import useEventListener from '@/shared/hooks/use-event-listener'
const moduleRailEntries = (
importOverleafModules('railEntries') as {
import: { default: RailElement }
path: string
}[]
).map(({ import: { default: element } }) => element)
const moduleRailPopovers = (
importOverleafModules('railPopovers') as {
import: {
default: {
key: string
Component: FC<{ ref: RefObject<HTMLAnchorElement> }>
ref: RefObject<HTMLAnchorElement>
hide: boolean | (() => boolean)
}
}
path: string
}[]
).map(({ import: { default: element } }) => element)
export const RailLayout = () => {
const { sendEvent } = useEditorAnalytics()
const { t } = useTranslation()
const { selectedTab, openTab, isOpen, setIsOpen, togglePane, selectTab } =
useRailContext()
const { features } = useProjectContext()
const { isRestrictedTokenMember } = useEditorContext()
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
const { isOverleaf } = getMeta('ol-ExposedSettings')
const { view, setLeftMenuShown } = useLayoutContext()
const { markMessagesAsRead } = useChatContext()
const isHistoryView = view === 'history'
const fileTreeRef = useRef<HTMLAnchorElement>(null)
const settingsRef = useRef<HTMLButtonElement>(null)
useEventListener(
'ui:select-rail-tab',
useCallback(
(event: Event) => {
const {
detail: { tab, open },
} = event as CustomEvent<{
tab: RailTabKey
open: boolean
}>
selectTab(tab)
setIsOpen(open)
},
[selectTab, setIsOpen]
)
)
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,
ref: fileTreeRef,
},
{
key: 'full-project-search',
icon: 'search',
title: t('project_search'),
component: <FullProjectSearchPanel />,
hide: !hasFullProjectSearch,
},
{
key: 'integrations',
icon: 'integration_instructions',
title: t('integrations'),
component: <IntegrationsPanel />,
hide: !isOverleaf && !gitBridgeEnabled,
},
{
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') ||
isRestrictedTokenMember,
},
...moduleRailEntries,
],
[
t,
features.trackChangesVisible,
view,
isRestrictedTokenMember,
isOverleaf,
gitBridgeEnabled,
]
)
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)
},
ref: settingsRef,
},
],
[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 =>
typeof tab.hide === 'function'
? !tab.hide()
: !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]
)
useEffect(() => {
const validTabKeys = railTabs
.filter(shouldIncludeElement)
.map(tab => tab.key)
if (!validTabKeys.includes(selectedTab) && isOpen) {
// If the selected tab is no longer valid (e.g. due to permissions changes),
// switch back to the file tree
openTab('file-tree')
}
}, [railTabs, selectedTab, openTab, isOpen])
const isReviewPanelOpen =
selectedTab === 'review-panel' && isOpen && !isHistoryView
const { tabsInRail, tabsInOverflow, tabWrapperRef } =
useRailOverflow(railTabs)
const moreOptionsAction: RailAction = useMemo(() => {
return {
key: 'more-options',
icon: 'more_vert',
title: t('more_options'),
hide: tabsInOverflow.length === 0,
dropdown: (
<RailOverflowDropdown
tabs={tabsInOverflow}
isOpen={isOpen}
selectedTab={selectedTab}
/>
),
}
}, [t, isOpen, selectedTab, tabsInOverflow])
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('sidebar')}
>
<Nav activeKey={selectedTab} className="ide-rail-tabs-nav">
<div className="ide-rail-tabs-wrapper" ref={tabWrapperRef}>
{tabsInRail
.filter(shouldIncludeElement)
.map(({ icon, key, indicator, title, disabled, ref, tab }) => {
const Component = tab ?? RailTab
return (
<Component
open={isOpen && selectedTab === key}
key={key}
eventKey={key}
icon={icon}
indicator={indicator}
title={title}
disabled={disabled}
ref={ref}
/>
)
})}
<RailActionElement key="more-options" action={moreOptionsAction} />
</div>
<nav aria-label={t('help_editor_settings')}>
{railActions.map(action => (
<RailActionElement
key={action.key}
action={action}
ref={action.ref}
/>
))}
</nav>
</Nav>
</nav>
<EditorTourRailTooltip target={fileTreeRef.current} />
<EditorTourThemeTooltip target={settingsRef.current} />
<EditorTourGotQuestionsTooltip target={settingsRef.current} />
{moduleRailPopovers
.filter(shouldIncludeElement)
.map(({ key, Component, ref }) => (
<Component key={key} ref={ref} />
))}
<RailPanel
isReviewPanelOpen={isReviewPanelOpen}
isHistoryView={isHistoryView}
railTabs={railTabs}
/>
<RailResizeHandle isReviewPanelOpen={isReviewPanelOpen} />
<RailModals />
</TabContainer>
)
}