first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
import { useTranslation } from 'react-i18next'
import { LostConnectionAlert } from './lost-connection-alert'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { debugging } from '@/utils/debugging'
import useScopeValue from '@/shared/hooks/use-scope-value'
import { createPortal } from 'react-dom'
import { useGlobalAlertsContainer } from '@/features/ide-react/context/global-alerts-context'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import OLButton from '@/features/ui/components/ol/ol-button'
export function Alerts() {
const { t } = useTranslation()
const {
connectionState,
isConnected,
isStillReconnecting,
tryReconnectNow,
secondsUntilReconnect,
} = useConnectionContext()
const globalAlertsContainer = useGlobalAlertsContainer()
const [synctexError] = useScopeValue('sync_tex_error')
if (!globalAlertsContainer) {
return null
}
return createPortal(
<>
{connectionState.forceDisconnected &&
// hide "disconnected" banner when displaying out of sync modal
connectionState.error !== 'out-of-sync' ? (
<OLNotification
type="error"
content={<strong>{t('disconnected')}</strong>}
/>
) : null}
{connectionState.reconnectAt ? (
<LostConnectionAlert
reconnectAt={connectionState.reconnectAt}
tryReconnectNow={tryReconnectNow}
/>
) : null}
{isStillReconnecting ? (
<OLNotification
type="warning"
content={<strong>{t('reconnecting')}</strong>}
/>
) : null}
{synctexError ? (
<OLNotification
type="warning"
content={<strong>{t('synctex_failed')}</strong>}
action={
<OLButton
href="/learn/how-to/SyncTeX_Errors"
target="_blank"
id="synctex-more-info-button"
variant="secondary"
size="sm"
>
{t('more_info')}
</OLButton>
}
/>
) : null}
{connectionState.inactiveDisconnect ||
(connectionState.readyState === WebSocket.CLOSED &&
(connectionState.error === 'rate-limited' ||
connectionState.error === 'unable-to-connect') &&
!secondsUntilReconnect()) ? (
<OLNotification
type="warning"
content={
<strong>{t('editor_disconected_click_to_reconnect')}</strong>
}
/>
) : null}
{debugging ? (
<OLNotification
type="warning"
content={<strong>Connected: {isConnected.toString()}</strong>}
/>
) : null}
</>,
globalAlertsContainer
)
}

View File

@@ -0,0 +1,49 @@
import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'
import { secondsUntil } from '@/features/ide-react/connection/utils'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import OLButton from '@/features/ui/components/ol/ol-button'
type LostConnectionAlertProps = {
reconnectAt: number
tryReconnectNow: () => void
}
export function LostConnectionAlert({
reconnectAt,
tryReconnectNow,
}: LostConnectionAlertProps) {
const { t } = useTranslation()
const [secondsUntilReconnect, setSecondsUntilReconnect] = useState(
secondsUntil(reconnectAt)
)
useEffect(() => {
const timer = window.setInterval(() => {
setSecondsUntilReconnect(secondsUntil(reconnectAt))
}, 1000)
return () => window.clearInterval(timer)
}, [reconnectAt])
return (
<OLNotification
type="warning"
content={
<>
<strong>{t('lost_connection')}</strong>{' '}
{t('reconnecting_in_x_secs', { seconds: secondsUntilReconnect })}.
</>
}
action={
<OLButton
id="try-reconnect-now-button"
onClick={() => tryReconnectNow()}
size="sm"
variant="secondary"
>
{t('try_now')}
</OLButton>
}
/>
)
}

View File

@@ -0,0 +1,116 @@
import React, { FC, useState } from 'react'
import { Panel, PanelGroup } from 'react-resizable-panels'
import NoSelectionPane from '@/features/ide-react/components/editor/no-selection-pane'
import FileView from '@/features/file-view/components/file-view'
import MultipleSelectionPane from '@/features/ide-react/components/editor/multiple-selection-pane'
import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
import { DefaultSynctexControl } from '@/features/pdf-preview/components/detach-synctex-control'
import PdfPreview from '@/features/pdf-preview/components/pdf-preview'
import { usePdfPane } from '@/features/ide-react/hooks/use-pdf-pane'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
import { fileViewFile } from '@/features/ide-react/util/file-view'
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
import { EditorPane } from '@/features/ide-react/components/editor/editor-pane'
export const EditorAndPdf: FC = () => {
const [resizing, setResizing] = useState(false)
const { t } = useTranslation()
const {
togglePdfPane,
handlePdfPaneExpand,
handlePdfPaneCollapse,
setPdfIsOpen,
pdfIsOpen,
pdfPanelRef,
} = usePdfPane()
const { view, pdfLayout } = useLayoutContext()
const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
const editorIsOpen =
view === 'editor' || view === 'file' || pdfLayout === 'sideBySide'
return (
<PanelGroup
autoSaveId="ide-editor-pdf-layout"
direction="horizontal"
className={classNames({
'ide-panel-group-resizing': resizing,
hidden: view === 'history',
})}
>
{/* ide */}
<Panel
id="panel-ide"
order={1}
defaultSize={50}
minSize={5}
className={classNames('ide-react-panel', {
'ide-panel-group-resizing': resizing,
hidden: !editorIsOpen,
})}
>
{selectedEntityCount === 0 && <NoSelectionPane />}
{selectedEntityCount === 1 && openEntity?.type === 'fileRef' && (
<FileView file={fileViewFile(openEntity.entity)} />
)}
{selectedEntityCount > 1 && (
<MultipleSelectionPane selectedEntityCount={selectedEntityCount} />
)}
<EditorPane />
</Panel>
<HorizontalResizeHandle
resizable={pdfLayout === 'sideBySide'}
onDoubleClick={togglePdfPane}
onDragging={setResizing}
className={classNames({
hidden: !editorIsOpen,
})}
hitAreaMargins={{ coarse: 0, fine: 0 }}
>
<HorizontalToggler
id="editor-pdf"
togglerType="east"
isOpen={pdfIsOpen}
setIsOpen={setPdfIsOpen}
tooltipWhenOpen={t('tooltip_hide_pdf')}
tooltipWhenClosed={t('tooltip_show_pdf')}
/>
{pdfLayout === 'sideBySide' && (
<div className="synctex-controls">
<DefaultSynctexControl />
</div>
)}
</HorizontalResizeHandle>
{/* pdf */}
<Panel
ref={pdfPanelRef}
id="panel-pdf"
order={2}
defaultSize={50}
minSize={5}
collapsible
onCollapse={handlePdfPaneCollapse}
onExpand={handlePdfPaneExpand}
className="ide-react-panel"
>
<PdfPreview />
{/* ensure that "sync to code" is available in PDF only layout */}
{pdfLayout === 'flat' && view === 'pdf' && (
<div className="synctex-controls" hidden>
<DefaultSynctexControl />
</div>
)}
</Panel>
</PanelGroup>
)
}

View File

@@ -0,0 +1,42 @@
import { useState, useCallback } from 'react'
import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import * as eventTracking from '@/infrastructure/event-tracking'
import EditorNavigationToolbarRoot from '@/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root'
import ShareProjectModal from '@/features/share-project-modal/components/share-project-modal'
import EditorOverLimitModal from '@/features/share-project-modal/components/editor-over-limit-modal'
import ViewOnlyAccessModal from '@/features/share-project-modal/components/view-only-access-modal'
function EditorNavigationToolbar() {
const [showShareModal, setShowShareModal] = useState(false)
const { onlineUsersArray } = useOnlineUsersContext()
const { openDoc } = useEditorManagerContext()
const handleOpenShareModal = useCallback(() => {
eventTracking.sendMBOnce('ide-open-share-modal-once')
setShowShareModal(true)
}, [])
const handleHideShareModal = useCallback(() => {
setShowShareModal(false)
}, [])
return (
<>
<EditorNavigationToolbarRoot
onlineUsersArray={onlineUsersArray}
openDoc={openDoc}
openShareProjectModal={handleOpenShareModal}
/>
<EditorOverLimitModal />
<ViewOnlyAccessModal />
<ShareProjectModal
show={showShareModal}
handleOpen={handleOpenShareModal}
handleHide={handleHideShareModal}
/>
</>
)
}
export default EditorNavigationToolbar

View File

@@ -0,0 +1,58 @@
import { Panel, PanelGroup } from 'react-resizable-panels'
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
import { FileTree } from '@/features/ide-react/components/file-tree'
import classNames from 'classnames'
import { useLayoutContext } from '@/shared/context/layout-context'
import { OutlineContainer } from '@/features/outline/components/outline-container'
import { useOutlinePane } from '@/features/ide-react/hooks/use-outline-pane'
import React, { ElementType } from 'react'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
const editorSidebarComponents = importOverleafModules(
'editorSidebarComponents'
) as { import: { default: ElementType }; path: string }[]
export default function EditorSidebar() {
const { view } = useLayoutContext()
const { outlineEnabled, outlinePanelRef } = useOutlinePane()
return (
<aside
className={classNames('ide-react-editor-sidebar', {
hidden: view === 'history',
})}
>
{editorSidebarComponents.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
<PanelGroup autoSaveId="ide-editor-sidebar-layout" direction="vertical">
<Panel
defaultSize={50}
minSize={25}
className="ide-react-file-tree-panel"
id="panel-file-tree"
order={1}
>
<FileTree />
</Panel>
<VerticalResizeHandle disabled={!outlineEnabled} />
<Panel
defaultSize={50}
maxSize={75}
id="panel-outline"
order={2}
collapsible
ref={outlinePanelRef}
style={{ minHeight: 32 }} // keep the header visible
>
<OutlineContainer />
</Panel>
</PanelGroup>
</aside>
)
}

View File

@@ -0,0 +1,61 @@
import { Panel, PanelGroup } from 'react-resizable-panels'
import React, { FC, lazy, Suspense } from 'react'
import useScopeValue from '@/shared/hooks/use-scope-value'
import SourceEditor from '@/features/source-editor/components/source-editor'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { EditorScopeValue } from '@/features/ide-react/scope-adapters/editor-manager-context-adapter'
import classNames from 'classnames'
import { LoadingPane } from '@/features/ide-react/components/editor/loading-pane'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
const SymbolPalettePane = lazy(
() => import('@/features/ide-react/components/editor/symbol-palette-pane')
)
export const EditorPane: FC = () => {
const [editor] = useScopeValue<EditorScopeValue>('editor')
const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
const { currentDocumentId, isLoading } = useEditorManagerContext()
if (!currentDocumentId) {
return null
}
return (
<div
className={classNames('ide-react-editor-content', 'full-size', {
hidden: openEntity?.type !== 'doc' || selectedEntityCount !== 1,
})}
>
<PanelGroup autoSaveId="ide-editor-layout" direction="vertical">
<Panel
id="panel-source-editor"
order={1}
className="ide-react-editor-panel"
>
<SourceEditor />
{isLoading && <LoadingPane />}
</Panel>
{editor.showSymbolPalette && (
<>
<VerticalResizeHandle id="editor-symbol-palette" />
<Panel
id="panel-symbol-palette"
order={2}
defaultSize={25}
minSize={10}
maxSize={50}
>
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<SymbolPalettePane />
</Suspense>
</Panel>
</>
)}
</PanelGroup>
</div>
)
}

View File

@@ -0,0 +1,10 @@
import { FC } from 'react'
import LoadingSpinner from '@/shared/components/loading-spinner'
export const LoadingPane: FC = () => {
return (
<div className="loading-panel">
<LoadingSpinner />
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { useTranslation } from 'react-i18next'
export default function MultipleSelectionPane({
selectedEntityCount,
}: {
selectedEntityCount: number
}) {
const { t } = useTranslation()
return (
<div className="multi-selection-ongoing">
<div className="multi-selection-message">
<h4>{`${selectedEntityCount} ${t('files_selected')}`}</h4>
</div>
</div>
)
}

View File

@@ -0,0 +1,13 @@
import { useTranslation } from 'react-i18next'
export default function NoSelectionPane() {
const { t } = useTranslation()
return (
<div className="no-file-selection">
<div className="no-file-selection-message">
<h3>{t('no_selection_select_file')}</h3>
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
import React, { ElementType, FC } from 'react'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
const symbolPaletteComponents = importOverleafModules(
'sourceEditorSymbolPalette'
) as { import: { default: ElementType }; path: string }[]
const SymbolPalettePane: FC = () => {
return (
<div className="ide-react-symbol-palette">
{symbolPaletteComponents.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
</div>
)
}
export default SymbolPalettePane

View File

@@ -0,0 +1,38 @@
import React, { memo, useCallback, useState } from 'react'
import { useUserContext } from '@/shared/context/user-context'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { RefProviders } from '../../../../../types/user'
import FileTreeRoot from '@/features/file-tree/components/file-tree-root'
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
export const FileTree = memo(function FileTree() {
const user = useUserContext()
const { setStartedFreeTrial } = useIdeReactContext()
const { isConnected, connectionState } = useConnectionContext()
const { handleFileTreeInit, handleFileTreeSelect, handleFileTreeDelete } =
useFileTreeOpenContext()
const [refProviders, setRefProviders] = useState<RefProviders>(
() => user.refProviders || {}
)
const setRefProviderEnabled = useCallback(
(provider: keyof RefProviders, value = true) => {
setRefProviders(refProviders => ({ ...refProviders, [provider]: value }))
},
[]
)
return (
<FileTreeRoot
refProviders={refProviders}
setRefProviderEnabled={setRefProviderEnabled}
setStartedFreeTrial={setStartedFreeTrial}
isConnected={isConnected || connectionState.reconnectAt !== null}
onInit={handleFileTreeInit}
onSelect={handleFileTreeSelect}
onDelete={handleFileTreeDelete}
/>
)
})

View File

@@ -0,0 +1,99 @@
import { OLToast, OLToastProps } from '@/features/ui/components/ol/ol-toast'
import useEventListener from '@/shared/hooks/use-event-listener'
import { Fragment, ReactElement, useCallback, useState } from 'react'
import { debugConsole } from '@/utils/debugging'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { OLToastContainer } from '@/features/ui/components/ol/ol-toast-container'
const moduleGeneratorsImport = importOverleafModules('toastGenerators') as {
import: { default: GlobalToastGeneratorEntry[] }
}[]
const moduleGenerators = moduleGeneratorsImport.map(
({ import: { default: listEntry } }) => listEntry
)
export type GlobalToastGeneratorEntry = {
key: string
generator: GlobalToastGenerator
}
type GlobalToastGenerator = (
args: Record<string, any>
) => Omit<OLToastProps, 'onDismiss'>
const GENERATOR_LIST: GlobalToastGeneratorEntry[] = moduleGenerators.flat()
const GENERATOR_MAP: Map<string, GlobalToastGenerator> = new Map(
GENERATOR_LIST.map(({ key, generator }) => [key, generator])
)
let toastCounter = 1
export const GlobalToasts = () => {
const [toasts, setToasts] = useState<
{ component: ReactElement; id: string }[]
>([])
const removeToast = useCallback((id: string) => {
setToasts(current => current.filter(toast => toast.id !== id))
}, [])
const createToast = useCallback(
(id: string, key: string, data: any): ReactElement | null => {
const generator = GENERATOR_MAP.get(key)
if (!generator) {
debugConsole.error('No toast generator found for key:', key)
return null
}
const props = generator(data)
if (!props.autoHide && !props.isDismissible) {
// We don't want any toasts that are not dismissible and don't auto-hide
props.isDismissible = true
}
if (props.autoHide && !props.isDismissible && props.delay !== undefined) {
// If the toast is auto-hiding but not dismissible, we need to make sure the delay is not too long
props.delay = Math.min(props.delay, 60_000)
}
return <OLToast {...props} onDismiss={() => removeToast(id)} />
},
[removeToast]
)
const addToast = useCallback(
(key: string, data?: any) => {
const id = `toast-${toastCounter++}`
const component = createToast(id, key, data)
if (!component) {
return
}
setToasts(current => [...current, { id, component }])
},
[createToast]
)
const showToastListener = useCallback(
(event: CustomEvent) => {
if (!event.detail?.key) {
debugConsole.error('No key provided for toast')
return
}
const { key, ...rest } = event.detail
addToast(key, rest)
},
[addToast]
)
useEventListener('ide:show-toast', showToastListener)
return (
<OLToastContainer className="global-toasts">
{toasts.map(({ component, id }) => (
<Fragment key={id}>{component}</Fragment>
))}
</OLToastContainer>
)
}

View File

@@ -0,0 +1,23 @@
import { useLayoutContext } from '../../../shared/context/layout-context'
import { FullSizeLoadingSpinner } from '../../../shared/components/loading-spinner'
import { lazy, Suspense } from 'react'
const HistoryRoot = lazy(
() => import('@/features/ide-react/components/history-root')
)
function HistoryContainer() {
const { view } = useLayoutContext()
if (view !== 'history') {
return null
}
return (
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<HistoryRoot />
</Suspense>
)
}
export default HistoryContainer

View File

@@ -0,0 +1,13 @@
import { memo } from 'react'
import { HistoryProvider } from '@/features/history/context/history-context'
import withErrorBoundary from '@/infrastructure/error-boundary'
import { ErrorBoundaryFallback } from '@/shared/components/error-boundary-fallback'
import History from './history'
const HistoryRoot = () => (
<HistoryProvider>
<History />
</HistoryProvider>
)
export default withErrorBoundary(memo(HistoryRoot), ErrorBoundaryFallback)

View File

@@ -0,0 +1,10 @@
import React from 'react'
export function HistorySidebar() {
return (
<aside
id="history-file-tree"
className="ide-react-editor-sidebar history-file-tree"
/>
)
}

View File

@@ -0,0 +1,28 @@
import { createPortal } from 'react-dom'
import HistoryFileTree from '@/features/history/components/history-file-tree'
import LoadingSpinner from '@/shared/components/loading-spinner'
import DiffView from '@/features/history/components/diff-view/diff-view'
import ChangeList from '@/features/history/components/change-list/change-list'
import { useHistoryContext } from '@/features/history/context/history-context'
export default function History() {
const { updatesInfo } = useHistoryContext()
const fileTreeContainer = document.getElementById('history-file-tree')
return (
<>
{fileTreeContainer &&
createPortal(<HistoryFileTree />, fileTreeContainer)}
<div className="history-react">
{updatesInfo.loadingState === 'loadingInitial' ? (
<LoadingSpinner />
) : (
<>
<DiffView />
<ChangeList />
</>
)}
</div>
</>
)
}

View File

@@ -0,0 +1,18 @@
import { FC, memo, useState } from 'react'
import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback'
import withErrorBoundary from '@/infrastructure/error-boundary'
import IdePage from '@/features/ide-react/components/layout/ide-page'
import { ReactContextRoot } from '@/features/ide-react/context/react-context-root'
import { Loading } from '@/features/ide-react/components/loading'
const IdeRoot: FC = () => {
const [loaded, setLoaded] = useState(false)
return (
<ReactContextRoot>
{loaded ? <IdePage /> : <Loading setLoaded={setLoaded} />}
</ReactContextRoot>
)
}
export default withErrorBoundary(memo(IdeRoot), GenericErrorBoundaryFallback)

View File

@@ -0,0 +1,49 @@
import { lazy, Suspense } from 'react'
import { Alerts } from '@/features/ide-react/components/alerts/alerts'
import { MainLayout } from '@/features/ide-react/components/layout/main-layout'
import EditorLeftMenu from '@/features/editor-left-menu/components/editor-left-menu'
import { useLayoutEventTracking } from '@/features/ide-react/hooks/use-layout-event-tracking'
import useSocketListeners from '@/features/ide-react/hooks/use-socket-listeners'
import { useEditingSessionHeartbeat } from '@/features/ide-react/hooks/use-editing-session-heartbeat'
import { useRegisterUserActivity } from '@/features/ide-react/hooks/use-register-user-activity'
import { useHasLintingError } from '@/features/ide-react/hooks/use-has-linting-error'
import { Modals } from '@/features/ide-react/components/modals/modals'
import { GlobalAlertsProvider } from '@/features/ide-react/context/global-alerts-context'
import { GlobalToasts } from '../global-toasts'
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
const MainLayoutNew = lazy(
() => import('@/features/ide-redesign/components/main-layout')
)
const SettingsModalNew = lazy(
() => import('@/features/ide-redesign/components/settings/settings-modal')
)
export default function IdePage() {
useLayoutEventTracking() // sent event when the layout changes
useSocketListeners() // listen for project-related websocket messages
useEditingSessionHeartbeat() // send a batched event when user is active
useRegisterUserActivity() // record activity and ensure connection when user is active
useHasLintingError() // pass editor:lint hasLintingError to the compiler
const newEditor = useIsNewEditorEnabled()
return (
<GlobalAlertsProvider>
<Alerts />
<Modals />
{newEditor ? (
<Suspense fallback={null}>
<SettingsModalNew />
<MainLayoutNew />
</Suspense>
) : (
<>
<EditorLeftMenu />
<MainLayout />
</>
)}
<GlobalToasts />
</GlobalAlertsProvider>
)
}

View File

@@ -0,0 +1,140 @@
import { Panel, PanelGroup } from 'react-resizable-panels'
import { ElementType, FC } from 'react'
import { HorizontalResizeHandle } from '../resize/horizontal-resize-handle'
import classNames from 'classnames'
import { useLayoutContext } from '@/shared/context/layout-context'
import EditorNavigationToolbar from '@/features/ide-react/components/editor-navigation-toolbar'
import ChatPane from '@/features/chat/components/chat-pane'
import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
import { HistorySidebar } from '@/features/ide-react/components/history-sidebar'
import EditorSidebar from '@/features/ide-react/components/editor-sidebar'
import { useTranslation } from 'react-i18next'
import { useSidebarPane } from '@/features/ide-react/hooks/use-sidebar-pane'
import { useChatPane } from '@/features/ide-react/hooks/use-chat-pane'
import { EditorAndPdf } from '@/features/ide-react/components/editor-and-pdf'
import HistoryContainer from '@/features/ide-react/components/history-container'
import getMeta from '@/utils/meta'
import { useEditorContext } from '@/shared/context/editor-context'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
const mainEditorLayoutModalsModules: Array<{
import: { default: ElementType }
path: string
}> = importOverleafModules('mainEditorLayoutModals')
export const MainLayout: FC = () => {
const { view } = useLayoutContext()
const { isRestrictedTokenMember } = useEditorContext()
const {
isOpen: sidebarIsOpen,
setIsOpen: setSidebarIsOpen,
panelRef: sidebarPanelRef,
togglePane: toggleSidebar,
handlePaneExpand: handleSidebarExpand,
handlePaneCollapse: handleSidebarCollapse,
resizing: sidebarResizing,
setResizing: setSidebarResizing,
} = useSidebarPane()
const {
isOpen: chatIsOpen,
panelRef: chatPanelRef,
togglePane: toggleChat,
resizing: chatResizing,
setResizing: setChatResizing,
handlePaneCollapse: handleChatCollapse,
handlePaneExpand: handleChatExpand,
} = useChatPane()
const chatEnabled = getMeta('ol-chatEnabled') && !isRestrictedTokenMember
const { t } = useTranslation()
return (
<div className="ide-react-main">
<EditorNavigationToolbar />
<div className="ide-react-body">
<PanelGroup
autoSaveId="ide-outer-layout"
direction="horizontal"
className={classNames({
'ide-panel-group-resizing': sidebarResizing || chatResizing,
})}
>
{/* sidebar */}
<Panel
ref={sidebarPanelRef}
id="panel-sidebar"
order={1}
defaultSize={15}
minSize={5}
maxSize={80}
collapsible
onCollapse={handleSidebarCollapse}
onExpand={handleSidebarExpand}
>
<EditorSidebar />
{view === 'history' && <HistorySidebar />}
</Panel>
<HorizontalResizeHandle
onDoubleClick={toggleSidebar}
resizable={sidebarIsOpen}
onDragging={setSidebarResizing}
hitAreaMargins={{ coarse: 0, fine: 0 }}
>
<HorizontalToggler
id="panel-sidebar"
togglerType="west"
isOpen={sidebarIsOpen}
setIsOpen={setSidebarIsOpen}
tooltipWhenOpen={t('tooltip_hide_filetree')}
tooltipWhenClosed={t('tooltip_show_filetree')}
/>
</HorizontalResizeHandle>
<Panel id="panel-outer-main" order={2}>
<PanelGroup autoSaveId="ide-inner-layout" direction="horizontal">
<Panel className="ide-react-panel" id="panel-main" order={1}>
<HistoryContainer />
<EditorAndPdf />
</Panel>
{chatEnabled && (
<>
<HorizontalResizeHandle
onDoubleClick={toggleChat}
resizable={chatIsOpen}
onDragging={setChatResizing}
hitAreaMargins={{ coarse: 0, fine: 0 }}
/>
{/* chat */}
<Panel
ref={chatPanelRef}
id="panel-chat"
order={2}
defaultSize={20}
minSize={5}
maxSize={30}
collapsible
onCollapse={handleChatCollapse}
onExpand={handleChatExpand}
>
<ChatPane />
</Panel>
</>
)}
</PanelGroup>
</Panel>
</PanelGroup>
</div>
{mainEditorLayoutModalsModules.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { FC } from 'react'
import { ConnectionError } from '@/features/ide-react/connection/types/connection-state'
import getMeta from '@/utils/meta'
const errorMessages = {
'io-not-loaded': 'ol-translationIoNotLoaded',
'unable-to-join': 'ol-translationUnableToJoin',
'i18n-error': 'ol-translationLoadErrorMessage',
} as const
const isHandledCode = (key: string): key is keyof typeof errorMessages =>
key in errorMessages
export type LoadingErrorProps = {
errorCode: ConnectionError | 'i18n-error' | ''
}
// NOTE: i18n translations might not be loaded in the client at this point,
// so these translations have to be loaded from meta tags
export const LoadingError: FC<LoadingErrorProps> = ({ errorCode }) => {
return isHandledCode(errorCode) ? (
<p className="loading-screen-error">{getMeta(errorMessages[errorCode])}</p>
) : null
}

View File

@@ -0,0 +1,87 @@
import { FC, useEffect, useState } from 'react'
import LoadingBranded from '@/shared/components/loading-branded'
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
import getMeta from '@/utils/meta'
import { useConnectionContext } from '../context/connection-context'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { LoadingError, LoadingErrorProps } from './loading-error'
type Part = 'initial' | 'render' | 'connection' | 'translations' | 'project'
const initialParts = new Set<Part>(['initial'])
const totalParts = new Set<Part>([
'initial',
'render',
'connection',
'translations',
'project',
])
export const Loading: FC<{
setLoaded: (value: boolean) => void
}> = ({ setLoaded }) => {
const [loadedParts, setLoadedParts] = useState(initialParts)
const progress = (loadedParts.size / totalParts.size) * 100
useEffect(() => {
setLoaded(progress === 100)
}, [progress, setLoaded])
const { connectionState, isConnected } = useConnectionContext()
const i18n = useWaitForI18n()
const { projectJoined } = useIdeReactContext()
useEffect(() => {
setLoadedParts(value => new Set(value).add('render'))
}, [])
useEffect(() => {
if (isConnected) {
setLoadedParts(value => new Set(value).add('connection'))
}
}, [isConnected])
useEffect(() => {
if (i18n.isReady) {
setLoadedParts(value => new Set(value).add('translations'))
}
}, [i18n.isReady])
useEffect(() => {
if (projectJoined) {
setLoadedParts(value => new Set(value).add('project'))
}
}, [projectJoined])
// Use loading text from the server, because i18n will not be ready initially
const label = getMeta('ol-loadingText')
const errorCode = connectionState.error ?? (i18n.error ? 'i18n-error' : '')
return <LoadingUI progress={progress} label={label} errorCode={errorCode} />
}
type LoadingUiProps = {
progress: number
label: string
errorCode: LoadingErrorProps['errorCode']
}
export const LoadingUI: FC<LoadingUiProps> = ({
progress,
label,
errorCode,
}) => {
return (
<div className="loading-screen">
<LoadingBranded
loadProgress={progress}
label={label}
hasError={Boolean(errorCode)}
/>
{Boolean(errorCode) && <LoadingError errorCode={errorCode} />}
</div>
)
}

View File

@@ -0,0 +1,68 @@
import { useTranslation } from 'react-i18next'
import { memo, useEffect, useState } from 'react'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import OLModal, {
OLModalBody,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
// show modal when editor is forcefully disconnected
function ForceDisconnected() {
const { connectionState } = useConnectionContext()
const { t } = useTranslation()
const [secondsUntilRefresh, setSecondsUntilRefresh] = useState(0)
const [show, setShow] = useState(false)
useEffect(() => {
if (
connectionState.forceDisconnected &&
// out of sync has its own modal
connectionState.error !== 'out-of-sync'
) {
setShow(true)
}
}, [connectionState.forceDisconnected, connectionState.error])
useEffect(() => {
if (connectionState.forceDisconnected) {
setSecondsUntilRefresh(connectionState.forcedDisconnectDelay)
}
}, [connectionState.forceDisconnected, connectionState.forcedDisconnectDelay])
useEffect(() => {
if (show) {
const timer = window.setInterval(() => {
setSecondsUntilRefresh(seconds => Math.max(0, seconds - 1))
}, 1000)
return () => {
window.clearInterval(timer)
}
}
}, [show])
if (!show) {
return null
}
return (
<OLModal
show
// It's not possible to hide this modal, but it's a required prop
onHide={() => {}}
className="lock-editor-modal"
backdrop={false}
keyboard={false}
>
<OLModalHeader>
<OLModalTitle>{t('please_wait')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{t('were_performing_maintenance', { seconds: secondsUntilRefresh })}
</OLModalBody>
</OLModal>
)
}
export default memo(ForceDisconnected)

View File

@@ -0,0 +1,53 @@
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import { ButtonProps } from '@/features/ui/components/types/button-props'
export type GenericConfirmModalOwnProps = {
title: string
message: string
onConfirm: () => void
confirmLabel?: string
primaryVariant?: ButtonProps['variant']
}
type GenericConfirmModalProps = React.ComponentProps<typeof OLModal> &
GenericConfirmModalOwnProps
function GenericConfirmModal({
title,
message,
confirmLabel,
primaryVariant = 'primary',
...modalProps
}: GenericConfirmModalProps) {
const { t } = useTranslation()
const handleConfirmClick = modalProps.onConfirm
return (
<OLModal {...modalProps}>
<OLModalHeader closeButton>
<OLModalTitle>{title}</OLModalTitle>
</OLModalHeader>
<OLModalBody className="modal-generic-confirm">{message}</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={() => modalProps.onHide()}>
{t('cancel')}
</OLButton>
<OLButton variant={primaryVariant} onClick={handleConfirmClick}>
{confirmLabel || t('ok')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
export default memo(GenericConfirmModal)

View File

@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
export type GenericMessageModalOwnProps = {
title: string
message: string
}
type GenericMessageModalProps = React.ComponentProps<typeof OLModal> &
GenericMessageModalOwnProps
function GenericMessageModal({
title,
message,
...modalProps
}: GenericMessageModalProps) {
const { t } = useTranslation()
return (
<OLModal {...modalProps}>
<OLModalHeader closeButton>
<OLModalTitle>{title}</OLModalTitle>
</OLModalHeader>
<OLModalBody className="modal-body-share">{message}</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={() => modalProps.onHide()}>
{t('ok')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
export default memo(GenericMessageModal)

View File

@@ -0,0 +1,17 @@
import { memo } from 'react'
import ForceDisconnected from '@/features/ide-react/components/modals/force-disconnected'
import { UnsavedDocs } from '@/features/ide-react/components/unsaved-docs/unsaved-docs'
import SystemMessages from '@/shared/components/system-messages'
import { IdeRedesignSwitcherModal } from '@/features/ide-redesign/components/switcher-modal/modal'
export const Modals = memo(() => {
return (
<>
<ForceDisconnected />
<UnsavedDocs />
<SystemMessages />
<IdeRedesignSwitcherModal />
</>
)
})
Modals.displayName = 'Modals'

View File

@@ -0,0 +1,86 @@
import { Trans, useTranslation } from 'react-i18next'
import { memo, useState } from 'react'
import { useLocation } from '@/shared/hooks/use-location'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
export type OutOfSyncModalProps = {
editorContent: string
show: boolean
onHide: () => void
}
function OutOfSyncModal({ editorContent, show, onHide }: OutOfSyncModalProps) {
const { t } = useTranslation()
const location = useLocation()
const [editorContentShown, setEditorContentShown] = useState(false)
const editorContentRows = (editorContent.match(/\n/g)?.length || 0) + 1
// Reload the page to avoid staying in an inconsistent state.
// https://github.com/overleaf/issues/issues/3694
function done() {
onHide()
location.reload()
}
return (
<OLModal
show={show}
onHide={done}
className="out-of-sync-modal"
backdrop={false}
keyboard={false}
>
<OLModalHeader closeButton>
<OLModalTitle>{t('out_of_sync')}</OLModalTitle>
</OLModalHeader>
<OLModalBody className="modal-body-share">
<Trans
i18nKey="out_of_sync_detail"
components={[
// eslint-disable-next-line react/jsx-key
<br />,
// eslint-disable-next-line jsx-a11y/anchor-has-content,react/jsx-key
<a
target="_blank"
rel="noopener noreferrer"
href="/learn/Kb/Editor_out_of_sync_problems"
/>,
]}
/>
</OLModalBody>
<OLModalBody>
<OLButton
variant="secondary"
onClick={() => setEditorContentShown(shown => !shown)}
>
{editorContentShown
? t('hide_local_file_contents')
: t('show_local_file_contents')}
</OLButton>
{editorContentShown ? (
<div className="text-preview">
<textarea
className="scroll-container"
readOnly
rows={editorContentRows}
value={editorContent}
/>
</div>
) : null}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={done}>
{t('reload_editor')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
export default memo(OutOfSyncModal)

View File

@@ -0,0 +1,57 @@
import { PanelResizeHandle } from 'react-resizable-panels'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PanelResizeHandleProps } from 'react-resizable-panels/dist/declarations/src/PanelResizeHandle'
import classNames from 'classnames'
type HorizontalResizeHandleOwnProps = {
resizable?: boolean
onDoubleClick?: () => void
}
export const HorizontalResizeHandle: FC<
HorizontalResizeHandleOwnProps & PanelResizeHandleProps
> = ({ children, resizable = true, onDoubleClick, ...props }) => {
const { t } = useTranslation()
const [isDragging, setIsDragging] = useState(false)
function handleDragging(isDraggingParam: boolean) {
if (isDragging || resizable) {
setIsDragging(isDraggingParam)
}
}
// Only call onDragging prop when the pointer moves after starting a drag
useEffect(() => {
if (isDragging) {
const handlePointerMove = () => {
props.onDragging?.(true)
}
document.addEventListener('pointermove', handlePointerMove)
return () => {
document.removeEventListener('pointermove', handlePointerMove)
}
} else {
props.onDragging?.(false)
}
}, [isDragging, props])
return (
<PanelResizeHandle
disabled={!resizable && !isDragging}
{...props}
onDragging={handleDragging}
>
<div
className={classNames('horizontal-resize-handle', {
'horizontal-resize-handle-enabled': resizable,
})}
title={t('resize')}
onDoubleClick={() => onDoubleClick?.()}
>
{children}
</div>
</PanelResizeHandle>
)
}

View File

@@ -0,0 +1,48 @@
import classNames from 'classnames'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
type HorizontalTogglerType = 'west' | 'east'
type HorizontalTogglerProps = {
id: string
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
togglerType: HorizontalTogglerType
tooltipWhenOpen: string
tooltipWhenClosed: string
}
export function HorizontalToggler({
id,
isOpen,
setIsOpen,
togglerType,
tooltipWhenOpen,
tooltipWhenClosed,
}: HorizontalTogglerProps) {
const description = isOpen ? tooltipWhenOpen : tooltipWhenClosed
return (
<OLTooltip
id={id}
description={description}
overlayProps={{
placement: togglerType === 'east' ? 'left' : 'right',
}}
>
<button
className={classNames(
'custom-toggler',
`custom-toggler-${togglerType}`,
{
'custom-toggler-open': isOpen,
'custom-toggler-closed': !isOpen,
}
)}
aria-label={description}
title=""
onClick={() => setIsOpen(!isOpen)}
/>
</OLTooltip>
)
}

View File

@@ -0,0 +1,19 @@
import { PanelResizeHandle } from 'react-resizable-panels'
import { useTranslation } from 'react-i18next'
import { PanelResizeHandleProps } from 'react-resizable-panels/dist/declarations/src/PanelResizeHandle'
import classNames from 'classnames'
export function VerticalResizeHandle(props: PanelResizeHandleProps) {
const { t } = useTranslation()
return (
<PanelResizeHandle {...props}>
<div
className={classNames('vertical-resize-handle', {
'vertical-resize-handle-enabled': !props.disabled,
})}
title={t('resize')}
/>
</PanelResizeHandle>
)
}

View File

@@ -0,0 +1,61 @@
import { FC, useEffect, useMemo, useRef } from 'react'
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import { useTranslation } from 'react-i18next'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import { sendMB } from '@/infrastructure/event-tracking'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
const MAX_UNSAVED_ALERT_SECONDS = 15
export const UnsavedDocsAlert: FC<{ unsavedDocs: Map<string, number> }> = ({
unsavedDocs,
}) => (
<>
{[...unsavedDocs.entries()].map(
([docId, seconds]) =>
seconds >= MAX_UNSAVED_ALERT_SECONDS && (
<UnsavedDocAlert key={docId} docId={docId} seconds={seconds} />
)
)}
</>
)
const UnsavedDocAlert: FC<{ docId: string; seconds: number }> = ({
docId,
seconds,
}) => {
const { pathInFolder, findEntityByPath } = useFileTreePathContext()
const { socket } = useConnectionContext()
const { t } = useTranslation()
const recordedRef = useRef(false)
useEffect(() => {
if (!recordedRef.current) {
recordedRef.current = true
sendMB('unsaved-doc-alert-shown', {
docId,
transport: socket.socket.transport?.name,
})
}
}, [docId, socket])
const doc = useMemo(() => {
const path = pathInFolder(docId)
return path ? findEntityByPath(path) : null
}, [docId, findEntityByPath, pathInFolder])
if (!doc) {
return null
}
return (
<OLNotification
type="warning"
content={t('saving_notification_with_seconds', {
docname: doc.entity.name,
seconds,
})}
/>
)
}

View File

@@ -0,0 +1,19 @@
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import OLNotification from '@/features/ui/components/ol/ol-notification'
export const UnsavedDocsLockedAlert: FC = () => {
const { t } = useTranslation()
return (
<OLNotification
type="warning"
content={
<>
<strong>{t('connection_lost_with_unsaved_changes')}</strong>{' '}
{t('dont_reload_or_close_this_tab')} {t('your_changes_will_save')}
</>
}
/>
)
}

View File

@@ -0,0 +1,121 @@
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { useEditorContext } from '@/shared/context/editor-context'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { PermissionsLevel } from '@/features/ide-react/types/permissions'
import { UnsavedDocsLockedAlert } from '@/features/ide-react/components/unsaved-docs/unsaved-docs-locked-alert'
import { UnsavedDocsAlert } from '@/features/ide-react/components/unsaved-docs/unsaved-docs-alert'
import useEventListener from '@/shared/hooks/use-event-listener'
import { createPortal } from 'react-dom'
import { useGlobalAlertsContainer } from '@/features/ide-react/context/global-alerts-context'
const MAX_UNSAVED_SECONDS = 30 // lock the editor after this time if unsaved
export const UnsavedDocs: FC = () => {
const { openDocs, debugTimers } = useEditorManagerContext()
const { permissionsLevel, setPermissionsLevel } = useEditorContext()
const [isLocked, setIsLocked] = useState(false)
const [unsavedDocs, setUnsavedDocs] = useState(new Map<string, number>())
const globalAlertsContainer = useGlobalAlertsContainer()
// always contains the latest value
const previousUnsavedDocsRef = useRef(unsavedDocs)
// always contains the latest value
const permissionsLevelRef = useRef(permissionsLevel)
useEffect(() => {
permissionsLevelRef.current = permissionsLevel
}, [permissionsLevel])
// warn if the window is being closed with unsaved changes
useEventListener(
'beforeunload',
useCallback(
event => {
if (openDocs.hasUnsavedChanges()) {
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
event.preventDefault()
}
},
[openDocs]
)
)
// keep track of which docs are currently unsaved, and how long they've been unsaved for
// NOTE: openDocs should never change, so it's safe to use as a dependency here
useEffect(() => {
const interval = window.setInterval(() => {
debugTimers.current.CheckUnsavedDocs = Date.now()
const unsavedDocs = new Map()
const docs = openDocs.unsavedDocs()
for (const doc of docs) {
const oldestOpCreatedAt =
doc.getInflightOpCreatedAt() ?? doc.getPendingOpCreatedAt()
if (oldestOpCreatedAt) {
const unsavedSeconds = Math.floor(
(performance.now() - oldestOpCreatedAt) / 1000
)
unsavedDocs.set(doc.doc_id, unsavedSeconds)
}
}
// avoid setting the unsavedDocs state to a new empty Map every second
if (unsavedDocs.size > 0 || previousUnsavedDocsRef.current.size > 0) {
previousUnsavedDocsRef.current = unsavedDocs
setUnsavedDocs(unsavedDocs)
}
}, 1000)
return () => {
window.clearInterval(interval)
}
}, [openDocs, debugTimers])
const maxUnsavedSeconds = Math.max(0, ...unsavedDocs.values())
// lock the editor if at least one doc has been unsaved for too long
useEffect(() => {
setIsLocked(maxUnsavedSeconds > MAX_UNSAVED_SECONDS)
}, [maxUnsavedSeconds])
// display a modal and set the permissions level to readOnly if docs have been unsaved for too long
const originalPermissionsLevelRef = useRef<PermissionsLevel | null>(null)
useEffect(() => {
if (isLocked) {
originalPermissionsLevelRef.current = permissionsLevelRef.current
// TODO: what if the real permissions level changes in the meantime?
// TODO: perhaps the "locked" state should be stored in the editor context instead?
setPermissionsLevel('readOnly')
setIsLocked(true)
} else {
if (originalPermissionsLevelRef.current) {
setPermissionsLevel(originalPermissionsLevelRef.current)
}
}
}, [isLocked, setPermissionsLevel])
// remove the modal (and unlock the page) if the connection has been re-established and all the docs have been saved
useEffect(() => {
if (unsavedDocs.size === 0 && permissionsLevelRef.current === 'readOnly') {
setIsLocked(false)
}
}, [unsavedDocs])
if (!globalAlertsContainer) {
return null
}
return (
<>
{isLocked &&
createPortal(<UnsavedDocsLockedAlert />, globalAlertsContainer)}
{unsavedDocs.size > 0 &&
createPortal(
<UnsavedDocsAlert unsavedDocs={unsavedDocs} />,
globalAlertsContainer
)}
</>
)
}

View File

@@ -0,0 +1,531 @@
import {
ConnectionError,
ConnectionState,
ExternalHeartbeat,
SocketDebuggingInfo,
} from './types/connection-state'
import SocketIoShim from '../../../ide/connection/SocketIoShim'
import getMeta from '../../../utils/meta'
import { Socket } from '@/features/ide-react/connection/types/socket'
import { debugConsole } from '@/utils/debugging'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
const ONE_HOUR_IN_MS = 1000 * 60 * 60
const TWO_MINUTES_IN_MS = 2 * 60 * 1000
const DISCONNECT_AFTER_MS = ONE_HOUR_IN_MS * 24
const CONNECTION_ERROR_RECONNECT_DELAY = 1000
const USER_ACTIVITY_RECONNECT_NOW_DELAY = 1000
const USER_ACTIVITY_RECONNECT_DELAY = 5000
const JOIN_PROJECT_RATE_LIMITED_DELAY = 15 * 1000
const BACK_OFF_RECONNECT_OFFLINE = 5000
const RECONNECT_GRACEFULLY_RETRY_INTERVAL_MS = 5000
const MAX_RECONNECT_GRACEFULLY_INTERVAL_MS = getMeta(
'ol-maxReconnectGracefullyIntervalMs'
)
const MAX_RETRY_CONNECT = 5
const RETRY_WEBSOCKET = 3
const externalSocketHeartbeat = isSplitTestEnabled('external-socket-heartbeat')
const initialState: ConnectionState = {
readyState: WebSocket.CLOSED,
forceDisconnected: false,
inactiveDisconnect: false,
lastConnectionAttempt: 0,
reconnectAt: null,
forcedDisconnectDelay: 0,
error: '',
}
export class StateChangeEvent extends CustomEvent<{
state: ConnectionState
previousState: ConnectionState
}> {}
export class ConnectionManager extends EventTarget {
state: ConnectionState = initialState
private connectionAttempt: number | null = null
private gracefullyReconnectUntil = 0
private lastUserActivity: number
private protocolVersion = -1
private readonly idleDisconnectInterval: number
private reconnectCountdownInterval = 0
private websocketFailureCount = 0
readonly socket: Socket
private userIsLeavingPage = false
private externalHeartbeatInterval?: number
private externalHeartbeat: ExternalHeartbeat = {
currentStart: 0,
lastSuccess: 0,
lastLatency: 0,
}
constructor() {
super()
this.lastUserActivity = performance.now()
this.idleDisconnectInterval = window.setInterval(() => {
this.disconnectIfIdleSince(DISCONNECT_AFTER_MS)
}, ONE_HOUR_IN_MS)
window.addEventListener('online', () => this.onOnline())
window.addEventListener('beforeunload', () => {
this.userIsLeavingPage = true
if (this.socket.socket.transport?.name === 'xhr-polling') {
// Websockets will close automatically.
this.socket.socket.disconnect()
}
})
const parsedURL = new URL(
getMeta('ol-wsUrl') || '/socket.io',
window.origin
)
const query = new URLSearchParams({
projectId: getMeta('ol-project_id'),
})
if (externalSocketHeartbeat) {
query.set('esh', '1')
query.set('ssp', '1') // with server-side ping
}
const socket = SocketIoShim.connect(parsedURL.origin, {
resource: parsedURL.pathname.slice(1),
'auto connect': false,
'connect timeout': 30 * 1000,
'force new connection': true,
query: query.toString(),
reconnect: false,
}) as unknown as Socket
this.socket = socket
// bail out if socket.io failed to load (e.g. the real-time server is down)
if (typeof window.io !== 'object') {
this.switchToWsFallbackIfPossible()
debugConsole.error(
'Socket.io javascript not loaded. Please check that the real-time service is running and accessible.'
)
this.changeState({
...this.state,
error: 'io-not-loaded',
})
return
}
socket.on('connect', () => this.onConnect())
socket.on('disconnect', () => this.onDisconnect())
socket.on('error', err => this.onConnectError(err))
socket.on('connect_failed', err => this.onConnectError(err))
socket.on('joinProjectResponse', body => this.onJoinProjectResponse(body))
socket.on('connectionRejected', err => this.onConnectionRejected(err))
socket.on('reconnectGracefully', () => this.onReconnectGracefully())
socket.on('forceDisconnect', (_, delay) => this.onForceDisconnect(delay))
socket.on(
'serverPing',
(counter, timestamp, serverTransport, serverSessionId) =>
this.sendPingResponse(
counter,
timestamp,
serverTransport,
serverSessionId
)
)
this.tryReconnect()
}
close(error: ConnectionError) {
this.onForceDisconnect(0, error)
}
tryReconnectNow() {
this.tryReconnectWithBackoff(USER_ACTIVITY_RECONNECT_NOW_DELAY)
}
// Called when document is clicked or the editor cursor changes
registerUserActivity() {
this.lastUserActivity = performance.now()
this.userIsLeavingPage = false
this.ensureIsConnected()
}
getSocketDebuggingInfo(): SocketDebuggingInfo {
return {
client_id: this.socket.socket?.sessionid,
transport: this.socket.socket?.transport?.name,
publicId: this.socket.publicId,
lastUserActivity: this.lastUserActivity,
connectionState: this.state,
externalHeartbeat: this.externalHeartbeat,
}
}
private changeState(state: ConnectionState) {
const previousState = this.state
this.state = state
debugConsole.log('[ConnectionManager] changed state', {
previousState,
state,
})
this.dispatchEvent(
new StateChangeEvent('statechange', { detail: { state, previousState } })
)
}
private switchToWsFallbackIfPossible() {
const search = new URLSearchParams(window.location.search)
if (getMeta('ol-wsUrl') && search.get('ws') !== 'fallback') {
// if we tried to boot from a custom real-time backend and failed,
// try reloading and falling back to the siteUrl
search.set('ws', 'fallback')
window.location.search = search.toString()
return true
}
return false
}
private onOnline() {
if (!this.state.inactiveDisconnect) this.ensureIsConnected()
}
private onConnectionRejected(err: any) {
switch (err?.message) {
case 'retry': // pending real-time shutdown
this.startAutoReconnectCountdown(0)
break
case 'rate-limit hit when joining project': // rate-limited
this.changeState({
...this.state,
error: 'rate-limited',
})
break
case 'not authorized': // not logged in
case 'invalid session': // expired session
this.changeState({
...this.state,
error: 'not-logged-in',
forceDisconnected: true,
})
break
case 'project not found': // project has been deleted
this.changeState({
...this.state,
error: 'project-deleted',
forceDisconnected: true,
})
break
default:
this.changeState({
...this.state,
error: 'unable-to-join',
})
break
}
}
private onConnectError(err: any) {
if (
this.socket.socket.transport?.name === 'websocket' &&
err instanceof Event &&
err.target instanceof WebSocket
) {
this.websocketFailureCount++
}
if (this.connectionAttempt === null) return // ignore errors once connected.
if (this.connectionAttempt++ < MAX_RETRY_CONNECT) {
setTimeout(
() => {
if (this.canReconnect()) this.socket.socket.connect()
},
// slow down when potentially offline
(navigator.onLine ? 0 : BACK_OFF_RECONNECT_OFFLINE) +
// add jitter to spread reconnects
this.connectionAttempt *
(1 + Math.random()) *
CONNECTION_ERROR_RECONNECT_DELAY
)
} else {
if (!this.switchToWsFallbackIfPossible()) {
this.disconnect()
this.changeState({
...this.state,
error: 'unable-to-connect',
})
}
}
}
private onConnect() {
if (externalSocketHeartbeat) {
if (this.externalHeartbeatInterval) {
window.clearInterval(this.externalHeartbeatInterval)
}
if (this.socket.socket.transport?.name === 'websocket') {
// Do not enable external heartbeat on polling transports.
this.externalHeartbeatInterval = window.setInterval(
() => this.sendExternalHeartbeat(),
15_000
)
}
}
// Reset on success regardless of transport. We want to upgrade back to websocket on reconnect.
this.websocketFailureCount = 0
}
private onDisconnect() {
this.connectionAttempt = null
if (this.externalHeartbeatInterval) {
window.clearInterval(this.externalHeartbeatInterval)
}
this.externalHeartbeat.currentStart = 0
this.changeState({
...this.state,
readyState: WebSocket.CLOSED,
})
if (this.disconnectIfIdleSince(DISCONNECT_AFTER_MS)) return
if (this.state.error === 'rate-limited') {
this.tryReconnectWithBackoff(JOIN_PROJECT_RATE_LIMITED_DELAY)
} else {
this.startAutoReconnectCountdown(0)
}
}
private onForceDisconnect(
delay: number,
error: ConnectionError = 'maintenance'
) {
clearInterval(this.idleDisconnectInterval)
clearTimeout(this.reconnectCountdownInterval)
window.removeEventListener('online', this.onOnline)
window.setTimeout(() => this.disconnect(), 1000 * delay)
this.changeState({
...this.state,
forceDisconnected: true,
forcedDisconnectDelay: delay,
error,
})
}
private onJoinProjectResponse({
protocolVersion,
publicId,
}: {
protocolVersion: number
publicId: string
}) {
if (
this.protocolVersion !== -1 &&
this.protocolVersion !== protocolVersion
) {
this.onForceDisconnect(0, 'protocol-changed')
return
}
this.protocolVersion = protocolVersion
this.socket.publicId = publicId
this.connectionAttempt = null
this.changeState({
...this.state,
readyState: WebSocket.OPEN,
error: '',
reconnectAt: null,
})
}
private onReconnectGracefully() {
// Disconnect idle users a little earlier than the 24h limit.
if (this.disconnectIfIdleSince(DISCONNECT_AFTER_MS * 0.75)) return
if (this.gracefullyReconnectUntil) return
this.gracefullyReconnectUntil =
performance.now() + MAX_RECONNECT_GRACEFULLY_INTERVAL_MS
this.tryReconnectGracefully()
}
private canReconnect(): boolean {
if (this.state.readyState === WebSocket.OPEN) return false // no need to reconnect
if (this.state.forceDisconnected) return false // reconnecting blocked
return true
}
private isReconnectingSoon(ms: number): boolean {
if (!this.state.reconnectAt) return false
return this.state.reconnectAt - performance.now() <= ms
}
private hasReconnectedRecently(ms: number): boolean {
return performance.now() - this.state.lastConnectionAttempt < ms
}
private isUserInactiveSince(since: number): boolean {
return performance.now() - this.lastUserActivity > since
}
private disconnectIfIdleSince(threshold: number): boolean {
if (!this.isUserInactiveSince(threshold)) return false
const previouslyClosed = this.state.readyState === WebSocket.CLOSED
this.changeState({
...this.state,
readyState: WebSocket.CLOSED,
inactiveDisconnect: true,
})
if (!previouslyClosed) {
this.socket.disconnect()
}
return true
}
private disconnect() {
this.changeState({
...this.state,
readyState: WebSocket.CLOSED,
})
this.socket.disconnect()
}
private ensureIsConnected() {
if (this.state.readyState === WebSocket.OPEN) return
this.tryReconnectWithBackoff(
this.state.error === 'rate-limited'
? JOIN_PROJECT_RATE_LIMITED_DELAY
: USER_ACTIVITY_RECONNECT_DELAY
)
}
private startAutoReconnectCountdown(backoff: number) {
if (this.userIsLeavingPage) return
if (!this.canReconnect()) return
let countdown
if (this.isUserInactiveSince(TWO_MINUTES_IN_MS)) {
countdown = 60 + Math.floor(Math.random() * 2 * 60)
} else {
countdown = 3 + Math.floor(Math.random() * 7)
}
const ms = backoff + countdown * 1000
if (this.isReconnectingSoon(ms)) return
this.changeState({
...this.state,
reconnectAt: performance.now() + ms,
})
clearTimeout(this.reconnectCountdownInterval)
this.reconnectCountdownInterval = window.setTimeout(() => {
if (this.isReconnectingSoon(0)) {
this.tryReconnect()
}
}, ms)
}
private tryReconnect() {
this.gracefullyReconnectUntil = 0
this.changeState({
...this.state,
reconnectAt: null,
})
if (!this.canReconnect()) return
this.connectionAttempt = 0
this.changeState({
...this.state,
readyState: WebSocket.CONNECTING,
error: '',
inactiveDisconnect: false,
lastConnectionAttempt: performance.now(),
})
this.addReconnectListeners()
this.socket.socket.transports = ['xhr-polling']
if (this.websocketFailureCount < RETRY_WEBSOCKET) {
this.socket.socket.transports.unshift('websocket')
}
if (this.socket.socket.connecting || this.socket.socket.connected) {
// Ensure the old transport has been cleaned up.
// Socket.disconnect() does not accept a parameter. Go one level deeper.
this.socket.forceDisconnectWithoutEvent()
}
this.socket.socket.connect()
}
private addReconnectListeners() {
const handleFailure = () => {
removeSocketListeners()
this.startAutoReconnectCountdown(
// slow down when potentially offline
navigator.onLine ? 0 : BACK_OFF_RECONNECT_OFFLINE
)
}
const handleSuccess = () => {
removeSocketListeners()
}
const removeSocketListeners = () => {
this.socket.removeListener('error', handleFailure)
this.socket.removeListener('connect', handleSuccess)
}
this.socket.on('error', handleFailure)
this.socket.on('connect', handleSuccess)
}
private tryReconnectGracefully() {
if (
this.state.readyState === WebSocket.CLOSED ||
!this.gracefullyReconnectUntil
)
return
if (
this.gracefullyReconnectUntil < performance.now() ||
this.isUserInactiveSince(RECONNECT_GRACEFULLY_RETRY_INTERVAL_MS)
) {
this.disconnect()
this.tryReconnect()
} else {
setTimeout(() => {
this.tryReconnectGracefully()
}, RECONNECT_GRACEFULLY_RETRY_INTERVAL_MS)
}
}
private tryReconnectWithBackoff(backoff: number) {
if (this.hasReconnectedRecently(backoff)) {
this.startAutoReconnectCountdown(backoff)
} else {
this.tryReconnect()
}
}
private sendExternalHeartbeat() {
const t0 = performance.now()
this.socket.emit('debug.getHostname', () => {
if (this.externalHeartbeat.currentStart !== t0) {
return
}
const t1 = performance.now()
this.externalHeartbeat = {
currentStart: 0,
lastSuccess: t1,
lastLatency: t1 - t0,
}
})
this.externalHeartbeat.currentStart = t0
}
private sendPingResponse(
counter?: number,
timestamp?: number,
serverTransport?: string,
serverSessionId?: string
) {
const clientTransport = this.socket.socket.transport?.name
const clientSessionId = this.socket.socket.sessionid
this.socket.emit(
'clientPong',
counter,
timestamp,
serverTransport,
serverSessionId,
clientTransport,
clientSessionId
)
}
}

View File

@@ -0,0 +1,235 @@
/*
Migrated from services/web/frontend/js/ide/connection/EditorWatchdogManager.js
EditorWatchdogManager is used for end-to-end checks of edits.
The editor UI is backed by Ace and CodeMirrors, which in turn are connected
to ShareJs documents in the frontend.
Edits propagate from the editor to ShareJs and are send through socket.io
and real-time to document-updater.
In document-updater edits are integrated into the document history and
a confirmation/rejection is sent back to the frontend.
Along the way things can get lost.
We have certain safe-guards in place, but are still getting occasional
reports of lost edits.
EditorWatchdogManager is implementing the basis for end-to-end checks on
two levels:
- local/ShareJsDoc: edits that pass-by a ShareJs document shall get
acknowledged eventually.
- global: any edits made in the editor shall get acknowledged eventually,
independent for which ShareJs document (potentially none) sees it.
How does this work?
===================
The global check is using a global EditorWatchdogManager that is available
via the angular factory 'ide'.
Local/ShareJsDoc level checks will connect to the global instance.
Each EditorWatchdogManager keeps track of the oldest un-acknowledged edit.
When ever a ShareJs document receives an acknowledgement event, a local
EditorWatchdogManager will see it and also notify the global instance about
it.
The next edit cycle will clear the oldest un-acknowledged timestamp in case
a new ack has arrived, otherwise it will bark loud! via the timeout handler.
Scenarios
=========
- User opens the CodeMirror editor
- attach global check to new CM instance
- detach Ace from the local EditorWatchdogManager
- when the frontend attaches the CM instance to ShareJs, we also
attach it to the local EditorWatchdogManager
- the internal attach process writes the document content to the editor,
which in turn emits 'change' events. These event need to be excluded
from the watchdog. EditorWatchdogManager.ignoreEditsFor takes care
of that.
- User opens the Ace editor (again)
- (attach global check to the Ace editor, only one copy of Ace is around)
- detach local EditorWatchdogManager from CM
- likewise with CM, attach Ace to the local EditorWatchdogManager
- User makes an edit
- the editor will emit a 'change' event
- the global EditorWatchdogManager will process it first
- the local EditorWatchdogManager will process it next
- Document-updater confirms an edit
- the local EditorWatchdogManager will process it first, it passes it on to
- the global EditorWatchdogManager will process it next
Time
====
The delay between edits and acks is measured using a monotonic clock:
`performance.now()`.
It is agnostic to system clock changes in either direction and timezone
changes do not affect it as well.
Roughly speaking, it is initialized with `0` when the `window` context is
created, before our JS app boots.
As per canIUse.com and MDN `performance.now()` is available to all supported
Browsers, including IE11.
See also: https://caniuse.com/?search=performance.now
See also: https://developer.mozilla.org/en-US/docs/Web/API/Performance/now
*/
import {
ChangeDescription,
EditorFacade,
} from '../../source-editor/extensions/realtime'
import { debugConsole } from '@/utils/debugging'
// TIMEOUT specifies the timeout for edits into a single ShareJsDoc.
const TIMEOUT = 60 * 1000
// GLOBAL_TIMEOUT specifies the timeout for edits into any ShareJSDoc.
const GLOBAL_TIMEOUT = TIMEOUT
// REPORT_EVERY specifies how often we send events/report errors.
const REPORT_EVERY = 60 * 1000
const SCOPE_LOCAL = 'ShareJsDoc'
const SCOPE_GLOBAL = 'global'
type Scope = 'ShareJsDoc' | 'global'
type Meta = {
scope: Scope
delay: number
lastAck: number
lastUnackedEdit: number
}
type TimeoutHandler = (meta: Meta) => void
class Reporter {
private lastReport: number | null = null
private queue: Meta[] = []
// eslint-disable-next-line no-useless-constructor
constructor(private readonly onTimeoutHandler: TimeoutHandler) {}
private getMetaPreferLocal() {
for (const meta of this.queue) {
if (meta.scope === SCOPE_LOCAL) {
return meta
}
}
return this.queue.pop()
}
onTimeout(meta: Meta) {
// Collect all 'meta's for this update.
// global arrive before local ones, but we are eager to report local ones.
this.queue.push(meta)
setTimeout(() => {
// Another handler processed the 'meta' entry already
if (!this.queue.length) return
// There is always an item on the queue at this point,
// so getMetaPreferLocal will always return a Meta object
const maybeLocalMeta = this.getMetaPreferLocal() as Meta
// Discard other, newly arrived 'meta's
this.queue.length = 0
const now = Date.now()
// Do not flood the server with losing-edits events
const reportedRecently =
this.lastReport !== null && now - this.lastReport < REPORT_EVERY
if (!reportedRecently) {
this.lastReport = now
this.onTimeoutHandler(maybeLocalMeta)
}
})
}
}
export default class EditorWatchdogManager {
lastAck: number | null = null
reporter: Reporter
parent?: EditorWatchdogManager
scope: Scope
timeout: number
lastUnackedEdit: number | null
constructor({
parent,
onTimeoutHandler,
}: {
parent?: EditorWatchdogManager
onTimeoutHandler?: TimeoutHandler
}) {
this.scope = parent ? SCOPE_LOCAL : SCOPE_GLOBAL
this.timeout = parent ? TIMEOUT : GLOBAL_TIMEOUT
this.parent = parent
if (parent) {
this.reporter = parent.reporter
} else if (onTimeoutHandler) {
this.reporter = new Reporter(onTimeoutHandler)
} else {
throw new Error('No parent or onTimeoutHandler')
}
this.lastAck = null
this.lastUnackedEdit = null
}
onAck() {
this.lastAck = performance.now()
// bubble up to globalEditorWatchdogManager
if (this.parent) this.parent.onAck()
}
onEdit() {
// Use timestamps to track the high-water mark of unacked edits
const now = performance.now()
// Discard the last unacked edit if there are now newer acks
// TODO Handle cases where lastAck and/or lastUnackedEdit are null more transparently
// @ts-ignore
if (this.lastAck > this.lastUnackedEdit) {
this.lastUnackedEdit = null
}
// Start tracking for this keypress if we aren't already tracking an
// unacked edit
if (!this.lastUnackedEdit) {
this.lastUnackedEdit = now
}
// Report an error if the last tracked edit hasn't been cleared by an
// ack from the server after a long time
const delay = now - this.lastUnackedEdit
if (delay > this.timeout) {
const timeOrigin = Date.now() - now
const scope = this.scope
const lastAck = this.lastAck ? timeOrigin + this.lastAck : 0
const lastUnackedEdit = timeOrigin + this.lastUnackedEdit
const meta: Meta = { scope, delay, lastAck, lastUnackedEdit }
this.log('timedOut', meta)
this.reporter.onTimeout(meta)
}
}
attachToEditor(editor: EditorFacade) {
this.log('attach to editor')
const onChange = (
_editor: EditorFacade,
changeDescription: ChangeDescription
) => {
if (changeDescription.origin === 'remote') return
if (!(changeDescription.removed || changeDescription.inserted)) return
this.onEdit()
}
editor.on('change', onChange)
return () => {
this.log('detach from editor')
editor.off('change', onChange)
}
}
private log(...args: any[]) {
debugConsole.log(`[EditorWatchdogManager] ${this.scope}:`, ...args)
}
}

View File

@@ -0,0 +1,14 @@
import { Project } from '../../../../../types/project'
import { PermissionsLevel } from '@/features/ide-react/types/permissions'
export type JoinProjectPayloadProject = Pick<
Project,
Exclude<keyof Project, ['rootDocId', 'publicAccessLevel']>
> & { rootDoc_id?: string; publicAccesLevel?: string }
export type JoinProjectPayload = {
permissionsLevel: PermissionsLevel
project: JoinProjectPayloadProject
protocolVersion: number
publicId: string
}

View File

@@ -0,0 +1,35 @@
export type ConnectionError =
| 'io-not-loaded'
| 'maintenance'
| 'not-logged-in'
| 'out-of-sync'
| 'project-deleted'
| 'protocol-changed'
| 'rate-limited'
| 'unable-to-connect'
| 'unable-to-join'
export type ConnectionState = {
readyState: WebSocket['CONNECTING'] | WebSocket['OPEN'] | WebSocket['CLOSED']
forceDisconnected: boolean
inactiveDisconnect: boolean
reconnectAt: number | null
forcedDisconnectDelay: number
lastConnectionAttempt: number
error: '' | ConnectionError
}
export type ExternalHeartbeat = {
currentStart: number
lastSuccess: number
lastLatency: number
}
export type SocketDebuggingInfo = {
client_id?: string
publicId?: string
transport?: string
lastUserActivity: number
connectionState: ConnectionState
externalHeartbeat: ExternalHeartbeat
}

View File

@@ -0,0 +1,46 @@
export type Socket = {
publicId: string
on(event: string, callback: (...data: any[]) => void): void
removeListener(event: string, callback: (...data: any[]) => void): void
emit(
event: string,
arg0: any,
callback?: (error: Error, ...data: any[]) => void
): void
emit(
event: string,
arg0: any,
arg1: any,
callback?: (error: Error, ...data: any[]) => void
): void
emit(
event: string,
arg0: any,
arg1: any,
arg2: any,
callback?: (error: Error, ...data: any[]) => void
): void
emit(
event: string,
arg0: any,
arg1: any,
arg2: any,
arg3: any,
arg4: any,
arg5: any,
callback?: (error: Error, ...data: any[]) => void
): void
socket: {
connected: boolean
connecting: boolean
connect(): void
disconnect(): void
sessionid: string
transport?: {
name: string
}
transports: string[]
}
disconnect(): void
forceDisconnectWithoutEvent(): void
}

View File

@@ -0,0 +1,6 @@
export function secondsUntil(timestamp: number | null) {
if (!timestamp) return 0
const seconds = Math.ceil((timestamp - performance.now()) / 1000)
if (seconds > 0) return seconds
return 0
}

View File

@@ -0,0 +1,59 @@
import { createContext, useCallback, useContext, useState } from 'react'
type CommandInvocationContext = {
location?: string
}
export type Command = {
label: string
id: string
handler?: (context: CommandInvocationContext) => void
href?: string
disabled?: boolean
// TODO: Keybinding?
}
const CommandRegistryContext = createContext<CommandRegistry | undefined>(
undefined
)
type CommandRegistry = {
registry: Map<string, Command>
register: (...elements: Command[]) => void
unregister: (...id: string[]) => void
}
export const CommandRegistryProvider: React.FC = ({ children }) => {
const [registry, setRegistry] = useState(new Map<string, Command>())
const register = useCallback((...elements: Command[]) => {
setRegistry(
registry =>
new Map([
...registry,
...elements.map(element => [element.id, element] as const),
])
)
}, [])
const unregister = useCallback((...ids: string[]) => {
setRegistry(
registry => new Map([...registry].filter(([key]) => !ids.includes(key)))
)
}, [])
return (
<CommandRegistryContext.Provider value={{ registry, register, unregister }}>
{children}
</CommandRegistryContext.Provider>
)
}
export const useCommandRegistry = (): CommandRegistry => {
const context = useContext(CommandRegistryContext)
if (!context) {
throw new Error(
'useCommandRegistry must be used within a CommandRegistryProvider'
)
}
return context
}

View File

@@ -0,0 +1,154 @@
import {
createContext,
useContext,
useEffect,
useState,
FC,
useCallback,
useMemo,
} from 'react'
import {
ConnectionError,
ConnectionState,
SocketDebuggingInfo,
} from '../connection/types/connection-state'
import {
ConnectionManager,
StateChangeEvent,
} from '@/features/ide-react/connection/connection-manager'
import { Socket } from '@/features/ide-react/connection/types/socket'
import { secondsUntil } from '@/features/ide-react/connection/utils'
import { useLocation } from '@/shared/hooks/use-location'
type ConnectionContextValue = {
socket: Socket
connectionState: ConnectionState
isConnected: boolean
isStillReconnecting: boolean
secondsUntilReconnect: () => number
tryReconnectNow: () => void
registerUserActivity: () => void
closeConnection: (err: ConnectionError) => void
getSocketDebuggingInfo: () => SocketDebuggingInfo
}
export const ConnectionContext = createContext<
ConnectionContextValue | undefined
>(undefined)
export const ConnectionProvider: FC = ({ children }) => {
const location = useLocation()
const [connectionManager] = useState(() => new ConnectionManager())
const [connectionState, setConnectionState] = useState(
connectionManager.state
)
useEffect(() => {
const handleStateChange = ((event: StateChangeEvent) => {
setConnectionState(event.detail.state)
}) as EventListener
connectionManager.addEventListener('statechange', handleStateChange)
return () => {
connectionManager.removeEventListener('statechange', handleStateChange)
}
}, [connectionManager])
const isConnected = connectionState.readyState === WebSocket.OPEN
const isStillReconnecting =
connectionState.readyState === WebSocket.CONNECTING &&
performance.now() - connectionState.lastConnectionAttempt > 1000
const secondsUntilReconnect = useCallback(
() => secondsUntil(connectionState.reconnectAt),
[connectionState.reconnectAt]
)
const tryReconnectNow = useCallback(
() => connectionManager.tryReconnectNow(),
[connectionManager]
)
const registerUserActivity = useCallback(
() => connectionManager.registerUserActivity(),
[connectionManager]
)
const closeConnection = useCallback(
(err: ConnectionError) => connectionManager.close(err),
[connectionManager]
)
const getSocketDebuggingInfo = useCallback(
() => connectionManager.getSocketDebuggingInfo(),
[connectionManager]
)
// Reload the page on force disconnect. Doing this in React-land means that we
// can use useLocation(), which provides mockable location methods
useEffect(() => {
if (
connectionState.forceDisconnected &&
// keep editor open when out of sync
connectionState.error !== 'out-of-sync'
) {
const timer = window.setTimeout(
() => location.reload(),
connectionState.forcedDisconnectDelay * 1000
)
return () => {
window.clearTimeout(timer)
}
}
}, [
connectionState.forceDisconnected,
connectionState.forcedDisconnectDelay,
connectionState.error,
location,
])
const value = useMemo<ConnectionContextValue>(
() => ({
socket: connectionManager.socket,
connectionState,
isConnected,
isStillReconnecting,
secondsUntilReconnect,
tryReconnectNow,
registerUserActivity,
closeConnection,
getSocketDebuggingInfo,
}),
[
connectionManager.socket,
connectionState,
isConnected,
isStillReconnecting,
registerUserActivity,
secondsUntilReconnect,
tryReconnectNow,
closeConnection,
getSocketDebuggingInfo,
]
)
return (
<ConnectionContext.Provider value={value}>
{children}
</ConnectionContext.Provider>
)
}
export function useConnectionContext(): ConnectionContextValue {
const context = useContext(ConnectionContext)
if (!context) {
throw new Error(
'useConnectionContext is only available inside ConnectionProvider'
)
}
return context
}

View File

@@ -0,0 +1,730 @@
import {
createContext,
FC,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { sendMB } from '@/infrastructure/event-tracking'
import useScopeValue from '@/shared/hooks/use-scope-value'
import { useIdeContext } from '@/shared/context/ide-context'
import { OpenDocuments } from '@/features/ide-react/editor/open-documents'
import EditorWatchdogManager from '@/features/ide-react/connection/editor-watchdog-manager'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { debugConsole } from '@/utils/debugging'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
import { useLayoutContext } from '@/shared/context/layout-context'
import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
import { Doc } from '../../../../../types/doc'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import {
findDocEntityById,
findFileRefEntityById,
} from '@/features/ide-react/util/find-doc-entity-by-id'
import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
import { useTranslation } from 'react-i18next'
import customLocalStorage from '@/infrastructure/local-storage'
import useEventListener from '@/shared/hooks/use-event-listener'
import { EditorType } from '@/features/ide-react/editor/types/editor-type'
import { DocId } from '../../../../../types/project-settings'
import { Update } from '@/features/history/services/types/update'
import { useDebugDiffTracker } from '../hooks/use-debug-diff-tracker'
import { useEditorContext } from '@/shared/context/editor-context'
import useScopeValueSetterOnly from '@/shared/hooks/use-scope-value-setter-only'
import { BinaryFile } from '@/features/file-view/types/binary-file'
import { convertFileRefToBinaryFile } from '@/features/ide-react/util/file-view'
export interface GotoOffsetOptions {
gotoOffset: number
}
interface OpenDocOptions
extends Partial<GotoLineOptions>,
Partial<GotoOffsetOptions> {
gotoOffset?: number
forceReopen?: boolean
keepCurrentView?: boolean
}
export type EditorManager = {
getEditorType: () => EditorType | null
showSymbolPalette: boolean
currentDocument: DocumentContainer | null
currentDocumentId: DocId | null
getCurrentDocValue: () => string | null
getCurrentDocumentId: () => DocId | null
setIgnoringExternalUpdates: (value: boolean) => void
openDocWithId: (docId: string, options?: OpenDocOptions) => void
openDoc: (document: Doc, options?: OpenDocOptions) => void
openDocs: OpenDocuments
openFileWithId: (fileId: string) => void
openInitialDoc: (docId: string) => void
openDocName: string | null
setOpenDocName: (openDocName: string) => void
isLoading: boolean
trackChanges: boolean
jumpToLine: (options: GotoLineOptions) => void
wantTrackChanges: boolean
setWantTrackChanges: React.Dispatch<
React.SetStateAction<EditorManager['wantTrackChanges']>
>
debugTimers: React.MutableRefObject<Record<string, number>>
}
function hasGotoLine(options: OpenDocOptions): options is GotoLineOptions {
return typeof options.gotoLine === 'number'
}
function hasGotoOffset(options: OpenDocOptions): options is GotoOffsetOptions {
return typeof options.gotoOffset === 'number'
}
export const EditorManagerContext = createContext<EditorManager | undefined>(
undefined
)
export const EditorManagerProvider: FC = ({ children }) => {
const { t } = useTranslation()
const { scopeStore } = useIdeContext()
const { reportError, eventEmitter, projectId } = useIdeReactContext()
const { setOutOfSync } = useEditorContext()
const { socket, closeConnection, connectionState } = useConnectionContext()
const { view, setView } = useLayoutContext()
const { showGenericMessageModal, genericModalVisible, showOutOfSyncModal } =
useModalsContext()
const [showSymbolPalette, setShowSymbolPalette] = useScopeValue<boolean>(
'editor.showSymbolPalette'
)
const [showVisual] = useScopeValue<boolean>('editor.showVisual')
const [currentDocument, setCurrentDocument] =
useScopeValue<DocumentContainer | null>('editor.sharejs_doc')
const [currentDocumentId, setCurrentDocumentId] = useScopeValue<DocId | null>(
'editor.open_doc_id'
)
const [openDocName, setOpenDocName] = useScopeValue<string | null>(
'editor.open_doc_name'
)
const [opening, setOpening] = useScopeValue<boolean>('editor.opening')
const [errorState, setIsInErrorState] =
useScopeValue<boolean>('editor.error_state')
const [trackChanges, setTrackChanges] = useScopeValue<boolean>(
'editor.trackChanges'
)
const [wantTrackChanges, setWantTrackChanges] = useScopeValue<boolean>(
'editor.wantTrackChanges'
)
const wantTrackChangesRef = useRef(wantTrackChanges)
useEffect(() => {
wantTrackChangesRef.current = wantTrackChanges
}, [wantTrackChanges])
const goToLineEmitter = useScopeEventEmitter('editor:gotoLine')
const { fileTreeData } = useFileTreeData()
const [ignoringExternalUpdates, setIgnoringExternalUpdates] = useState(false)
const { createDebugDiff, debugTimers } = useDebugDiffTracker(
projectId,
currentDocument
)
const [globalEditorWatchdogManager] = useState(
() =>
new EditorWatchdogManager({
onTimeoutHandler: (meta: Record<string, any>) => {
let diffSize: number | null = null
createDebugDiff()
.then(calculatedDiffSize => {
diffSize = calculatedDiffSize
})
.finally(() => {
sendMB('losing-edits', {
...meta,
diffSize,
timers: debugTimers.current,
})
reportError('losing-edits', {
...meta,
diffSize,
timers: debugTimers.current,
})
})
},
})
)
// Store the most recent document error and consume it in an effect, which
// prevents circular dependencies in useCallbacks
const [docError, setDocError] = useState<{
doc: Doc
document: DocumentContainer
error: Error | string
meta?: Record<string, any>
editorContent?: string
} | null>(null)
const [docTooLongErrorShown, setDocTooLongErrorShown] = useState(false)
useEffect(() => {
if (!genericModalVisible) {
setDocTooLongErrorShown(false)
}
}, [genericModalVisible])
const [openDocs] = useState(
() => new OpenDocuments(socket, globalEditorWatchdogManager, eventEmitter)
)
const currentDocumentIdStorageKey = `doc.open_id.${projectId}`
// Persist the open document ID to local storage
useEffect(() => {
if (currentDocumentId) {
customLocalStorage.setItem(currentDocumentIdStorageKey, currentDocumentId)
}
}, [currentDocumentId, currentDocumentIdStorageKey])
const editorOpenDocEpochRef = useRef(0)
// TODO: This looks dodgy because it wraps a state setter and is itself
// stored in React state in the scope store. The problem is that it needs to
// be exposed via the scope store because some components access it that way;
// it would be better to simply access it from a context, but the current
// implementation in EditorManager interacts with Angular scope to update
// the layout. Once Angular is gone, this can become a context method.
useEffect(() => {
scopeStore.set('editor.toggleSymbolPalette', () => {
setShowSymbolPalette(show => {
const newValue = !show
sendMB(newValue ? 'symbol-palette-show' : 'symbol-palette-hide')
return newValue
})
})
}, [scopeStore, setShowSymbolPalette])
const getEditorType = useCallback((): EditorType | null => {
if (!currentDocument) {
return null
}
return showVisual ? 'cm6-rich-text' : 'cm6'
}, [currentDocument, showVisual])
const getCurrentDocValue = useCallback(() => {
return currentDocument?.getSnapshot() ?? null
}, [currentDocument])
const getCurrentDocumentId = useCallback(
() => currentDocumentId,
[currentDocumentId]
)
const jumpToLine = useCallback(
(options: GotoLineOptions) => {
goToLineEmitter(options)
},
[goToLineEmitter]
)
const attachErrorHandlerToDocument = useCallback(
(doc: Doc, document: DocumentContainer) => {
document.on(
'error',
(
error: Error | string,
meta?: Record<string, any>,
editorContent?: string
) => {
setDocError({ doc, document, error, meta, editorContent })
}
)
},
[]
)
const ignoringExternalUpdatesRef = useRef<boolean>(ignoringExternalUpdates)
useEffect(() => {
ignoringExternalUpdatesRef.current = ignoringExternalUpdates
}, [ignoringExternalUpdates])
const bindToDocumentEvents = useCallback(
(doc: Doc, document: DocumentContainer) => {
attachErrorHandlerToDocument(doc, document)
document.on('externalUpdate', (update: Update) => {
if (ignoringExternalUpdatesRef.current) {
return
}
if (
update.meta.type === 'external' &&
update.meta.source === 'git-bridge'
) {
return
}
if (
update.meta.origin?.kind === 'file-restore' ||
update.meta.origin?.kind === 'project-restore'
) {
return
}
showGenericMessageModal(
t('document_updated_externally'),
t('document_updated_externally_detail')
)
})
},
[attachErrorHandlerToDocument, showGenericMessageModal, t]
)
const syncTimeoutRef = useRef<number | null>(null)
const syncTrackChangesState = useCallback(
(doc: DocumentContainer) => {
if (!doc) {
return
}
if (syncTimeoutRef.current) {
window.clearTimeout(syncTimeoutRef.current)
syncTimeoutRef.current = null
}
const want = wantTrackChangesRef.current
const have = doc.getTrackingChanges()
if (want === have) {
setTrackChanges(want)
return
}
const tryToggle = () => {
const saved = doc.getInflightOp() == null && doc.getPendingOp() == null
if (saved) {
doc.setTrackingChanges(want)
setTrackChanges(want)
} else {
syncTimeoutRef.current = window.setTimeout(tryToggle, 100)
}
}
tryToggle()
},
[setTrackChanges]
)
const doOpenNewDocument = useCallback(
(doc: Doc) =>
new Promise<DocumentContainer>((resolve, reject) => {
debugConsole.log('[doOpenNewDocument] Opening...')
const newDocument = openDocs.getDocument(doc._id)
if (!newDocument) {
debugConsole.error(`No open document with ID '${doc._id}' found`)
reject(new Error('no open document found'))
return
}
const preJoinEpoch = ++editorOpenDocEpochRef.current
newDocument.join(error => {
if (error) {
debugConsole.log(
`[doOpenNewDocument] error joining doc ${doc._id}`,
error
)
reject(error)
return
}
if (editorOpenDocEpochRef.current !== preJoinEpoch) {
debugConsole.log(
`[doOpenNewDocument] editorOpenDocEpoch mismatch ${editorOpenDocEpochRef.current} vs ${preJoinEpoch}`
)
newDocument.leaveAndCleanUp()
reject(new Error('another document was loaded'))
return
}
bindToDocumentEvents(doc, newDocument)
resolve(newDocument)
})
}),
[bindToDocumentEvents, openDocs]
)
const openNewDocument = useCallback(
async (doc: Doc): Promise<DocumentContainer> => {
// Leave the current document
// - when we are opening a different new one, to avoid race conditions
// between leaving and joining the same document
// - when the current one has pending ops that need flushing, to avoid
// race conditions from cleanup
const currentDocumentId = currentDocument?.doc_id
const hasBufferedOps = currentDocument && currentDocument.hasBufferedOps()
const changingDoc = currentDocument && currentDocumentId !== doc._id
if (changingDoc || hasBufferedOps) {
debugConsole.log('[openNewDocument] Leaving existing open doc...')
// Do not trigger any UI changes from remote operations
currentDocument.off()
// Keep listening for out-of-sync and similar errors.
attachErrorHandlerToDocument(doc, currentDocument)
// Teardown the Document -> ShareJsDoc -> sharejs doc
// By the time this completes, the Document instance is no longer
// registered in OpenDocuments and doOpenNewDocument can start
// from scratch -- read: no corrupted internal state.
const preLeaveEpoch = ++editorOpenDocEpochRef.current
try {
await currentDocument.leaveAndCleanUpPromise()
} catch (error) {
debugConsole.log(
`[openNewDocument] error leaving doc ${currentDocumentId}`,
error
)
throw error
}
if (editorOpenDocEpochRef.current !== preLeaveEpoch) {
debugConsole.log(
`[openNewDocument] editorOpenDocEpoch mismatch ${editorOpenDocEpochRef.current} vs ${preLeaveEpoch}`
)
throw new Error('another document was loaded')
}
}
return doOpenNewDocument(doc)
},
[attachErrorHandlerToDocument, doOpenNewDocument, currentDocument]
)
const currentDocumentIdRef = useRef(currentDocumentId)
useEffect(() => {
currentDocumentIdRef.current = currentDocumentId
}, [currentDocumentId])
const openDoc = useCallback(
async (doc: Doc, options: OpenDocOptions = {}) => {
debugConsole.log(`[openDoc] Opening ${doc._id}`)
const { promise, resolve, reject } = Promise.withResolvers<Doc>()
if (view === 'editor') {
// store position of previous doc before switching docs
eventEmitter.emit('store-doc-position')
}
if (!options.keepCurrentView) {
setView('editor')
}
const done = (isNewDoc: boolean) => {
window.dispatchEvent(
new CustomEvent('doc:after-opened', {
detail: { isNewDoc, docId: doc._id },
})
)
window.dispatchEvent(
new CustomEvent('entity:opened', {
detail: doc._id,
})
)
if (hasGotoLine(options)) {
window.setTimeout(() => jumpToLine(options))
// Jump to the line again after a stored scroll position has been restored
if (isNewDoc) {
window.addEventListener(
'editor:scroll-position-restored',
() => jumpToLine(options),
{ once: true }
)
}
} else if (hasGotoOffset(options)) {
window.setTimeout(() => {
eventEmitter.emit('editor:gotoOffset', options)
})
}
resolve(doc)
}
// If we already have the document open, or are opening the document, we can return at this point.
// Note: only use forceReopen:true to override this when the document is
// out of sync and needs to be reloaded from the server.
if (doc._id === currentDocumentIdRef.current && !options.forceReopen) {
done(false)
return
}
// We're now either opening a new document or reloading a broken one.
currentDocumentIdRef.current = doc._id as DocId
setCurrentDocumentId(doc._id as DocId)
setOpenDocName(doc.name)
setOpening(true)
try {
const document = await openNewDocument(doc)
syncTrackChangesState(document)
setOpening(false)
setCurrentDocument(document)
done(true)
} catch (error: any) {
if (error?.message === 'another document was loaded') {
debugConsole.log(
`[openDoc] another document was loaded while ${doc._id} was loading`
)
return
}
debugConsole.error('Error opening document', error)
showGenericMessageModal(
t('error_opening_document'),
t('error_opening_document_detail')
)
reject(error)
}
return promise
},
[
eventEmitter,
jumpToLine,
openNewDocument,
setCurrentDocument,
setCurrentDocumentId,
setOpenDocName,
setOpening,
setView,
showGenericMessageModal,
syncTrackChangesState,
t,
view,
]
)
const openDocWithId = useCallback(
(docId: string, options: OpenDocOptions = {}) => {
const doc = findDocEntityById(fileTreeData, docId)
if (!doc) {
return
}
openDoc(doc, options)
},
[fileTreeData, openDoc]
)
const [, setOpenFile] = useScopeValueSetterOnly<BinaryFile | null>('openFile')
const openFileWithId = useCallback(
(fileRefId: string) => {
const fileRef = findFileRefEntityById(fileTreeData, fileRefId)
if (!fileRef) {
return
}
setOpenFile(convertFileRefToBinaryFile(fileRef))
window.dispatchEvent(
new CustomEvent('entity:opened', {
detail: fileRef._id,
})
)
},
[fileTreeData, setOpenFile]
)
const openInitialDoc = useCallback(
(fallbackDocId: string) => {
const docId =
customLocalStorage.getItem(currentDocumentIdStorageKey) || fallbackDocId
if (docId) {
openDocWithId(docId)
}
},
[currentDocumentIdStorageKey, openDocWithId]
)
useEffect(() => {
if (docError) {
const { doc, document, error, meta } = docError
let { editorContent } = docError
const message = typeof error === 'string' ? error : (error?.message ?? '')
// Clear document error so that it's only handled once
setDocError(null)
if (message.includes('maxDocLength')) {
openDoc(doc, { forceReopen: true })
const hasTrackedDeletes =
document.ranges != null &&
document.ranges.changes.some(change => 'd' in change.op)
const explanation = hasTrackedDeletes
? `${t('document_too_long_detail')} ${t('document_too_long_tracked_deletes')}`
: t('document_too_long_detail')
showGenericMessageModal(t('document_too_long'), explanation)
setDocTooLongErrorShown(true)
} else if (/too many comments or tracked changes/.test(message)) {
showGenericMessageModal(
t('too_many_comments_or_tracked_changes'),
t('too_many_comments_or_tracked_changes_detail')
)
} else if (!docTooLongErrorShown) {
// Do not allow this doc to open another error modal.
document.off('error')
// Preserve the sharejs contents before the teardown.
// eslint-disable-next-line no-unused-vars
editorContent =
typeof editorContent === 'string'
? editorContent
: document.doc?._doc.snapshot
// Tear down the ShareJsDoc.
if (document.doc) document.doc.clearInflightAndPendingOps()
// Do not re-join after re-connecting.
document.leaveAndCleanUp()
closeConnection('out-of-sync')
reportError(error, meta)
// Tell the user about the error state.
setIsInErrorState(true)
// Ensure that the editor is locked
setOutOfSync(true)
// Display the "out of sync" modal
showOutOfSyncModal(editorContent || '')
// Do not forceReopen the document.
return
}
const handleProjectJoined = () => {
openDoc(doc, { forceReopen: true })
}
eventEmitter.once('project:joined', handleProjectJoined)
return () => {
eventEmitter.off('project:joined', handleProjectJoined)
}
}
}, [
closeConnection,
docError,
docTooLongErrorShown,
eventEmitter,
openDoc,
reportError,
setIsInErrorState,
showGenericMessageModal,
showOutOfSyncModal,
setOutOfSync,
t,
])
useEventListener(
'editor:insert-symbol',
useCallback(() => {
sendMB('symbol-palette-insert')
}, [])
)
useEventListener(
'blur',
useCallback(() => {
openDocs.flushAll()
}, [openDocs])
)
// Flush changes before disconnecting
useEffect(() => {
if (connectionState.forceDisconnected) {
openDocs.flushAll()
}
}, [connectionState.forceDisconnected, openDocs])
// Watch for changes in wantTrackChanges
const previousWantTrackChangesRef = useRef(wantTrackChanges)
useEffect(() => {
if (
currentDocument &&
wantTrackChanges !== previousWantTrackChangesRef.current
) {
previousWantTrackChangesRef.current = wantTrackChanges
syncTrackChangesState(currentDocument)
}
}, [currentDocument, syncTrackChangesState, wantTrackChanges])
const isLoading = Boolean(
(!currentDocument || opening) && !errorState && currentDocumentId
)
const value: EditorManager = useMemo(
() => ({
getEditorType,
showSymbolPalette,
currentDocument,
currentDocumentId,
getCurrentDocValue,
getCurrentDocumentId,
setIgnoringExternalUpdates,
openDocWithId,
openDoc,
openDocs,
openDocName,
setOpenDocName,
trackChanges,
isLoading,
openFileWithId,
openInitialDoc,
jumpToLine,
wantTrackChanges,
setWantTrackChanges,
debugTimers,
}),
[
getEditorType,
showSymbolPalette,
currentDocument,
currentDocumentId,
getCurrentDocValue,
getCurrentDocumentId,
setIgnoringExternalUpdates,
openDocWithId,
openDoc,
openDocs,
openFileWithId,
openInitialDoc,
openDocName,
setOpenDocName,
trackChanges,
isLoading,
jumpToLine,
wantTrackChanges,
setWantTrackChanges,
debugTimers,
]
)
return (
<EditorManagerContext.Provider value={value}>
{children}
</EditorManagerContext.Provider>
)
}
export function useEditorManagerContext(): EditorManager {
const context = useContext(EditorManagerContext)
if (!context) {
throw new Error(
'useEditorManagerContext is only available inside EditorManagerProvider'
)
}
return context
}

View File

@@ -0,0 +1,171 @@
import {
createContext,
FC,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useProjectContext } from '@/shared/context/project-context'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import useScopeValueSetterOnly from '@/shared/hooks/use-scope-value-setter-only'
import { BinaryFile } from '@/features/file-view/types/binary-file'
import {
FileTreeDocumentFindResult,
FileTreeFileRefFindResult,
FileTreeFindResult,
} from '@/features/ide-react/types/file-tree'
import { debugConsole } from '@/utils/debugging'
import { convertFileRefToBinaryFile } from '@/features/ide-react/util/file-view'
import { sendMB } from '@/infrastructure/event-tracking'
import { FileRef } from '../../../../../types/file-ref'
const FileTreeOpenContext = createContext<
| {
selectedEntityCount: number
openEntity: FileTreeDocumentFindResult | FileTreeFileRefFindResult | null
handleFileTreeInit: () => void
handleFileTreeSelect: (selectedEntities: FileTreeFindResult[]) => void
handleFileTreeDelete: (entity: FileTreeFindResult) => void
fileTreeExpanded: boolean
toggleFileTreeExpanded: () => void
}
| undefined
>(undefined)
export const FileTreeOpenProvider: FC = ({ children }) => {
const { rootDocId, owner } = useProjectContext()
const { eventEmitter, projectJoined } = useIdeReactContext()
const { openDocWithId, currentDocumentId, openInitialDoc } =
useEditorManagerContext()
const [, setOpenFile] = useScopeValueSetterOnly<BinaryFile | null>('openFile')
const [openEntity, setOpenEntity] = useState<
FileTreeDocumentFindResult | FileTreeFileRefFindResult | null
>(null)
const [selectedEntityCount, setSelectedEntityCount] = useState(0)
const [fileTreeReady, setFileTreeReady] = useState(false)
// NOTE: Only used in editor redesign
const [fileTreeExpanded, setFileTreeExpanded] = useState(true)
const toggleFileTreeExpanded = useCallback(() => {
setFileTreeExpanded(prev => !prev)
}, [])
const handleFileTreeInit = useCallback(() => {
setFileTreeReady(true)
}, [])
// Open a document in the editor when one is selected in the file tree
const handleFileTreeSelect = useCallback(
(selectedEntities: FileTreeFindResult[]) => {
debugConsole.log('File tree selection changed', selectedEntities)
setSelectedEntityCount(selectedEntities.length)
if (selectedEntities.length !== 1) {
setOpenEntity(null)
return
}
const [selected] = selectedEntities
if (selected.type === 'folder') {
return
}
setOpenEntity(selected)
if (selected.type === 'doc' && fileTreeReady) {
openDocWithId(selected.entity._id, { keepCurrentView: true })
if (selected.entity.name.endsWith('.bib')) {
sendMB('open-bib-file', {
projectOwner: owner._id,
isSampleFile: selected.entity.name === 'sample.bib',
linkedFileProvider: null,
})
}
}
// Keep openFile scope value in sync with the file tree
const openFile =
selected.type === 'fileRef'
? convertFileRefToBinaryFile(selected.entity)
: null
setOpenFile(openFile)
if (openFile) {
if (selected?.entity?.name?.endsWith('.bib')) {
sendMB('open-bib-file', {
projectOwner: owner._id,
isSampleFile: false,
linkedFileProvider: (selected.entity as FileRef).linkedFileData
?.provider,
})
}
window.dispatchEvent(new CustomEvent('file-view:file-opened'))
}
},
[fileTreeReady, setOpenFile, openDocWithId, owner]
)
const handleFileTreeDelete = useCallback(
(entity: FileTreeFindResult) => {
eventEmitter.emit('entity:deleted', entity)
// Select the root document if the current document was deleted
if (entity.entity._id === currentDocumentId) {
openDocWithId(rootDocId!)
}
},
[eventEmitter, currentDocumentId, openDocWithId, rootDocId]
)
// Open a document once the file tree and project are ready
const initialOpenDoneRef = useRef(false)
useEffect(() => {
if (
rootDocId &&
fileTreeReady &&
projectJoined &&
!initialOpenDoneRef.current
) {
initialOpenDoneRef.current = true
openInitialDoc(rootDocId)
}
}, [fileTreeReady, openInitialDoc, projectJoined, rootDocId])
const value = useMemo(() => {
return {
selectedEntityCount,
openEntity,
handleFileTreeInit,
handleFileTreeSelect,
handleFileTreeDelete,
fileTreeExpanded,
toggleFileTreeExpanded,
}
}, [
handleFileTreeDelete,
handleFileTreeInit,
handleFileTreeSelect,
openEntity,
selectedEntityCount,
fileTreeExpanded,
toggleFileTreeExpanded,
])
return (
<FileTreeOpenContext.Provider value={value}>
{children}
</FileTreeOpenContext.Provider>
)
}
export const useFileTreeOpenContext = () => {
const context = useContext(FileTreeOpenContext)
if (!context) {
throw new Error(
'useFileTreeOpenContext is only available inside FileTreeOpenProvider'
)
}
return context
}

View File

@@ -0,0 +1,36 @@
import { createContext, FC, useCallback, useContext, useState } from 'react'
const GlobalAlertsContext = createContext<HTMLDivElement | null | undefined>(
undefined
)
export const GlobalAlertsProvider: FC = ({ children }) => {
const [globalAlertsContainer, setGlobalAlertsContainer] =
useState<HTMLDivElement | null>(null)
const handleGlobalAlertsContainer = useCallback(
(node: HTMLDivElement | null) => {
setGlobalAlertsContainer(node)
},
[]
)
return (
<GlobalAlertsContext.Provider value={globalAlertsContainer}>
<div className="global-alerts" ref={handleGlobalAlertsContainer} />
{children}
</GlobalAlertsContext.Provider>
)
}
export const useGlobalAlertsContainer = () => {
const context = useContext(GlobalAlertsContext)
if (context === undefined) {
throw new Error(
'useGlobalAlertsContainer is only available inside GlobalAlertsProvider'
)
}
return context
}

View File

@@ -0,0 +1,202 @@
import React, {
createContext,
useContext,
useState,
FC,
useMemo,
useEffect,
useCallback,
} from 'react'
import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
import populateLayoutScope from '@/features/ide-react/scope-adapters/layout-context-adapter'
import { IdeProvider } from '@/shared/context/ide-context'
import {
createIdeEventEmitter,
IdeEventEmitter,
} from '@/features/ide-react/create-ide-event-emitter'
import { JoinProjectPayload } from '@/features/ide-react/connection/join-project-payload'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { getMockIde } from '@/shared/context/mock/mock-ide'
import { populateEditorScope } from '@/features/ide-react/scope-adapters/editor-manager-context-adapter'
import { postJSON } from '@/infrastructure/fetch-json'
import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter'
import getMeta from '@/utils/meta'
const LOADED_AT = new Date()
type IdeReactContextValue = {
projectId: string
eventEmitter: IdeEventEmitter
startedFreeTrial: boolean
setStartedFreeTrial: React.Dispatch<
React.SetStateAction<IdeReactContextValue['startedFreeTrial']>
>
reportError: (error: any, meta?: Record<string, any>) => void
projectJoined: boolean
}
export const IdeReactContext = createContext<IdeReactContextValue | undefined>(
undefined
)
function populateIdeReactScope(store: ReactScopeValueStore) {
store.set('settings', {})
store.set('sync_tex_error', false)
}
function populateProjectScope(store: ReactScopeValueStore) {
store.allowNonExistentPath('project', true)
store.set('permissionsLevel', 'readOnly')
store.set('permissions', {
read: true,
write: false,
admin: false,
comment: true,
})
}
function populatePdfScope(store: ReactScopeValueStore) {
store.allowNonExistentPath('pdf', true)
}
export function createReactScopeValueStore(projectId: string) {
const scopeStore = new ReactScopeValueStore()
// Populate the scope value store with default values that will be used by
// nested contexts that refer to scope values. The ideal would be to leave
// initialization of store values up to the nested context, which would keep
// initialization code together with the context and would only populate
// necessary values in the store, but this is simpler for now
populateIdeReactScope(scopeStore)
populateEditorScope(scopeStore, projectId)
populateLayoutScope(scopeStore)
populateProjectScope(scopeStore)
populatePdfScope(scopeStore)
scopeStore.allowNonExistentPath('hasLintingError')
return scopeStore
}
export const IdeReactProvider: FC = ({ children }) => {
const projectId = getMeta('ol-project_id')
const [scopeStore] = useState(() => createReactScopeValueStore(projectId))
const [eventEmitter] = useState(createIdeEventEmitter)
const [scopeEventEmitter] = useState(
() => new ReactScopeEventEmitter(eventEmitter)
)
const [startedFreeTrial, setStartedFreeTrial] = useState(false)
const release = getMeta('ol-ExposedSettings')?.sentryRelease ?? null
// Set to true only after project:joined has fired and all its listeners have
// been called
const [projectJoined, setProjectJoined] = useState(false)
const { socket, getSocketDebuggingInfo } = useConnectionContext()
const reportError = useCallback(
(error: any, meta?: Record<string, any>) => {
const metadata = {
...meta,
user_id: getMeta('ol-user_id'),
project_id: projectId,
client_now: new Date(),
performance_now: performance.now(),
release,
client_load: LOADED_AT,
spellCheckLanguage: scopeStore.get('project.spellCheckLanguage'),
...getSocketDebuggingInfo(),
}
const errorObj: Record<string, any> = {}
if (typeof error === 'object') {
for (const key of Object.getOwnPropertyNames(error)) {
errorObj[key] = error[key]
}
} else if (typeof error === 'string') {
errorObj.message = error
}
return postJSON('/error/client', {
body: {
error: errorObj,
meta: metadata,
},
})
},
[release, projectId, getSocketDebuggingInfo, scopeStore]
)
// Populate scope values when joining project, then fire project:joined event
useEffect(() => {
function handleJoinProjectResponse({
project,
permissionsLevel,
}: JoinProjectPayload) {
scopeStore.set('project', { rootDoc_id: null, ...project })
scopeStore.set('permissionsLevel', permissionsLevel)
// Make watchers update immediately
scopeStore.flushUpdates()
eventEmitter.emit('project:joined', { project, permissionsLevel })
setProjectJoined(true)
}
function handleMainBibliographyDocUpdated(payload: string) {
scopeStore.set('project.mainBibliographyDoc_id', payload)
}
socket.on('joinProjectResponse', handleJoinProjectResponse)
socket.on('mainBibliographyDocUpdated', handleMainBibliographyDocUpdated)
return () => {
socket.removeListener('joinProjectResponse', handleJoinProjectResponse)
socket.removeListener(
'mainBibliographyDocUpdated',
handleMainBibliographyDocUpdated
)
}
}, [socket, eventEmitter, scopeStore])
const ide = useMemo(() => {
return {
...getMockIde(),
socket,
reportError,
}
}, [socket, reportError])
const value = useMemo(
() => ({
eventEmitter,
startedFreeTrial,
setStartedFreeTrial,
projectId,
reportError,
projectJoined,
}),
[eventEmitter, projectId, projectJoined, reportError, startedFreeTrial]
)
return (
<IdeReactContext.Provider value={value}>
<IdeProvider
ide={ide}
scopeStore={scopeStore}
scopeEventEmitter={scopeEventEmitter}
>
{children}
</IdeProvider>
</IdeReactContext.Provider>
)
}
export function useIdeReactContext(): IdeReactContextValue {
const context = useContext(IdeReactContext)
if (!context) {
throw new Error(
'useIdeReactContext is only available inside IdeReactProvider'
)
}
return context
}

View File

@@ -0,0 +1,39 @@
import {
createContext,
Dispatch,
FC,
SetStateAction,
useContext,
useState,
} from 'react'
type IdeRedesignSwitcherContextValue = {
showSwitcherModal: boolean
setShowSwitcherModal: Dispatch<SetStateAction<boolean>>
}
export const IdeRedesignSwitcherContext = createContext<
IdeRedesignSwitcherContextValue | undefined
>(undefined)
export const IdeRedesignSwitcherProvider: FC = ({ children }) => {
const [showSwitcherModal, setShowSwitcherModal] = useState(false)
return (
<IdeRedesignSwitcherContext.Provider
value={{ showSwitcherModal, setShowSwitcherModal }}
>
{children}
</IdeRedesignSwitcherContext.Provider>
)
}
export const useIdeRedesignSwitcherContext = () => {
const context = useContext(IdeRedesignSwitcherContext)
if (!context) {
throw new Error(
'useIdeRedesignSwitcherContext is only available inside IdeRedesignSwitcherProvider'
)
}
return context
}

View File

@@ -0,0 +1,231 @@
import {
createContext,
useContext,
useEffect,
FC,
useCallback,
useMemo,
useState,
useRef,
} from 'react'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { getJSON, postJSON } from '@/infrastructure/fetch-json'
import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
import { useEditorContext } from '@/shared/context/editor-context'
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
import useEventListener from '@/shared/hooks/use-event-listener'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useTranslation } from 'react-i18next'
import { IdeEvents } from '@/features/ide-react/create-ide-event-emitter'
export type Command = {
caption: string
snippet: string
meta: string
score: number
}
export type DocumentMetadata = {
labels: string[]
packages: Record<string, Command[]>
packageNames: string[]
}
type DocumentsMetadata = Record<string, DocumentMetadata>
type DocMetadataResponse = { docId: string; meta: DocumentMetadata }
export const MetadataContext = createContext<
| {
commands: Command[]
labels: Set<string>
packageNames: Set<string>
}
| undefined
>(undefined)
export const MetadataProvider: FC = ({ children }) => {
const { t } = useTranslation()
const { eventEmitter, projectId } = useIdeReactContext()
const { socket } = useConnectionContext()
const { onlineUsersCount } = useOnlineUsersContext()
const { permissionsLevel } = useEditorContext()
const permissions = usePermissionsContext()
const { currentDocument } = useEditorManagerContext()
const { showGenericMessageModal } = useModalsContext()
const [documents, setDocuments] = useState<DocumentsMetadata>({})
const debouncerRef = useRef<Map<string, number>>(new Map()) // DocId => Timeout
useEffect(() => {
const handleEntityDeleted = ({
detail: [entity],
}: CustomEvent<IdeEvents['entity:deleted']>) => {
if (entity.type === 'doc') {
setDocuments(documents => {
delete documents[entity.entity._id]
return { ...documents }
})
}
}
eventEmitter.on('entity:deleted', handleEntityDeleted)
return () => {
eventEmitter.off('entity:deleted', handleEntityDeleted)
}
}, [eventEmitter])
useEffect(() => {
window.dispatchEvent(
new CustomEvent('project:metadata', { detail: documents })
)
}, [documents])
const onBroadcastDocMeta = useCallback((data: DocMetadataResponse) => {
const { docId, meta } = data
if (docId != null && meta != null) {
setDocuments(documents => ({ ...documents, [docId]: meta }))
}
}, [])
const loadProjectMetaFromServer = useCallback(() => {
getJSON(`/project/${projectId}/metadata`).then(
(response: { projectMeta: DocumentsMetadata }) => {
const { projectMeta } = response
if (projectMeta) {
setDocuments(projectMeta)
}
}
)
}, [projectId])
const loadDocMetaFromServer = useCallback(
(docId: string) => {
// Don't broadcast metadata when there are no other users in the
// project.
const broadcast = onlineUsersCount > 0
postJSON(`/project/${projectId}/doc/${docId}/metadata`, {
body: {
broadcast,
},
}).then((response: DocMetadataResponse) => {
if (!broadcast && response) {
// handle the POST response like a broadcast event when there are no
// other users in the project.
onBroadcastDocMeta(response)
}
})
},
[onBroadcastDocMeta, onlineUsersCount, projectId]
)
const scheduleLoadDocMetaFromServer = useCallback(
(docId: string) => {
if (permissionsLevel === 'readOnly') {
// The POST request is blocked for users without write permission.
// The user will not be able to consume the metadata for edits anyway.
return
}
// Debounce loading labels with a timeout
const existingTimeout = debouncerRef.current.get(docId)
if (existingTimeout != null) {
window.clearTimeout(existingTimeout)
debouncerRef.current.delete(docId)
}
debouncerRef.current.set(
docId,
window.setTimeout(() => {
// TODO: wait for the document to be saved?
loadDocMetaFromServer(docId)
debouncerRef.current.delete(docId)
}, 2000)
)
},
[loadDocMetaFromServer, permissionsLevel]
)
const handleBroadcastDocMeta = useCallback(
(data: DocMetadataResponse) => {
onBroadcastDocMeta(data)
},
[onBroadcastDocMeta]
)
useSocketListener(socket, 'broadcastDocMeta', handleBroadcastDocMeta)
const handleMetadataOutdated = useCallback(() => {
if (currentDocument) {
scheduleLoadDocMetaFromServer(currentDocument.doc_id)
}
}, [currentDocument, scheduleLoadDocMetaFromServer])
useEventListener('editor:metadata-outdated', handleMetadataOutdated)
const permissionsRef = useRef(permissions)
useEffect(() => {
permissionsRef.current = permissions
}, [permissions])
useEffect(() => {
const handleProjectJoined = ({
detail: [{ project }],
}: CustomEvent<IdeEvents['project:joined']>) => {
if (project.deletedByExternalDataSource) {
showGenericMessageModal(
t('project_renamed_or_deleted'),
t('project_renamed_or_deleted_detail')
)
}
window.setTimeout(() => {
if (
permissionsRef.current.write ||
permissionsRef.current.trackedWrite
) {
loadProjectMetaFromServer()
}
}, 200)
}
eventEmitter.once('project:joined', handleProjectJoined)
return () => {
eventEmitter.off('project:joined', handleProjectJoined)
}
}, [eventEmitter, loadProjectMetaFromServer, showGenericMessageModal, t])
const value = useMemo(() => {
const docs = Object.values(documents)
return {
commands: docs.flatMap(doc => Object.values(doc.packages).flat()),
labels: new Set(docs.flatMap(doc => doc.labels)),
packageNames: new Set(docs.flatMap(doc => doc.packageNames)),
}
}, [documents])
return (
<MetadataContext.Provider value={value}>
{children}
</MetadataContext.Provider>
)
}
export function useMetadataContext() {
const context = useContext(MetadataContext)
if (!context) {
throw new Error(
'useMetadataContext is only available inside MetadataProvider'
)
}
return context
}

View File

@@ -0,0 +1,140 @@
import {
createContext,
useContext,
FC,
useCallback,
useMemo,
useState,
} from 'react'
import GenericMessageModal, {
GenericMessageModalOwnProps,
} from '@/features/ide-react/components/modals/generic-message-modal'
import OutOfSyncModal, {
OutOfSyncModalProps,
} from '@/features/ide-react/components/modals/out-of-sync-modal'
import GenericConfirmModal, {
GenericConfirmModalOwnProps,
} from '../components/modals/generic-confirm-modal'
type ModalsContextValue = {
genericModalVisible: boolean
showGenericConfirmModal: (data: GenericConfirmModalOwnProps) => void
showGenericMessageModal: (
title: GenericMessageModalOwnProps['title'],
message: GenericMessageModalOwnProps['message']
) => void
showOutOfSyncModal: (
editorContent: OutOfSyncModalProps['editorContent']
) => void
}
const ModalsContext = createContext<ModalsContextValue | undefined>(undefined)
export const ModalsContextProvider: FC = ({ children }) => {
const [showGenericModal, setShowGenericModal] = useState(false)
const [showConfirmModal, setShowConfirmModal] = useState(false)
const [genericMessageModalData, setGenericMessageModalData] =
useState<GenericMessageModalOwnProps>({ title: '', message: '' })
const [genericConfirmModalData, setGenericConfirmModalData] =
useState<GenericConfirmModalOwnProps>({
title: '',
message: '',
onConfirm: () => {},
})
const [shouldShowOutOfSyncModal, setShouldShowOutOfSyncModal] =
useState(false)
const [outOfSyncModalData, setOutOfSyncModalData] = useState({
editorContent: '',
})
const handleHideGenericModal = useCallback(() => {
setShowGenericModal(false)
}, [])
const handleHideGenericConfirmModal = useCallback(() => {
setShowConfirmModal(false)
}, [])
const handleConfirmGenericConfirmModal = useCallback(() => {
genericConfirmModalData.onConfirm()
setShowConfirmModal(false)
}, [genericConfirmModalData])
const showGenericMessageModal = useCallback(
(
title: GenericMessageModalOwnProps['title'],
message: GenericMessageModalOwnProps['message']
) => {
setGenericMessageModalData({ title, message })
setShowGenericModal(true)
},
[]
)
const showGenericConfirmModal = useCallback(
(data: GenericConfirmModalOwnProps) => {
setGenericConfirmModalData(data)
setShowConfirmModal(true)
},
[]
)
const handleHideOutOfSyncModal = useCallback(() => {
setShouldShowOutOfSyncModal(false)
}, [])
const showOutOfSyncModal = useCallback((editorContent: string) => {
setOutOfSyncModalData({ editorContent })
setShouldShowOutOfSyncModal(true)
}, [])
const value = useMemo<ModalsContextValue>(
() => ({
showGenericMessageModal,
showGenericConfirmModal,
genericModalVisible: showGenericModal,
showOutOfSyncModal,
}),
[
showGenericMessageModal,
showGenericConfirmModal,
showGenericModal,
showOutOfSyncModal,
]
)
return (
<ModalsContext.Provider value={value}>
{children}
<GenericMessageModal
show={showGenericModal}
onHide={handleHideGenericModal}
{...genericMessageModalData}
/>
<GenericConfirmModal
show={showConfirmModal}
onHide={handleHideGenericConfirmModal}
{...genericConfirmModalData}
onConfirm={handleConfirmGenericConfirmModal}
/>
<OutOfSyncModal
{...outOfSyncModalData}
show={shouldShowOutOfSyncModal}
onHide={handleHideOutOfSyncModal}
/>
</ModalsContext.Provider>
)
}
export function useModalsContext(): ModalsContextValue {
const context = useContext(ModalsContext)
if (!context) {
throw new Error(
'useModalsContext is only available inside ModalsContextProvider'
)
}
return context
}

View File

@@ -0,0 +1,288 @@
import {
createContext,
useContext,
useEffect,
FC,
useCallback,
useMemo,
useState,
} from 'react'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { CursorPosition } from '@/features/ide-react/types/cursor-position'
import { omit } from 'lodash'
import { Doc } from '../../../../../types/doc'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { findDocEntityById } from '@/features/ide-react/util/find-doc-entity-by-id'
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
import { debugConsole } from '@/utils/debugging'
import { IdeEvents } from '@/features/ide-react/create-ide-event-emitter'
import { getHueForUserId } from '@/shared/utils/colors'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
export type OnlineUser = {
id: string
user_id: string
email: string
name: string
initial?: string
doc_id?: string
doc?: Doc | null
row?: number
column?: number
}
type ConnectedUser = {
client_id: string
user_id: string
email: string
first_name: string
last_name: string
cursorData?: {
doc_id: string
row: number
column: number
}
}
type CursorHighlight = {
label: string
cursor: {
row: number
column: number
}
hue: number
}
type OnlineUsersContextValue = {
onlineUsers: Record<string, OnlineUser>
onlineUserCursorHighlights: Record<string, CursorHighlight[]>
onlineUsersArray: OnlineUser[]
onlineUsersCount: number
}
export const OnlineUsersContext = createContext<
OnlineUsersContextValue | undefined
>(undefined)
export const OnlineUsersProvider: FC = ({ children }) => {
const { eventEmitter } = useIdeReactContext()
const { socket } = useConnectionContext()
const { currentDocumentId } = useEditorManagerContext()
const { fileTreeData } = useFileTreeData()
const [onlineUsers, setOnlineUsers] = useState<Record<string, OnlineUser>>({})
const [onlineUserCursorHighlights, setOnlineUserCursorHighlights] = useState<
Record<string, CursorHighlight[]>
>({})
const [onlineUsersArray, setOnlineUsersArray] = useState<OnlineUser[]>([])
const [onlineUsersCount, setOnlineUsersCount] = useState(0)
const [currentPosition, setCurrentPosition] = useState<CursorPosition | null>(
null
)
const [cursorUpdateInterval, setCursorUpdateInterval] = useState(500)
const calculateValues = useCallback(
(onlineUsers: OnlineUsersContextValue['onlineUsers']) => {
const decoratedOnlineUsers: OnlineUsersContextValue['onlineUsers'] = {}
const onlineUsersArray: OnlineUser[] = []
const onlineUserCursorHighlights: OnlineUsersContextValue['onlineUserCursorHighlights'] =
{}
for (const [clientId, user] of Object.entries(onlineUsers)) {
const decoratedUser = { ...user }
const docId = user.doc_id
if (docId) {
decoratedUser.doc = findDocEntityById(fileTreeData, docId)
}
// If the user's name is empty use their email as display name
// Otherwise they're probably an anonymous user
if (user.name === null || user.name.trim().length === 0) {
decoratedUser.name = user.email ? user.email.trim() : 'Anonymous'
}
decoratedUser.initial = user.name?.[0]
if (!decoratedUser.initial || decoratedUser.initial === ' ') {
decoratedUser.initial = '?'
}
onlineUsersArray.push(decoratedUser)
decoratedOnlineUsers[clientId] = decoratedUser
if (docId == null || user.row == null || user.column == null) {
continue
}
if (!onlineUserCursorHighlights[docId]) {
onlineUserCursorHighlights[docId] = []
}
onlineUserCursorHighlights[docId].push({
label: user.name,
cursor: {
row: user.row,
column: user.column,
},
hue: getHueForUserId(user.user_id),
})
}
const cursorUpdateInterval =
onlineUsersArray.length > 0 ? 500 : 60 * 1000 * 5
return {
onlineUsers: decoratedOnlineUsers,
onlineUsersArray,
onlineUserCursorHighlights,
cursorUpdateInterval,
}
},
[fileTreeData]
)
const setAllValues = useCallback(
(newOnlineUsers: OnlineUsersContextValue['onlineUsers']) => {
const values = calculateValues(newOnlineUsers)
setOnlineUsers(values.onlineUsers)
setOnlineUsersArray(values.onlineUsersArray)
setOnlineUsersCount(values.onlineUsersArray.length)
setOnlineUserCursorHighlights(values.onlineUserCursorHighlights)
setCursorUpdateInterval(values.cursorUpdateInterval)
},
[
calculateValues,
setOnlineUserCursorHighlights,
setOnlineUsers,
setOnlineUsersArray,
setOnlineUsersCount,
]
)
useEffect(() => {
const handleProjectJoined = () => {
socket.emit(
'clientTracking.getConnectedUsers',
(error: Error, connectedUsers: ConnectedUser[]) => {
if (error) {
// TODO: handle this error or ignore it?
debugConsole.error(error)
return
}
const newOnlineUsers: OnlineUsersContextValue['onlineUsers'] = {}
for (const user of connectedUsers) {
if (user.client_id === socket.publicId) {
// Don't store myself
continue
}
// Store data in the same format returned by clientTracking.clientUpdated
newOnlineUsers[user.client_id] = {
id: user.client_id,
user_id: user.user_id,
email: user.email,
name: `${user.first_name} ${user.last_name}`,
doc_id: user.cursorData?.doc_id,
row: user.cursorData?.row,
column: user.cursorData?.column,
}
}
setAllValues(newOnlineUsers)
}
)
}
eventEmitter.on('project:joined', handleProjectJoined)
return () => {
eventEmitter.off('project:joined', handleProjectJoined)
}
}, [eventEmitter, setAllValues, setOnlineUsers, socket])
// Track the position of the main cursor
useEffect(() => {
const handleCursorUpdate = ({
detail: [position],
}: CustomEvent<IdeEvents['cursor:editor:update']>) => {
if (position) {
setCurrentPosition(position)
}
}
eventEmitter.on('cursor:editor:update', handleCursorUpdate)
return () => {
eventEmitter.off('cursor:editor:update', handleCursorUpdate)
}
}, [cursorUpdateInterval, eventEmitter])
// Send the latest position to other clients when currentPosition changes
useEffect(() => {
const timer = window.setTimeout(() => {
socket.emit('clientTracking.updatePosition', {
row: currentPosition?.row,
column: currentPosition?.column,
doc_id: currentDocumentId,
})
}, cursorUpdateInterval)
return () => {
window.clearTimeout(timer)
}
}, [currentPosition, cursorUpdateInterval, currentDocumentId, socket])
const handleClientUpdated = useCallback(
(client: OnlineUser) => {
// Check it's not me!
if (client.id !== socket.publicId) {
setAllValues({ ...onlineUsers, [client.id]: client })
}
},
[onlineUsers, setAllValues, socket.publicId]
)
useSocketListener(socket, 'clientTracking.clientUpdated', handleClientUpdated)
const handleClientDisconnected = useCallback(
(clientId: string) => {
setAllValues(omit(onlineUsers, clientId))
},
[onlineUsers, setAllValues]
)
useSocketListener(
socket,
'clientTracking.clientDisconnected',
handleClientDisconnected
)
const value = useMemo<OnlineUsersContextValue>(
() => ({
onlineUsers,
onlineUsersArray,
onlineUserCursorHighlights,
onlineUsersCount,
}),
[
onlineUsers,
onlineUsersArray,
onlineUserCursorHighlights,
onlineUsersCount,
]
)
return (
<OnlineUsersContext.Provider value={value}>
{children}
</OnlineUsersContext.Provider>
)
}
export function useOnlineUsersContext(): OnlineUsersContextValue {
const context = useContext(OnlineUsersContext)
if (!context) {
throw new Error(
'useOnlineUsersContext is only available inside OnlineUsersProvider'
)
}
return context
}

View File

@@ -0,0 +1,198 @@
import {
createContext,
Dispatch,
FC,
SetStateAction,
useCallback,
useContext,
useMemo,
useState,
} from 'react'
import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
import useEventListener from '@/shared/hooks/use-event-listener'
import * as eventTracking from '@/infrastructure/event-tracking'
import { isValidTeXFile } from '@/main/is-valid-tex-file'
import localStorage from '@/infrastructure/local-storage'
import { useProjectContext } from '@/shared/context/project-context'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
export type PartialFlatOutline = {
level: number
title: string
line: number
}[]
export type FlatOutlineState =
| {
items: PartialFlatOutline
partial: boolean
}
| undefined
const OutlineContext = createContext<
| {
flatOutline: FlatOutlineState
setFlatOutline: Dispatch<SetStateAction<FlatOutlineState>>
highlightedLine: number
jumpToLine: (lineNumber: number, syncToPdf: boolean) => void
canShowOutline: boolean
outlineExpanded: boolean
toggleOutlineExpanded: () => void
}
| undefined
>(undefined)
export const OutlineProvider: FC = ({ children }) => {
const [flatOutline, setFlatOutline] = useState<FlatOutlineState>(undefined)
const [currentlyHighlightedLine, setCurrentlyHighlightedLine] =
useState<number>(-1)
const [binaryFileOpened, setBinaryFileOpened] = useState<boolean>(false)
const [ignoreNextCursorUpdate, setIgnoreNextCursorUpdate] =
useState<boolean>(false)
const [ignoreNextScroll, setIgnoreNextScroll] = useState<boolean>(false)
const goToLineEmitter = useScopeEventEmitter('editor:gotoLine', true)
useEventListener(
'file-view:file-opened',
useCallback(_ => {
setBinaryFileOpened(true)
}, [])
)
useEventListener(
'scroll:editor:update',
useCallback(
evt => {
if (ignoreNextScroll) {
setIgnoreNextScroll(false)
return
}
setCurrentlyHighlightedLine(evt.detail + 1)
},
[ignoreNextScroll]
)
)
useEventListener(
'cursor:editor:update',
useCallback(
evt => {
if (ignoreNextCursorUpdate) {
setIgnoreNextCursorUpdate(false)
return
}
setCurrentlyHighlightedLine(evt.detail.row + 1)
},
[ignoreNextCursorUpdate]
)
)
useEventListener(
'doc:after-opened',
useCallback(evt => {
if (evt.detail.isNewDoc) {
setIgnoreNextCursorUpdate(true)
}
setBinaryFileOpened(false)
setIgnoreNextScroll(true)
}, [])
)
const jumpToLine = useCallback(
(lineNumber: number, syncToPdf: boolean) => {
setIgnoreNextScroll(true)
goToLineEmitter({
gotoLine: lineNumber,
gotoColumn: 0,
syncToPdf,
})
eventTracking.sendMB('outline-jump-to-line')
},
[goToLineEmitter]
)
const highlightedLine = useMemo(
() =>
closestSectionLineNumber(flatOutline?.items, currentlyHighlightedLine),
[flatOutline, currentlyHighlightedLine]
)
const { openDocName } = useEditorManagerContext()
const isTexFile = useMemo(
() => (openDocName ? isValidTeXFile(openDocName) : false),
[openDocName]
)
const { _id: projectId } = useProjectContext()
const storageKey = `file_outline.expanded.${projectId}`
const [outlineExpanded, setOutlineExpanded] = useState(
() => localStorage.getItem(storageKey) !== false
)
const canShowOutline = isTexFile && !binaryFileOpened
const toggleOutlineExpanded = useCallback(() => {
if (canShowOutline) {
localStorage.setItem(storageKey, !outlineExpanded)
eventTracking.sendMB(
outlineExpanded ? 'outline-collapse' : 'outline-expand'
)
setOutlineExpanded(!outlineExpanded)
}
}, [canShowOutline, outlineExpanded, storageKey])
const value = useMemo(
() => ({
flatOutline,
setFlatOutline,
highlightedLine,
jumpToLine,
canShowOutline,
outlineExpanded,
toggleOutlineExpanded,
}),
[
flatOutline,
highlightedLine,
jumpToLine,
canShowOutline,
outlineExpanded,
toggleOutlineExpanded,
]
)
return (
<OutlineContext.Provider value={value}>{children}</OutlineContext.Provider>
)
}
export const useOutlineContext = () => {
const context = useContext(OutlineContext)
if (!context) {
throw new Error(
'useOutlineProvider is only available inside OutlineProvider'
)
}
return context
}
const closestSectionLineNumber = (
outline: { line: number }[] | undefined,
lineNumber: number
): number => {
if (!outline) {
return -1
}
let highestLine = -1
for (const section of outline) {
if (section.line > lineNumber) {
return highestLine
}
highestLine = section.line
}
return highestLine
}

View File

@@ -0,0 +1,136 @@
import { createContext, useContext, useEffect } from 'react'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { useEditorContext } from '@/shared/context/editor-context'
import getMeta from '@/utils/meta'
import {
Permissions,
PermissionsLevel,
} from '@/features/ide-react/types/permissions'
import useScopeValue from '@/shared/hooks/use-scope-value'
import { DeepReadonly } from '../../../../../types/utils'
import useViewerPermissions from '@/shared/hooks/use-viewer-permissions'
import { useProjectContext } from '@/shared/context/project-context'
export const PermissionsContext = createContext<Permissions | undefined>(
undefined
)
const permissionsMap: DeepReadonly<Record<PermissionsLevel, Permissions>> = {
readOnly: {
read: true,
comment: true,
resolveOwnComments: false,
resolveAllComments: false,
trackedWrite: false,
write: false,
admin: false,
labelVersion: false,
},
review: {
read: true,
comment: true,
resolveOwnComments: true,
resolveAllComments: false,
trackedWrite: true,
write: false,
admin: false,
labelVersion: true,
},
readAndWrite: {
read: true,
comment: true,
resolveOwnComments: true,
resolveAllComments: true,
trackedWrite: true,
write: true,
admin: false,
labelVersion: true,
},
owner: {
read: true,
comment: true,
resolveOwnComments: true,
resolveAllComments: true,
trackedWrite: true,
write: true,
admin: true,
labelVersion: true,
},
}
const anonymousPermissionsMap: typeof permissionsMap = {
readOnly: { ...permissionsMap.readOnly, comment: false },
readAndWrite: { ...permissionsMap.readAndWrite, comment: false },
review: { ...permissionsMap.review, comment: false },
owner: { ...permissionsMap.owner, comment: false },
}
const linkSharingWarningPermissionsMap: typeof permissionsMap = {
readOnly: { ...permissionsMap.readOnly, comment: false },
readAndWrite: permissionsMap.readAndWrite,
review: permissionsMap.review,
owner: permissionsMap.owner,
}
const noTrackChangesPermissionsMap: typeof permissionsMap = {
readOnly: permissionsMap.readOnly,
readAndWrite: permissionsMap.readAndWrite,
review: { ...permissionsMap.review, trackedWrite: false },
owner: permissionsMap.owner,
}
export const PermissionsProvider: React.FC = ({ children }) => {
const [permissions, setPermissions] =
useScopeValue<Readonly<Permissions>>('permissions')
const { connectionState } = useConnectionContext()
const { permissionsLevel } = useEditorContext() as {
permissionsLevel: PermissionsLevel
}
const hasViewerPermissions = useViewerPermissions()
const anonymous = getMeta('ol-anonymous')
const project = useProjectContext()
useEffect(() => {
let activePermissionsMap
if (hasViewerPermissions) {
activePermissionsMap = linkSharingWarningPermissionsMap
} else if (anonymous) {
activePermissionsMap = anonymousPermissionsMap
} else if (!project.features.trackChanges) {
activePermissionsMap = noTrackChangesPermissionsMap
} else {
activePermissionsMap = permissionsMap
}
setPermissions(activePermissionsMap[permissionsLevel])
}, [
anonymous,
permissionsLevel,
setPermissions,
hasViewerPermissions,
project.features.trackChanges,
])
useEffect(() => {
if (connectionState.forceDisconnected) {
setPermissions(prevState => ({ ...prevState, write: false }))
}
}, [connectionState.forceDisconnected, setPermissions])
return (
<PermissionsContext.Provider value={permissions}>
{children}
</PermissionsContext.Provider>
)
}
export function usePermissionsContext() {
const context = useContext(PermissionsContext)
if (!context) {
throw new Error(
'usePermissionsContext is only available inside PermissionsProvider'
)
}
return context
}

View File

@@ -0,0 +1,122 @@
import { FC } from 'react'
import { ChatProvider } from '@/features/chat/context/chat-context'
import { ConnectionProvider } from './connection-context'
import { DetachCompileProvider } from '@/shared/context/detach-compile-context'
import { DetachProvider } from '@/shared/context/detach-context'
import { EditorManagerProvider } from '@/features/ide-react/context/editor-manager-context'
import { EditorProvider } from '@/shared/context/editor-context'
import { FileTreeDataProvider } from '@/shared/context/file-tree-data-context'
import { FileTreeOpenProvider } from '@/features/ide-react/context/file-tree-open-context'
import { FileTreePathProvider } from '@/features/file-tree/contexts/file-tree-path'
import { IdeReactProvider } from '@/features/ide-react/context/ide-react-context'
import { LayoutProvider } from '@/shared/context/layout-context'
import { LocalCompileProvider } from '@/shared/context/local-compile-context'
import { MetadataProvider } from '@/features/ide-react/context/metadata-context'
import { ModalsContextProvider } from '@/features/ide-react/context/modals-context'
import { OnlineUsersProvider } from '@/features/ide-react/context/online-users-context'
import { OutlineProvider } from '@/features/ide-react/context/outline-context'
import { PermissionsProvider } from '@/features/ide-react/context/permissions-context'
import { ProjectProvider } from '@/shared/context/project-context'
import { RailProvider } from '@/features/ide-redesign/contexts/rail-context'
import { ProjectSettingsProvider } from '@/features/editor-left-menu/context/project-settings-context'
import { ReferencesProvider } from '@/features/ide-react/context/references-context'
import { SnapshotProvider } from '@/features/ide-react/context/snapshot-context'
import { SplitTestProvider } from '@/shared/context/split-test-context'
import { UserProvider } from '@/shared/context/user-context'
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
import { IdeRedesignSwitcherProvider } from './ide-redesign-switcher-context'
import { CommandRegistryProvider } from './command-registry-context'
export const ReactContextRoot: FC<{ providers?: Record<string, FC> }> = ({
children,
providers = {},
}) => {
const Providers = {
ChatProvider,
ConnectionProvider,
DetachCompileProvider,
DetachProvider,
EditorManagerProvider,
EditorProvider,
FileTreeDataProvider,
FileTreeOpenProvider,
FileTreePathProvider,
IdeReactProvider,
LayoutProvider,
LocalCompileProvider,
MetadataProvider,
ModalsContextProvider,
OnlineUsersProvider,
OutlineProvider,
PermissionsProvider,
ProjectProvider,
ProjectSettingsProvider,
RailProvider,
ReferencesProvider,
SnapshotProvider,
SplitTestProvider,
UserProvider,
UserSettingsProvider,
IdeRedesignSwitcherProvider,
CommandRegistryProvider,
...providers,
}
return (
<Providers.SplitTestProvider>
<Providers.ModalsContextProvider>
<Providers.ConnectionProvider>
<Providers.IdeReactProvider>
<Providers.UserProvider>
<Providers.UserSettingsProvider>
<Providers.ProjectProvider>
<Providers.SnapshotProvider>
<Providers.FileTreeDataProvider>
<Providers.FileTreePathProvider>
<Providers.ReferencesProvider>
<Providers.DetachProvider>
<Providers.EditorProvider>
<Providers.PermissionsProvider>
<Providers.RailProvider>
<Providers.LayoutProvider>
<Providers.ProjectSettingsProvider>
<Providers.EditorManagerProvider>
<Providers.LocalCompileProvider>
<Providers.DetachCompileProvider>
<Providers.ChatProvider>
<Providers.FileTreeOpenProvider>
<Providers.OnlineUsersProvider>
<Providers.MetadataProvider>
<Providers.OutlineProvider>
<Providers.IdeRedesignSwitcherProvider>
<Providers.CommandRegistryProvider>
{children}
</Providers.CommandRegistryProvider>
</Providers.IdeRedesignSwitcherProvider>
</Providers.OutlineProvider>
</Providers.MetadataProvider>
</Providers.OnlineUsersProvider>
</Providers.FileTreeOpenProvider>
</Providers.ChatProvider>
</Providers.DetachCompileProvider>
</Providers.LocalCompileProvider>
</Providers.EditorManagerProvider>
</Providers.ProjectSettingsProvider>
</Providers.LayoutProvider>
</Providers.RailProvider>
</Providers.PermissionsProvider>
</Providers.EditorProvider>
</Providers.DetachProvider>
</Providers.ReferencesProvider>
</Providers.FileTreePathProvider>
</Providers.FileTreeDataProvider>
</Providers.SnapshotProvider>
</Providers.ProjectProvider>
</Providers.UserSettingsProvider>
</Providers.UserProvider>
</Providers.IdeReactProvider>
</Providers.ConnectionProvider>
</Providers.ModalsContextProvider>
</Providers.SplitTestProvider>
)
}

View File

@@ -0,0 +1,155 @@
import { generateSHA1Hash } from '../../../shared/utils/sha1'
import {
createContext,
useContext,
useEffect,
FC,
useCallback,
useMemo,
useState,
} from 'react'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { postJSON } from '@/infrastructure/fetch-json'
import { ShareJsDoc } from '@/features/ide-react/editor/share-js-doc'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { findDocEntityById } from '@/features/ide-react/util/find-doc-entity-by-id'
import { IdeEvents } from '@/features/ide-react/create-ide-event-emitter'
import { debugConsole } from '@/utils/debugging'
import useEventListener from '@/shared/hooks/use-event-listener'
export const ReferencesContext = createContext<
| {
referenceKeys: Set<string>
indexAllReferences: (shouldBroadcast: boolean) => Promise<void>
}
| undefined
>(undefined)
export const ReferencesProvider: FC = ({ children }) => {
const { fileTreeData } = useFileTreeData()
const { eventEmitter, projectId } = useIdeReactContext()
const { socket } = useConnectionContext()
const [referenceKeys, setReferenceKeys] = useState(new Set<string>())
const [existingIndexHash, setExistingIndexHash] = useState<
Record<string, { hash: string; timestamp: number }>
>({})
const indexAllReferences = useCallback(
async (shouldBroadcast: boolean) => {
return postJSON(`/project/${projectId}/references/indexAll`, {
body: {
shouldBroadcast,
},
})
.then((response: { keys: string[] }) => {
setReferenceKeys(new Set(response.keys))
})
.catch(error => {
// allow the request to fail
debugConsole.error(error)
})
},
[projectId]
)
const indexReferencesIfDocModified = useCallback(
(doc: ShareJsDoc, shouldBroadcast: boolean) => {
// avoid reindexing references if the bib file has not changed since the
// last time they were indexed
const docId = doc.doc_id
const snapshot = doc._doc.snapshot
const now = Date.now()
const sha1 = generateSHA1Hash(
'blob ' + snapshot.length + '\x00' + snapshot
)
const CACHE_LIFETIME = 6 * 3600 * 1000 // allow reindexing every 6 hours
const cacheEntry = existingIndexHash[docId]
const isCached =
cacheEntry &&
cacheEntry.timestamp > now - CACHE_LIFETIME &&
cacheEntry.hash === sha1
if (!isCached) {
indexAllReferences(shouldBroadcast)
setExistingIndexHash(existingIndexHash => ({
...existingIndexHash,
[docId]: { hash: sha1, timestamp: now },
}))
}
},
[existingIndexHash, indexAllReferences]
)
useEffect(() => {
const handleDocClosed = ({
detail: [doc],
}: CustomEvent<IdeEvents['document:closed']>) => {
if (
doc.doc_id &&
findDocEntityById(fileTreeData, doc.doc_id)?.name?.endsWith('.bib')
) {
indexReferencesIfDocModified(doc, true)
}
}
eventEmitter.on('document:closed', handleDocClosed)
return () => {
eventEmitter.off('document:closed', handleDocClosed)
}
}, [eventEmitter, fileTreeData, indexReferencesIfDocModified])
useEventListener(
'reference:added',
useCallback(() => {
indexAllReferences(true)
}, [indexAllReferences])
)
useEffect(() => {
const handleProjectJoined = () => {
// We only need to grab the references when the editor first loads,
// not on every reconnect
socket.on('references:keys:updated', (keys, allDocs) => {
setReferenceKeys(oldKeys =>
allDocs ? new Set(keys) : new Set([...oldKeys, ...keys])
)
})
indexAllReferences(false)
}
eventEmitter.once('project:joined', handleProjectJoined)
return () => {
eventEmitter.off('project:joined', handleProjectJoined)
}
}, [eventEmitter, indexAllReferences, socket])
const value = useMemo(
() => ({
referenceKeys,
indexAllReferences,
}),
[indexAllReferences, referenceKeys]
)
return (
<ReferencesContext.Provider value={value}>
{children}
</ReferencesContext.Provider>
)
}
export function useReferencesContext() {
const context = useContext(ReferencesContext)
if (!context) {
throw new Error(
'useReferencesContext is only available inside ReferencesProvider'
)
}
return context
}

View File

@@ -0,0 +1,127 @@
import {
createContext,
FC,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { Snapshot } from 'overleaf-editor-core'
import { useProjectContext } from '@/shared/context/project-context'
import { debugConsole } from '@/utils/debugging'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { Folder } from '../../../../../types/folder'
export const StubSnapshotUtils = {
SnapshotUpdater: class SnapshotUpdater {
// eslint-disable-next-line no-useless-constructor
constructor(readonly projectId: string) {}
refresh(): Promise<{ snapshot: Snapshot; snapshotVersion: number }> {
throw new Error('not implemented')
}
abort(): void {
throw new Error('not implemented')
}
},
buildFileTree(snapshot: Snapshot): Folder {
throw new Error('not implemented')
},
createFolder(_id: string, name: string): Folder {
throw new Error('not implemented')
},
}
const { SnapshotUpdater } =
(importOverleafModules('snapshotUtils')[0]
?.import as typeof StubSnapshotUtils) || StubSnapshotUtils
export type SnapshotLoadingState = '' | 'loading' | 'error'
export const SnapshotContext = createContext<
| {
snapshotVersion: number
snapshot?: Snapshot
snapshotLoadingState: SnapshotLoadingState
fileTreeFromHistory: boolean
setFileTreeFromHistory: (v: boolean) => void
}
| undefined
>(undefined)
export const SnapshotProvider: FC = ({ children }) => {
const { _id: projectId } = useProjectContext()
const [snapshotLoadingState, setSnapshotLoadingState] =
useState<SnapshotLoadingState>('')
const [snapshotUpdater] = useState(() => new SnapshotUpdater(projectId))
const [snapshot, setSnapshot] = useState<Snapshot>()
const [snapshotVersion, setSnapshotVersion] = useState(-1)
const [fileTreeFromHistory, setFileTreeFromHistory] = useState(false)
useEffect(() => {
if (!fileTreeFromHistory) return
let stop = false
let handle: number
const refresh = () => {
setSnapshotLoadingState('loading')
snapshotUpdater
.refresh()
.then(({ snapshot, snapshotVersion }) => {
setSnapshot(snapshot)
setSnapshotVersion(snapshotVersion)
setSnapshotLoadingState('')
})
.catch(err => {
debugConsole.error(err)
setSnapshotLoadingState('error')
})
.finally(() => {
if (stop) return
// use a chain of timeouts to avoid concurrent updates
handle = window.setTimeout(refresh, 30_000)
})
}
refresh()
return () => {
stop = true
snapshotUpdater.abort()
clearInterval(handle)
}
}, [projectId, fileTreeFromHistory, snapshotUpdater])
const value = useMemo(
() => ({
snapshot,
snapshotVersion,
snapshotLoadingState,
fileTreeFromHistory,
setFileTreeFromHistory,
}),
[
snapshot,
snapshotVersion,
snapshotLoadingState,
fileTreeFromHistory,
setFileTreeFromHistory,
]
)
return (
<SnapshotContext.Provider value={value}>
{children}
</SnapshotContext.Provider>
)
}
export function useSnapshotContext() {
const context = useContext(SnapshotContext)
if (!context) {
throw new Error(
'useSnapshotContext is only available within SnapshotProvider'
)
}
return context
}

View File

@@ -0,0 +1,56 @@
import { Project } from '../../../../types/project'
import { PermissionsLevel } from '@/features/ide-react/types/permissions'
import { ShareJsDoc } from '@/features/ide-react/editor/share-js-doc'
import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
import { GotoOffsetOptions } from '@/features/ide-react/context/editor-manager-context'
import { CursorPosition } from '@/features/ide-react/types/cursor-position'
import { FileTreeFindResult } from '@/features/ide-react/types/file-tree'
export type IdeEvents = {
'project:joined': [{ project: Project; permissionsLevel: PermissionsLevel }]
'document:closed': [doc: ShareJsDoc]
'doc:changed': [{ doc_id: string }]
'doc:saved': [{ doc_id: string }]
'ide:opAcknowledged': [{ doc_id: string; op: any }]
'store-doc-position': []
'editor:gotoOffset': [options: GotoOffsetOptions]
'editor:gotoLine': [options: GotoLineOptions]
'cursor:editor:update': [position: CursorPosition]
'outline-toggled': [isOpen: boolean]
'cursor:editor:syncToPdf': []
'scroll:editor:update': [middleVisibleLine?: number]
'comment:start_adding': []
'history:toggle': []
'entity:deleted': [entity: FileTreeFindResult]
}
export class IdeEventEmitter extends EventTarget {
emit<T extends keyof IdeEvents>(eventName: T, ...detail: IdeEvents[T]) {
this.dispatchEvent(new CustomEvent<IdeEvents[T]>(eventName, { detail }))
}
on<T extends keyof IdeEvents>(
eventName: T,
listener: (event: CustomEvent<IdeEvents[T]>) => void
) {
this.addEventListener(eventName, listener as EventListener)
}
once<T extends keyof IdeEvents>(
eventName: T,
listener: (event: CustomEvent<IdeEvents[T]>) => void
) {
this.addEventListener(eventName, listener as EventListener, { once: true })
}
off<T extends keyof IdeEvents>(
eventName: T,
listener: (event: CustomEvent<IdeEvents[T]>) => void
) {
this.removeEventListener(eventName, listener as EventListener)
}
}
export function createIdeEventEmitter() {
return new IdeEventEmitter()
}

View File

@@ -0,0 +1,703 @@
/* eslint-disable camelcase */
// Migrated from services/web/frontend/js/ide/editor/Document.js
import RangesTracker from '@overleaf/ranges-tracker'
import { ShareJsDoc } from './share-js-doc'
import { debugConsole } from '@/utils/debugging'
import { Socket } from '@/features/ide-react/connection/types/socket'
import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
import { EditorFacade } from '@/features/source-editor/extensions/realtime'
import EditorWatchdogManager from '@/features/ide-react/connection/editor-watchdog-manager'
import EventEmitter from '@/utils/EventEmitter'
import {
AnyOperation,
Change,
CommentOperation,
EditOperation,
} from '../../../../../types/change'
import {
isCommentOperation,
isDeleteOperation,
isEditOperation,
isInsertOperation,
} from '@/utils/operations'
import { decodeUtf8 } from '@/utils/decode-utf8'
import {
ShareJsOperation,
TrackChangesIdSeeds,
} from '@/features/ide-react/editor/types/document'
import { ThreadId } from '../../../../../types/review-panel/review-panel'
import getMeta from '@/utils/meta'
const MAX_PENDING_OP_SIZE = 64
type JoinCallback = (error?: Error) => void
type LeaveCallback = JoinCallback
type Update =
| {
v: number
doc: string
}
| {
v: number
doc: string
op: AnyOperation[]
meta: {
type?: string
source: string
user_id: string
ts: number
}
hash?: string
lastV?: number
}
type Message = {
meta: {
tc: string
user_id: string
}
}
type ErrorMetadata = Record<string, any>
function getOpSize(op: AnyOperation) {
if (isInsertOperation(op)) {
return op.i.length
}
if (isDeleteOperation(op)) {
return op.d.length
}
return 0
}
function getShareJsOpSize(shareJsOp: ShareJsOperation) {
return shareJsOp.reduce((total, op) => total + getOpSize(op), 0)
}
// TODO: define these in RangesTracker
type _RangesTracker = Omit<RangesTracker, 'changes' | 'comments'> & {
changes: Change<EditOperation>[]
comments: Change<CommentOperation>[]
track_changes?: boolean
}
export type RangesTrackerWithResolvedThreadIds = _RangesTracker & {
resolvedThreadIds: Record<ThreadId, boolean>
}
export class DocumentContainer extends EventEmitter {
private connected: boolean
private wantToBeJoined = false
private chaosMonkeyTimer: number | null = null
public track_changes_as: string | null = null
private joinCallbacks: JoinCallback[] = []
private leaveCallbacks: LeaveCallback[] = []
doc?: ShareJsDoc
cm6?: EditorFacade
oldInflightOp?: ShareJsOperation
ranges?: _RangesTracker | RangesTrackerWithResolvedThreadIds
joined = false
// This is set and read in useCodeMirrorScope
docName = ''
constructor(
readonly doc_id: string,
readonly socket: Socket,
private readonly globalEditorWatchdogManager: EditorWatchdogManager,
private readonly ideEventEmitter: IdeEventEmitter,
private readonly detachDoc: (docId: string, doc: DocumentContainer) => void
) {
super()
this.connected = this.socket.socket.connected
this.bindToEditorEvents()
this.bindToSocketEvents()
}
attachToCM6(cm6: EditorFacade) {
this.cm6 = cm6
if (this.doc) {
this.doc.attachToCM6(this.cm6)
}
this.cm6.on('change', this.checkConsistency)
}
detachFromCM6() {
if (this.doc) {
this.doc.detachFromCM6()
}
if (this.cm6) {
this.cm6.off('change', this.checkConsistency)
}
delete this.cm6
this.clearChaosMonkey()
if (this.doc) {
this.ideEventEmitter.emit('document:closed', this.doc)
}
}
submitOp(...ops: AnyOperation[]) {
this.doc?.submitOp(ops)
}
private checkConsistency = (editor: EditorFacade) => {
// We've been seeing a lot of errors when I think there shouldn't be
// any, which may be related to this check happening before the change is
// applied. If we use a timeout, hopefully we can reduce this.
window.setTimeout(() => {
const editorValue = editor?.getValue()
const sharejsValue = this.doc?.getSnapshot()
if (editorValue !== sharejsValue) {
return this.onError(
new Error('Editor text does not match server text'),
{},
editorValue
)
}
}, 0)
}
getSnapshot() {
return this.doc?.getSnapshot()
}
getType() {
return this.doc?.getType()
}
getInflightOp(): ShareJsOperation | undefined {
return this.doc?.getInflightOp()
}
getPendingOp(): ShareJsOperation | undefined {
return this.doc?.getPendingOp()
}
getRecentAck() {
return this.doc?.getRecentAck()
}
getInflightOpCreatedAt() {
return this.doc?.getInflightOpCreatedAt()
}
getPendingOpCreatedAt() {
return this.doc?.getPendingOpCreatedAt()
}
hasBufferedOps() {
return this.doc?.hasBufferedOps()
}
setTrackingChanges(track_changes: boolean) {
if (this.doc) {
this.doc.track_changes = track_changes
}
}
getTrackingChanges() {
return !!this.doc?.track_changes
}
setTrackChangesIdSeeds(id_seeds: TrackChangesIdSeeds) {
if (this.doc) {
this.doc.track_changes_id_seeds = id_seeds
}
}
private onUpdateAppliedHandler = (update: any) => this.onUpdateApplied(update)
private onErrorHandler = (error: Error, message: ErrorMetadata) => {
// 'otUpdateError' are emitted per doc socket.io room, hence we can be
// sure that message.doc_id exists.
if (message.doc_id !== this.doc_id) {
// This error is for another doc. Do not action it. We could open
// a modal that has the wrong context on it.
return
}
this.onError(error, message)
}
private onDisconnectHandler = () => this.onDisconnect()
private bindToSocketEvents() {
this.socket.on('otUpdateApplied', this.onUpdateAppliedHandler)
this.socket.on('otUpdateError', this.onErrorHandler)
return this.socket.on('disconnect', this.onDisconnectHandler)
}
private unBindFromSocketEvents() {
this.socket.removeListener('otUpdateApplied', this.onUpdateAppliedHandler)
this.socket.removeListener('otUpdateError', this.onErrorHandler)
return this.socket.removeListener('disconnect', this.onDisconnectHandler)
}
private bindToEditorEvents() {
this.ideEventEmitter.on('project:joined', this.onReconnect)
}
private unBindFromEditorEvents() {
this.ideEventEmitter.off('project:joined', this.onReconnect)
}
leaveAndCleanUp(cb?: (error?: Error) => void) {
return this.leave((error?: Error) => {
this.cleanUp()
if (cb) cb(error)
})
}
leaveAndCleanUpPromise() {
return new Promise<void>((resolve, reject) => {
this.leaveAndCleanUp((error?: Error) => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}
join(callback?: JoinCallback) {
this.wantToBeJoined = true
this.cancelLeave()
if (this.connected) {
this.joinDoc(callback)
} else if (callback) {
this.joinCallbacks.push(callback)
}
}
leave(callback?: LeaveCallback) {
this.flush() // force an immediate flush when leaving document
this.wantToBeJoined = false
this.cancelJoin()
if (this.doc?.hasBufferedOps()) {
debugConsole.log(
'[leave] Doc has buffered ops, pushing callback for later'
)
if (callback) {
this.leaveCallbacks.push(callback)
}
} else if (!this.connected) {
debugConsole.log('[leave] Not connected, returning now')
callback?.()
} else {
debugConsole.log('[leave] Leaving now')
this.leaveDoc(callback)
}
}
flush() {
return this.doc?.flushPendingOps()
}
chaosMonkey(line = 0, char = 'a') {
const orig = char
let copy: string | null = null
let pos = 0
const timer = () => {
if (copy == null || !copy.length) {
copy = orig.slice() + ' ' + new Date() + '\n'
line += Math.random() > 0.1 ? 1 : -2
if (line < 0) {
line = 0
}
pos = 0
}
char = copy[0]
copy = copy.slice(1)
if (this.cm6) {
this.cm6.view.dispatch({
changes: {
from: Math.min(pos, this.cm6.view.state.doc.length),
insert: char,
},
})
}
pos += 1
this.chaosMonkeyTimer = window.setTimeout(
timer,
100 + (Math.random() < 0.1 ? 1000 : 0)
)
}
timer()
}
clearChaosMonkey() {
const timer = this.chaosMonkeyTimer
if (timer) {
this.chaosMonkeyTimer = null
window.clearTimeout(timer)
}
}
pollSavedStatus() {
// returns false if doc has ops waiting to be acknowledged or
// sent that haven't changed since the last time we checked.
// Otherwise returns true.
let saved
const inflightOp = this.getInflightOp()
const pendingOp = this.getPendingOp()
const recentAck = this.getRecentAck()
const pendingOpSize = pendingOp ? getShareJsOpSize(pendingOp) : 0
if (inflightOp == null && pendingOp == null) {
// There's nothing going on, this is OK.
saved = true
debugConsole.log('[pollSavedStatus] no inflight or pending ops')
} else if (inflightOp && inflightOp === this.oldInflightOp) {
// The same inflight op has been sitting unacked since we
// last checked, this is bad.
saved = false
debugConsole.log('[pollSavedStatus] inflight op is same as before')
} else if (
pendingOp != null &&
recentAck &&
pendingOpSize < MAX_PENDING_OP_SIZE
) {
// There is an op waiting to go to server but it is small and
// within the recent ack limit, this is OK for now.
saved = true
debugConsole.log(
'[pollSavedStatus] pending op (small with recent ack) assume ok',
pendingOp,
pendingOpSize
)
} else {
// In any other situation, assume the document is unsaved.
saved = false
debugConsole.log(
`[pollSavedStatus] assuming not saved (inflightOp?: ${
inflightOp != null
}, pendingOp?: ${pendingOp != null})`
)
}
this.oldInflightOp = inflightOp
return saved
}
private cancelLeave() {
this.leaveCallbacks = []
}
private cancelJoin() {
this.joinCallbacks = []
}
private onUpdateApplied(update: Update) {
if (update?.doc === this.doc_id && this.doc != null) {
// FIXME: change this back to processUpdateFromServer when redis fixed
this.doc.processUpdateFromServerInOrder(update)
if (!this.wantToBeJoined) {
return this.leave()
}
}
}
private onDisconnect() {
debugConsole.log('[onDisconnect] disconnecting')
this.connected = false
this.joined = false
return this.doc != null
? this.doc.updateConnectionState('disconnected')
: undefined
}
private onReconnect = () => {
debugConsole.log('[onReconnect] reconnected (joined project)')
this.connected = true
if (this.wantToBeJoined || this.doc?.hasBufferedOps()) {
debugConsole.log(
`[onReconnect] Rejoining (wantToBeJoined: ${
this.wantToBeJoined
} OR hasBufferedOps: ${this.doc?.hasBufferedOps()})`
)
this.joinDoc((error?: Error) => {
if (error) {
this.onError(error)
return
}
this.doc?.updateConnectionState('ok')
this.doc?.flushPendingOps()
this.callJoinCallbacks()
})
}
}
private callJoinCallbacks() {
for (const callback of this.joinCallbacks) {
callback()
}
this.joinCallbacks = []
}
private joinDoc(callback?: JoinCallback) {
if (this.doc) {
return this.socket.emit(
'joinDoc',
this.doc_id,
this.doc.getVersion(),
{ encodeRanges: true, age: this.doc.getTimeSinceLastServerActivity() },
(error, docLines, version, updates, ranges) => {
if (error) {
callback?.(error)
return
}
this.joined = true
this.doc?.catchUp(updates)
this.decodeRanges(ranges)
this.catchUpRanges(ranges?.changes, ranges?.comments)
callback?.()
}
)
} else {
this.socket.emit(
'joinDoc',
this.doc_id,
{ encodeRanges: true },
(error, docLines, version, updates, ranges) => {
if (error) {
callback?.(error)
return
}
this.joined = true
this.doc = new ShareJsDoc(
this.doc_id,
docLines,
version,
this.socket,
this.globalEditorWatchdogManager,
this.ideEventEmitter
)
this.decodeRanges(ranges)
this.ranges = new RangesTracker(ranges?.changes, ranges?.comments)
this.bindToShareJsDocEvents()
callback?.()
}
)
}
}
private decodeRanges(ranges: RangesTracker) {
try {
if (ranges.changes) {
for (const change of ranges.changes) {
if (isInsertOperation(change.op)) {
change.op.i = decodeUtf8(change.op.i)
}
if (isDeleteOperation(change.op)) {
change.op.d = decodeUtf8(change.op.d)
}
}
}
return (() => {
if (!ranges.comments) {
return []
}
return ranges.comments.map((comment: Change<CommentOperation>) =>
comment.op.c != null
? (comment.op.c = decodeUtf8(comment.op.c))
: undefined
)
})()
} catch (err) {
debugConsole.error(err)
}
}
private leaveDoc(callback?: LeaveCallback) {
debugConsole.log('[leaveDoc] Sending leaveDoc request')
this.socket.emit('leaveDoc', this.doc_id, error => {
if (error) {
callback?.(error)
return
}
this.joined = false
for (const leaveCallback of this.leaveCallbacks) {
debugConsole.log('[_leaveDoc] Calling buffered callback', leaveCallback)
leaveCallback(error)
}
this.leaveCallbacks = []
callback?.()
})
}
cleanUp() {
// if we arrive here from _onError the pending and inflight ops will have been cleared
if (this.hasBufferedOps()) {
debugConsole.log(
`[cleanUp] Document (${this.doc_id}) has buffered ops, refusing to remove from openDocs`
)
return // return immediately, do not unbind from events
}
this.detachDoc(this.doc_id, this)
this.unBindFromEditorEvents()
this.unBindFromSocketEvents()
}
private bindToShareJsDocEvents() {
if (!this.doc) {
return
}
this.doc.on('error', (error: Error, meta: ErrorMetadata) =>
this.onError(error, meta)
)
this.doc.on('externalUpdate', (update: Update) => {
return this.trigger('externalUpdate', update)
})
this.doc.on('remoteop', (...ops: AnyOperation[]) => {
return this.trigger('remoteop', ...ops)
})
this.doc.on('op:sent', (op: AnyOperation) => {
return this.trigger('op:sent')
})
this.doc.on('op:acknowledged', (op: AnyOperation) => {
this.ideEventEmitter.emit('ide:opAcknowledged', {
doc_id: this.doc_id,
op,
})
return this.trigger('op:acknowledged')
})
this.doc.on('op:timeout', (op: AnyOperation) => {
this.trigger('op:timeout')
return this.onError(new Error('op timed out'))
})
let docChangedTimeout: number | null = null
this.doc.on(
'change',
(ops: AnyOperation[], oldSnapshot: any, msg: Message) => {
this.applyOpsToRanges(ops, msg)
if (docChangedTimeout) {
window.clearTimeout(docChangedTimeout)
}
docChangedTimeout = window.setTimeout(() => {
if (ops.some(isEditOperation)) {
window.dispatchEvent(
new CustomEvent('doc:changed', { detail: { id: this.doc_id } })
)
this.ideEventEmitter.emit('doc:changed', {
doc_id: this.doc_id,
})
}
}, 50)
}
)
this.doc.on('flipped_pending_to_inflight', () => {
return this.trigger('flipped_pending_to_inflight')
})
let docSavedTimeout: number | null
this.doc.on('saved', () => {
if (docSavedTimeout) {
window.clearTimeout(docSavedTimeout)
}
docSavedTimeout = window.setTimeout(() => {
window.dispatchEvent(
new CustomEvent('doc:saved', { detail: { id: this.doc_id } })
)
this.ideEventEmitter.emit('doc:saved', { doc_id: this.doc_id })
}, 50)
})
}
private onError(
error: Error,
meta: ErrorMetadata = {},
editorContent?: string
) {
meta.doc_id = this.doc_id
debugConsole.log('ShareJS error', error, meta)
if (error.message === 'no project_id found on client') {
debugConsole.log('ignoring error, will wait to join project')
return
}
if (this.doc) {
this.doc.clearInflightAndPendingOps()
}
this.trigger('error', error, meta, editorContent)
// The clean-up should run after the error is triggered because the error triggers a
// disconnect. If we run the clean-up first, we remove our event handlers and miss
// the disconnect event, which means we try to leaveDoc when the connection comes back.
// This could interfere with the new connection of a new instance of this document.
this.cleanUp()
}
private applyOpsToRanges(ops: AnyOperation[], msg?: Message) {
let old_id_seed
let track_changes_as = null
const remote_op = msg != null
if (remote_op && msg?.meta.tc) {
old_id_seed = this.ranges!.getIdSeed()
this.ranges!.setIdSeed(msg.meta.tc)
track_changes_as = msg.meta.user_id
} else if (!remote_op && this.track_changes_as != null) {
track_changes_as = this.track_changes_as
}
this.ranges!.track_changes = track_changes_as != null
for (const op of this.filterOps(ops)) {
this.ranges!.applyOp(op, { user_id: track_changes_as })
}
if (old_id_seed != null) {
this.ranges!.setIdSeed(old_id_seed)
}
if (remote_op) {
// With remote ops, the editor hasn't been updated when we receive this
// op, so defer updating track changes until it has
return window.setTimeout(() => this.emit('ranges:dirty'))
} else {
return this.emit('ranges:dirty')
}
}
private catchUpRanges(
changes: Change<EditOperation>[],
comments: Change<CommentOperation>[]
) {
// We've just been given the current server's ranges, but need to apply any local ops we have.
// Reset to the server state then apply our local ops again.
if (changes == null) {
changes = []
}
if (comments == null) {
comments = []
}
this.emit('ranges:clear')
this.ranges!.changes = changes
this.ranges!.comments = comments
this.ranges!.track_changes = this.doc?.track_changes ?? false
for (const op of this.filterOps(this.doc?.getInflightOp() || [])) {
this.ranges!.setIdSeed(this.doc?.track_changes_id_seeds?.inflight)
this.ranges!.applyOp(op, { user_id: this.track_changes_as })
}
for (const op of this.filterOps(this.doc?.getPendingOp() || [])) {
this.ranges!.setIdSeed(this.doc?.track_changes_id_seeds?.pending)
this.ranges!.applyOp(op, { user_id: this.track_changes_as })
}
return this.emit('ranges:redraw')
}
private filterOps(ops: AnyOperation[]) {
// Read-only token users can't see/edit comment, so we filter out comment
// ops to avoid highlighting comment ranges.
if (getMeta('ol-isRestrictedTokenMember')) {
return ops.filter(op => !isCommentOperation(op))
} else {
return ops
}
}
}

View File

@@ -0,0 +1,16 @@
import { debugConsole } from '@/utils/debugging'
type EditorEvent = { type: string; meta: unknown; date: Date }
// Record events and then do nothing with them.
export class EventLog {
private recentEvents: EditorEvent[] = []
pushEvent = (type: string, meta: unknown = {}) => {
debugConsole.log('event', type, meta)
this.recentEvents.push({ type, meta, date: new Date() })
if (this.recentEvents.length > 100) {
return this.recentEvents.shift()
}
}
}

View File

@@ -0,0 +1,126 @@
// Migrated from static methods of Document in Document.js
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
import { debugConsole } from '@/utils/debugging'
import { Socket } from '@/features/ide-react/connection/types/socket'
import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
import EditorWatchdogManager from '@/features/ide-react/connection/editor-watchdog-manager'
export class OpenDocuments {
private openDocs = new Map<string, DocumentContainer>()
// eslint-disable-next-line no-useless-constructor
constructor(
private readonly socket: Socket,
private readonly globalEditorWatchdogManager: EditorWatchdogManager,
private readonly events: IdeEventEmitter
) {}
getDocument(docId: string) {
// Try to clean up existing docs before reopening them. If the doc has no
// buffered ops then it will be deleted by _cleanup() and a new instance
// of the document created below. This prevents us trying to follow the
// joinDoc:existing code path on an existing doc that doesn't have any
// local changes and getting an error if its version is too old.
if (this.openDocs.has(docId)) {
debugConsole.log(
`[getDocument] Cleaning up existing document instance for ${docId}`
)
this.openDocs.get(docId)?.cleanUp()
}
if (!this.openDocs.has(docId)) {
debugConsole.log(
`[getDocument] Creating new document instance for ${docId}`
)
this.createDoc(docId)
} else {
debugConsole.log(
`[getDocument] Returning existing document instance for ${docId}`
)
}
return this.openDocs.get(docId)
}
private createDoc(docId: string) {
const doc = new DocumentContainer(
docId,
this.socket,
this.globalEditorWatchdogManager,
this.events,
this.detachDoc.bind(this)
)
this.openDocs.set(docId, doc)
}
detachDoc(docId: string, doc: DocumentContainer) {
if (this.openDocs.get(docId) === doc) {
debugConsole.log(
`[detach] Removing document with ID (${docId}) from openDocs`
)
this.openDocs.delete(docId)
} else {
// It's possible that this instance has error, and the doc has been reloaded.
// This creates a new instance in Document.openDoc with the same id. We shouldn't
// clear it because it's not this instance.
debugConsole.log(
`[_cleanUp] New instance of (${docId}) created. Not removing`
)
}
}
hasUnsavedChanges() {
for (const doc of this.openDocs.values()) {
if (doc.hasBufferedOps()) {
return true
}
}
return false
}
flushAll() {
for (const doc of this.openDocs.values()) {
doc.flush()
}
}
unsavedDocs() {
const docs = []
for (const doc of this.openDocs.values()) {
if (!doc.pollSavedStatus()) {
docs.push(doc)
}
}
return docs
}
async awaitBufferedOps(signal: AbortSignal) {
if (this.hasUnsavedChanges()) {
const { promise, resolve } = Promise.withResolvers<void>()
let resolved = false
const listener = () => {
if (!this.hasUnsavedChanges()) {
debugConsole.log('saved')
window.removeEventListener('doc:saved', listener)
resolved = true
resolve()
}
}
window.addEventListener('doc:saved', listener)
signal.addEventListener('abort', () => {
if (!resolved) {
debugConsole.log('aborted')
window.removeEventListener('doc:saved', listener)
resolve()
}
})
this.flushAll()
await promise
}
}
}

View File

@@ -0,0 +1,446 @@
/* eslint-disable camelcase */
// Migrated from services/web/frontend/js/ide/editor/ShareJsDoc.js
import EventEmitter from '../../../utils/EventEmitter'
import { Doc } from '@/vendor/libs/sharejs'
import { Socket } from '@/features/ide-react/connection/types/socket'
import { debugConsole } from '@/utils/debugging'
import { decodeUtf8 } from '@/utils/decode-utf8'
import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
import EditorWatchdogManager from '@/features/ide-react/connection/editor-watchdog-manager'
import {
Message,
ShareJsConnectionState,
ShareJsOperation,
TrackChangesIdSeeds,
} from '@/features/ide-react/editor/types/document'
import { EditorFacade } from '@/features/source-editor/extensions/realtime'
import { recordDocumentFirstChangeEvent } from '@/features/event-tracking/document-first-change-event'
import getMeta from '@/utils/meta'
// All times below are in milliseconds
const SINGLE_USER_FLUSH_DELAY = 2000
const MULTI_USER_FLUSH_DELAY = 500
const INFLIGHT_OP_TIMEOUT = 5000 // Retry sending ops after 5 seconds without an ack
const WAIT_FOR_CONNECTION_TIMEOUT = 500
const FATAL_OP_TIMEOUT = 45000
const RECENT_ACK_LIMIT = 2 * SINGLE_USER_FLUSH_DELAY
type Update = Record<string, any>
type Connection = {
send: (update: Update) => void
state: ShareJsConnectionState
id: string
}
export class ShareJsDoc extends EventEmitter {
type: string
track_changes = false
track_changes_id_seeds: TrackChangesIdSeeds | null = null
connection: Connection
// @ts-ignore
_doc: Doc
private editorWatchdogManager: EditorWatchdogManager
private lastAcked: number | null = null
private pendingOpCreatedAt: number | null = null
private inflightOpCreatedAt: number | null = null
private queuedMessageTimer: number | null = null
private queuedMessages: Message[] = []
private detachEditorWatchdogManager: (() => void) | null = null
private _timeoutTimer: number | null = null
constructor(
readonly doc_id: string,
docLines: string[],
version: number,
readonly socket: Socket,
private readonly globalEditorWatchdogManager: EditorWatchdogManager,
private readonly eventEmitter: IdeEventEmitter
) {
super()
this.type = 'text'
// Decode any binary bits of data
const snapshot = docLines.map(line => decodeUtf8(line)).join('\n')
this.connection = {
send: (update: Update) => {
this.startInflightOpTimeout(update)
if (this.track_changes && this.track_changes_id_seeds) {
if (update.meta == null) {
update.meta = {}
}
update.meta.tc = this.track_changes_id_seeds.inflight
}
return this.socket.emit(
'applyOtUpdate',
this.doc_id,
update,
(error: Error) => {
if (error != null) {
this.handleError(error)
}
}
)
},
state: 'ok',
id: this.socket.publicId,
}
this._doc = new Doc(this.connection, this.doc_id, {
type: this.type,
})
this._doc.setFlushDelay(SINGLE_USER_FLUSH_DELAY)
this._doc.on('change', (...args: any[]) => {
const isRemote = args[3]
if (!isRemote && !this.pendingOpCreatedAt) {
debugConsole.log('set pendingOpCreatedAt', new Date())
this.pendingOpCreatedAt = performance.now()
}
return this.trigger('change', ...args)
})
this.editorWatchdogManager = new EditorWatchdogManager({
parent: globalEditorWatchdogManager,
})
this._doc.on('acknowledge', () => {
this.lastAcked = performance.now() // note time of last ack from server for an op we sent
this.inflightOpCreatedAt = null
debugConsole.log('unset inflightOpCreatedAt')
this.editorWatchdogManager.onAck() // keep track of last ack globally
return this.trigger('acknowledge')
})
this._doc.on('remoteop', (...args: any[]) => {
// As soon as we're working with a collaborator, start sending
// ops more frequently for low latency.
this._doc.setFlushDelay(MULTI_USER_FLUSH_DELAY)
return this.trigger('remoteop', ...args)
})
this._doc.on('flipped_pending_to_inflight', () => {
this.inflightOpCreatedAt = this.pendingOpCreatedAt
debugConsole.log('set inflightOpCreatedAt from pendingOpCreatedAt')
this.pendingOpCreatedAt = null
debugConsole.log('unset pendingOpCreatedAt')
return this.trigger('flipped_pending_to_inflight')
})
this._doc.on('saved', () => {
return this.trigger('saved')
})
this._doc.on('error', (e: Error) => {
return this.handleError(e)
})
this.bindToDocChanges(this._doc)
this.processUpdateFromServer({
open: true,
v: version,
snapshot,
})
this.removeCarriageReturnCharFromShareJsDoc()
}
private removeCarriageReturnCharFromShareJsDoc() {
const doc = this._doc
if (doc.snapshot.indexOf('\r') === -1) {
return
}
let nextPos
while ((nextPos = doc.snapshot.indexOf('\r')) !== -1) {
debugConsole.log('[ShareJsDoc] remove-carriage-return-char', nextPos)
doc.del(nextPos, 1)
}
}
submitOp(op: ShareJsOperation) {
this._doc.submitOp(op)
}
// The following code puts out of order messages into a queue
// so that they can be processed in order. This is a workaround
// for messages being delayed by redis cluster.
// FIXME: REMOVE THIS WHEN REDIS PUBSUB IS SENDING MESSAGES IN ORDER
private isAheadOfExpectedVersion(message: Message) {
return this._doc.version > 0 && message.v > this._doc.version
}
private pushOntoQueue(message: Message) {
debugConsole.log(`[processUpdate] push onto queue ${message.v}`)
// set a timer so that we never leave messages in the queue indefinitely
if (!this.queuedMessageTimer) {
this.queuedMessageTimer = window.setTimeout(() => {
debugConsole.log(`[processUpdate] queue timeout fired for ${message.v}`)
// force the message to be processed after the timeout,
// it will cause an error if the missing update has not arrived
this.processUpdateFromServer(message)
}, INFLIGHT_OP_TIMEOUT)
}
this.queuedMessages.push(message)
// keep the queue in order, lowest version first
this.queuedMessages.sort(function (a, b) {
return a.v - b.v
})
}
private clearQueue() {
this.queuedMessages = []
}
private processQueue() {
if (this.queuedMessages.length > 0) {
const nextAvailableVersion = this.queuedMessages[0].v
if (nextAvailableVersion > this._doc.version) {
// there are updates we still can't apply yet
} else {
// there's a version we can accept on the queue, apply it
debugConsole.log(
`[processUpdate] taken from queue ${nextAvailableVersion}`
)
const message = this.queuedMessages.shift()
if (message) {
this.processUpdateFromServerInOrder(message)
}
// clear the pending timer if the queue has now been cleared
if (this.queuedMessages.length === 0 && this.queuedMessageTimer) {
debugConsole.log('[processUpdate] queue is empty, cleared timeout')
window.clearTimeout(this.queuedMessageTimer)
this.queuedMessageTimer = null
}
}
}
}
// FIXME: This is the new method which reorders incoming updates if needed
// called from document.ts
processUpdateFromServerInOrder(message: Message) {
// Is this update ahead of the next expected update?
// If so, put it on a queue to be handled later.
if (this.isAheadOfExpectedVersion(message)) {
this.pushOntoQueue(message)
return // defer processing this update for now
}
const error = this.processUpdateFromServer(message)
if (
error instanceof Error &&
error.message === 'Invalid version from server'
) {
// if there was an error, abandon the queued updates ahead of this one
this.clearQueue()
return
}
// Do we have any messages queued up?
// find the next message if available
this.processQueue()
}
// FIXME: This is the original method. Switch back to this when redis
// issues are resolved.
processUpdateFromServer(message: Message) {
try {
this._doc._onMessage(message)
} catch (error) {
// Version mismatches are thrown as errors
debugConsole.log(error)
this.handleError(error)
return error // return the error for queue handling
}
if (message.meta?.type === 'external') {
return this.trigger('externalUpdate', message)
}
}
catchUp(updates: Message[]) {
return updates.map(update => {
update.v = this._doc.version
update.doc = this.doc_id
return this.processUpdateFromServer(update)
})
}
getSnapshot() {
return this._doc.snapshot as string | undefined
}
getVersion() {
return this._doc.version
}
getTimeSinceLastServerActivity() {
return Math.floor(performance.now() - this._doc.lastServerActivity)
}
getType() {
return this.type
}
clearInflightAndPendingOps() {
this.clearFatalTimeoutTimer()
this._doc.inflightOp = null
this._doc.inflightCallbacks = []
this._doc.pendingOp = null
return (this._doc.pendingCallbacks = [])
}
flushPendingOps() {
// This will flush any ops that are pending.
// If there is an inflight op it will do nothing.
return this._doc.flush()
}
updateConnectionState(state: ShareJsConnectionState) {
debugConsole.log(`[updateConnectionState] Setting state to ${state}`)
this.connection.state = state
this.connection.id = this.socket.publicId
this._doc.autoOpen = false
this._doc._connectionStateChanged(state)
this.lastAcked = null // reset the last ack time when connection changes
}
hasBufferedOps() {
return this._doc.inflightOp != null || this._doc.pendingOp != null
}
getInflightOp() {
return this._doc.inflightOp
}
getPendingOp() {
return this._doc.pendingOp
}
getRecentAck() {
// check if we have received an ack recently (within a factor of two of the single user flush delay)
return (
this.lastAcked !== null &&
performance.now() - this.lastAcked < RECENT_ACK_LIMIT
)
}
getInflightOpCreatedAt() {
return this.inflightOpCreatedAt
}
getPendingOpCreatedAt() {
return this.pendingOpCreatedAt
}
private attachEditorWatchdogManager(editor: EditorFacade) {
// end-to-end check for edits -> acks, for this very ShareJsdoc
// This will catch a broken connection and missing UX-blocker for the
// user, allowing them to keep editing.
this.detachEditorWatchdogManager =
this.editorWatchdogManager.attachToEditor(editor)
}
private attachToEditor(editor: EditorFacade, attachToShareJs: () => void) {
this.attachEditorWatchdogManager(editor)
attachToShareJs()
}
private maybeDetachEditorWatchdogManager() {
// a failed attach attempt may lead to a missing cleanup handler
if (this.detachEditorWatchdogManager) {
this.detachEditorWatchdogManager()
this.detachEditorWatchdogManager = null
}
}
attachToCM6(cm6: EditorFacade) {
this.attachToEditor(cm6, () => {
cm6.attachShareJs(this._doc, getMeta('ol-maxDocLength'))
})
}
detachFromCM6() {
this.maybeDetachEditorWatchdogManager()
if (this._doc.detach_cm6) {
this._doc.detach_cm6()
}
}
private startInflightOpTimeout(update: Update) {
this.startFatalTimeoutTimer(update)
const retryOp = () => {
// Only send the update again if inflightOp is still populated
// This can be cleared when hard reloading the document in which
// case we don't want to keep trying to send it.
debugConsole.log('[inflightOpTimeout] Trying op again')
if (this._doc.inflightOp != null) {
// When there is a socket.io disconnect, @_doc.inflightSubmittedIds
// is updated with the socket.io client id of the current op in flight
// (meta.source of the op).
// @connection.id is the client id of the current socket.io session.
// So we need both depending on whether the op was submitted before
// one or more disconnects, or if it was submitted during the current session.
update.dupIfSource = [
this.connection.id,
...Array.from(this._doc.inflightSubmittedIds),
]
// We must be joined to a project for applyOtUpdate to work on the real-time
// service, so don't send an op if we're not. Connection state is set to 'ok'
// when we've joined the project
if (this.connection.state !== 'ok') {
debugConsole.log(
'[inflightOpTimeout] Not connected, retrying in 0.5s'
)
window.setTimeout(retryOp, WAIT_FOR_CONNECTION_TIMEOUT)
} else {
debugConsole.log('[inflightOpTimeout] Sending')
return this.connection.send(update)
}
}
}
const timer = window.setTimeout(retryOp, INFLIGHT_OP_TIMEOUT)
return this._doc.inflightCallbacks.push(() => {
this.clearFatalTimeoutTimer()
window.clearTimeout(timer)
}) // 30 seconds
}
private startFatalTimeoutTimer(update: Update) {
// If an op doesn't get acked within FATAL_OP_TIMEOUT, something has
// gone unrecoverably wrong (the op will have been retried multiple times)
if (this._timeoutTimer != null) {
return
}
return (this._timeoutTimer = window.setTimeout(() => {
this.clearFatalTimeoutTimer()
return this.trigger('op:timeout', update)
}, FATAL_OP_TIMEOUT))
}
private clearFatalTimeoutTimer() {
if (this._timeoutTimer == null) {
return
}
clearTimeout(this._timeoutTimer)
return (this._timeoutTimer = null)
}
private handleError(error: unknown, meta = {}) {
return this.trigger('error', error, meta)
}
// @ts-ignore
private bindToDocChanges(doc: Doc) {
const { submitOp } = doc
doc.submitOp = (op: ShareJsOperation, callback?: () => void) => {
recordDocumentFirstChangeEvent()
this.trigger('op:sent', op)
doc.pendingCallbacks.push(() => {
return this.trigger('op:acknowledged', op)
})
return submitOp.call(doc, op, callback)
}
const { flush } = doc
doc.flush = () => {
this.trigger('flush', doc.inflightOp, doc.pendingOp, doc.version)
return flush.call(doc)
}
}
}

View File

@@ -0,0 +1,20 @@
import { AnyOperation } from '../../../../../../types/change'
export type Version = number
export type ShareJsConnectionState = 'ok' | 'disconnected' | 'stopped'
export type ShareJsOperation = AnyOperation[]
export type TrackChangesIdSeeds = { inflight: string; pending: string }
// TODO: check the properties of this type
export type Message = {
v: Version
open?: boolean
meta?: {
type?: string
}
doc?: string
snapshot?: string
}

View File

@@ -0,0 +1 @@
export type EditorType = 'cm6' | 'cm6-rich-text'

View File

@@ -0,0 +1,34 @@
import { useLayoutContext } from '@/shared/context/layout-context'
import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
import { useCallback, useRef, useState } from 'react'
import { ImperativePanelHandle } from 'react-resizable-panels'
export const useChatPane = () => {
const { chatIsOpen: isOpen, setChatIsOpen: setIsOpen } = useLayoutContext()
const [resizing, setResizing] = useState(false)
const panelRef = useRef<ImperativePanelHandle>(null)
useCollapsiblePanel(isOpen, panelRef)
const togglePane = useCallback(() => {
setIsOpen(value => !value)
}, [setIsOpen])
const handlePaneExpand = useCallback(() => {
setIsOpen(true)
}, [setIsOpen])
const handlePaneCollapse = useCallback(() => {
setIsOpen(false)
}, [setIsOpen])
return {
isOpen,
panelRef,
resizing,
setResizing,
togglePane,
handlePaneExpand,
handlePaneCollapse,
}
}

View File

@@ -0,0 +1,20 @@
import { RefObject, useEffect } from 'react'
import { ImperativePanelHandle } from 'react-resizable-panels'
export default function useCollapsiblePanel(
panelIsOpen: boolean,
panelRef: RefObject<ImperativePanelHandle>
) {
// collapse the panel when it is toggled closed (including on initial layout)
useEffect(() => {
const panelHandle = panelRef.current
if (panelHandle) {
if (panelIsOpen) {
panelHandle.expand()
} else {
panelHandle.collapse()
}
}
}, [panelIsOpen, panelRef])
}

View File

@@ -0,0 +1,21 @@
import { DependencyList, useEffect } from 'react'
import {
Command,
useCommandRegistry,
} from '../context/command-registry-context'
export const useCommandProvider = (
generateElements: () => Command[] | undefined,
dependencies: DependencyList
) => {
const { register, unregister } = useCommandRegistry()
useEffect(() => {
const elements = generateElements()
if (!elements) return
register(...elements)
return () => {
unregister(...elements.map(element => element.id))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies)
}

View File

@@ -0,0 +1,79 @@
import { useEffect, useMemo, useRef } from 'react'
import { DocumentContainer } from '../editor/document-container'
import { DocId } from '../../../../../types/project-settings'
import { debugConsole } from '@/utils/debugging'
import { diffChars } from 'diff'
const DIFF_TIMEOUT_MS = 5000
async function tryGetDiffSize(
currentContents: string | null | undefined,
projectId: string | null,
docId: DocId | null | undefined
): Promise<number | null> {
debugConsole.debug('tryGetDiffSize')
// If we don't know the current content or id, there's not much we can do
if (!projectId) {
debugConsole.debug('tryGetDiffSize: missing projectId')
return null
}
if (!currentContents) {
debugConsole.debug('tryGetDiffSize: missing currentContents')
return null
}
if (!docId) {
debugConsole.debug('tryGetDiffSize: missing docId')
return null
}
try {
const response = await fetch(
`/Project/${projectId}/doc/${docId}/download`,
{ signal: AbortSignal.timeout(DIFF_TIMEOUT_MS) }
)
const serverContent = await response.text()
const differences = diffChars(serverContent, currentContents)
let diffSize = 0
for (const diff of differences) {
if (diff.added || diff.removed) {
diffSize += diff.value.length
}
}
return diffSize
} catch {
// There's a good chance we're offline, so just return null
debugConsole.debug('tryGetDiffSize: fetch failed')
return null
}
}
export const useDebugDiffTracker = (
projectId: string,
currentDocument: DocumentContainer | null
) => {
const debugCurrentDocument = useRef<DocumentContainer | null>(null)
const debugProjectId = useRef<string | null>(null)
const debugTimers = useRef<Record<string, number>>({})
useEffect(() => {
debugCurrentDocument.current = currentDocument
}, [currentDocument])
useEffect(() => {
debugProjectId.current = projectId
}, [projectId])
const createDebugDiff = useMemo(
() => async () =>
await tryGetDiffSize(
debugCurrentDocument.current?.getSnapshot(),
debugProjectId.current,
debugCurrentDocument.current?.doc_id as DocId | undefined
),
[]
)
return {
createDebugDiff,
debugTimers,
}
}

View File

@@ -0,0 +1,80 @@
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { EditorType } from '@/features/ide-react/editor/types/editor-type'
import { putJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import { useCallback, useEffect, useRef } from 'react'
import useEventListener from '@/shared/hooks/use-event-listener'
import useDomEventListener from '@/shared/hooks/use-dom-event-listener'
function createEditingSessionHeartbeatData(editorType: EditorType) {
return {
editorType,
}
}
function sendEditingSessionHeartbeat(
projectId: string,
segmentation: Record<string, unknown>
) {
putJSON(`/editingSession/${projectId}`, {
body: { segmentation },
}).catch(debugConsole.error)
}
export function useEditingSessionHeartbeat() {
const { projectId } = useIdeReactContext()
const { getEditorType } = useEditorManagerContext()
// Keep track of how many heartbeats we've sent so that we can calculate how
// long to wait until the next one
const heartBeatsSentRef = useRef(0)
const heartBeatSentRecentlyRef = useRef(false)
const heartBeatResetTimerRef = useRef<number>()
useEffect(() => {
return () => {
window.clearTimeout(heartBeatResetTimerRef.current)
}
}, [])
const editingSessionHeartbeat = useCallback(() => {
debugConsole.log('[Event] heartbeat trigger')
const editorType = getEditorType()
if (editorType === null) return
// Heartbeat already sent recently
if (heartBeatSentRecentlyRef.current) return
heartBeatSentRecentlyRef.current = true
const segmentation = createEditingSessionHeartbeatData(editorType)
debugConsole.log('[Event] send heartbeat request', segmentation)
sendEditingSessionHeartbeat(projectId, segmentation)
const heartbeatsSent = heartBeatsSentRef.current
heartBeatsSentRef.current++
// Send two first heartbeats at 0 and 30s then increase the backoff time
// 1min per call until we reach 5 min
const backoffSecs =
heartbeatsSent <= 2
? 30
: heartbeatsSent <= 6
? (heartbeatsSent - 2) * 60
: 300
heartBeatResetTimerRef.current = window.setTimeout(() => {
heartBeatSentRecentlyRef.current = false
}, backoffSecs * 1000)
}, [getEditorType, projectId])
// Hook the heartbeat up to editor events
useEventListener('cursor:editor:update', editingSessionHeartbeat)
useEventListener('scroll:editor:update', editingSessionHeartbeat)
useDomEventListener(document, 'click', editingSessionHeartbeat)
}

View File

@@ -0,0 +1,19 @@
import useEventListener from '@/shared/hooks/use-event-listener'
import { useLocalCompileContext } from '@/shared/context/local-compile-context'
import { useCallback } from 'react'
export function useHasLintingError() {
const { setHasLintingError } = useLocalCompileContext()
// Listen for editor:lint event from CM6 linter and keep compile context
// up to date
useEventListener(
'editor:lint',
useCallback(
(event: CustomEvent) => {
setHasLintingError(event.detail.hasLintingError)
},
[setHasLintingError]
)
)
}

View File

@@ -0,0 +1,25 @@
import { useLayoutContext } from '@/shared/context/layout-context'
import { useEffect } from 'react'
import { sendMBOnce } from '@/infrastructure/event-tracking'
export function useLayoutEventTracking() {
const { view, leftMenuShown, chatIsOpen } = useLayoutContext()
useEffect(() => {
if (view && view !== 'editor' && view !== 'pdf') {
sendMBOnce(`ide-open-view-${view}-once`)
}
}, [view])
useEffect(() => {
if (leftMenuShown) {
sendMBOnce(`ide-open-left-menu-once`)
}
}, [leftMenuShown])
useEffect(() => {
if (chatIsOpen) {
sendMBOnce(`ide-open-chat-once`)
}
}, [chatIsOpen])
}

View File

@@ -0,0 +1,14 @@
import { useOutlineContext } from '@/features/ide-react/context/outline-context'
import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
import { useRef } from 'react'
import { ImperativePanelHandle } from 'react-resizable-panels'
export const useOutlinePane = () => {
const { canShowOutline, outlineExpanded } = useOutlineContext()
const outlinePanelRef = useRef<ImperativePanelHandle>(null)
const outlineEnabled = canShowOutline && outlineExpanded
useCollapsiblePanel(outlineEnabled, outlinePanelRef)
return { outlineEnabled, outlinePanelRef }
}

View File

@@ -0,0 +1,62 @@
import { useCallback, useRef } from 'react'
import { ImperativePanelHandle } from 'react-resizable-panels'
import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
import { useLayoutContext } from '@/shared/context/layout-context'
export const usePdfPane = () => {
const { view, pdfLayout, changeLayout, detachRole, reattach } =
useLayoutContext()
const pdfPanelRef = useRef<ImperativePanelHandle>(null)
const pdfIsOpen = pdfLayout === 'sideBySide' || view === 'pdf'
useCollapsiblePanel(pdfIsOpen, pdfPanelRef)
// triggered by a double-click on the resizer
const togglePdfPane = useCallback(() => {
if (pdfIsOpen) {
changeLayout('flat', 'editor')
} else {
changeLayout('sideBySide')
}
}, [changeLayout, pdfIsOpen])
// triggered by a click on the toggle button
const setPdfIsOpen = useCallback(
(value: boolean) => {
if (value) {
// opening the PDF view, so close a detached PDF
if (detachRole === 'detacher') {
reattach()
}
changeLayout('sideBySide')
} else {
changeLayout('flat', 'editor')
}
},
[changeLayout, detachRole, reattach]
)
// triggered when the PDF pane becomes open
const handlePdfPaneExpand = useCallback(() => {
if (pdfLayout === 'flat' && view === 'editor') {
changeLayout('sideBySide', 'editor')
}
}, [changeLayout, pdfLayout, view])
// triggered when the PDF pane becomes closed (either by dragging or toggling)
const handlePdfPaneCollapse = useCallback(() => {
if (pdfLayout === 'sideBySide') {
changeLayout('flat', 'editor')
}
}, [changeLayout, pdfLayout])
return {
togglePdfPane,
handlePdfPaneExpand,
handlePdfPaneCollapse,
setPdfIsOpen,
pdfIsOpen,
pdfPanelRef,
}
}

View File

@@ -0,0 +1,10 @@
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import useEventListener from '@/shared/hooks/use-event-listener'
import useDomEventListener from '@/shared/hooks/use-dom-event-listener'
export function useRegisterUserActivity() {
const { registerUserActivity } = useConnectionContext()
useEventListener('cursor:editor:update', registerUserActivity)
useDomEventListener(document.body, 'click', registerUserActivity)
}

View File

@@ -0,0 +1,33 @@
import { useCallback, useRef, useState } from 'react'
import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
import { ImperativePanelHandle } from 'react-resizable-panels'
export const useSidebarPane = () => {
const [isOpen, setIsOpen] = useState(true)
const [resizing, setResizing] = useState(false)
const panelRef = useRef<ImperativePanelHandle>(null)
useCollapsiblePanel(isOpen, panelRef)
const togglePane = useCallback(() => {
setIsOpen(value => !value)
}, [])
const handlePaneExpand = useCallback(() => {
setIsOpen(true)
}, [])
const handlePaneCollapse = useCallback(() => {
setIsOpen(false)
}, [])
return {
isOpen,
setIsOpen,
panelRef,
togglePane,
handlePaneExpand,
handlePaneCollapse,
resizing,
setResizing,
}
}

View File

@@ -0,0 +1,18 @@
import { useEffect } from 'react'
import { Socket } from '@/features/ide-react/connection/types/socket'
type SocketOnParams = Parameters<Socket['on']>
export default function useSocketListener(
socket: Socket,
event: SocketOnParams[0],
listener: SocketOnParams[1]
) {
useEffect(() => {
socket.on(event, listener)
return () => {
socket.removeListener(event, listener)
}
}, [event, listener, socket])
}

View File

@@ -0,0 +1,113 @@
import { useTranslation } from 'react-i18next'
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
import {
listProjectInvites,
listProjectMembers,
} from '@/features/share-project-modal/utils/api'
import useScopeValue from '@/shared/hooks/use-scope-value'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
import { debugConsole } from '@/utils/debugging'
import { useCallback } from 'react'
import { PublicAccessLevel } from '../../../../../types/public-access-level'
import { useLocation } from '@/shared/hooks/use-location'
import { useEditorContext } from '@/shared/context/editor-context'
function useSocketListeners() {
const { t } = useTranslation()
const { socket } = useConnectionContext()
const { projectId } = useIdeReactContext()
const { showGenericMessageModal } = useModalsContext()
const { permissionsLevel } = useEditorContext()
const [, setPublicAccessLevel] = useScopeValue('project.publicAccesLevel')
const [, setProjectMembers] = useScopeValue('project.members')
const [, setProjectInvites] = useScopeValue('project.invites')
const location = useLocation()
useSocketListener(
socket,
'project:access:revoked',
useCallback(() => {
showGenericMessageModal(
t('removed_from_project'),
t(
'you_have_been_removed_from_this_project_and_will_be_redirected_to_project_dashboard'
)
)
// redirect to project page before reconnect timer runs out and reloads the page
const timer = window.setTimeout(() => {
location.assign('/project')
}, 5000)
return () => {
window.clearTimeout(timer)
}
}, [showGenericMessageModal, t, location])
)
useSocketListener(
socket,
'project:publicAccessLevel:changed',
useCallback(
(data: { newAccessLevel?: PublicAccessLevel }) => {
if (data.newAccessLevel) {
setPublicAccessLevel(data.newAccessLevel)
}
},
[setPublicAccessLevel]
)
)
useSocketListener(
socket,
'project:collaboratorAccessLevel:changed',
useCallback(() => {
listProjectMembers(projectId)
.then(({ members }) => {
if (members) {
setProjectMembers(members)
}
})
.catch(err => {
debugConsole.error('Error fetching members for project', err)
})
}, [projectId, setProjectMembers])
)
useSocketListener(
socket,
'project:membership:changed',
useCallback(
(data: { members?: boolean; invites?: boolean }) => {
if (data.members) {
listProjectMembers(projectId)
.then(({ members }) => {
if (members) {
setProjectMembers(members)
}
})
.catch(err => {
debugConsole.error('Error fetching members for project', err)
})
}
if (data.invites && permissionsLevel === 'owner') {
listProjectInvites(projectId)
.then(({ invites }) => {
if (invites) {
setProjectInvites(invites)
}
})
.catch(err => {
debugConsole.error('Error fetching invites for project', err)
})
}
},
[projectId, setProjectInvites, setProjectMembers, permissionsLevel]
)
)
}
export default useSocketListeners

View File

@@ -0,0 +1,58 @@
import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
import customLocalStorage from '@/infrastructure/local-storage'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
export type EditorScopeValue = {
showSymbolPalette: false
toggleSymbolPalette: () => void
sharejs_doc: DocumentContainer | null
open_doc_id: string | null
open_doc_name: string | null
opening: boolean
trackChanges: boolean
wantTrackChanges: boolean
showVisual: boolean
error_state: boolean
}
export function populateEditorScope(
store: ReactScopeValueStore,
projectId: string
) {
store.set('project.name', null)
const editor: Omit<EditorScopeValue, 'showVisual'> = {
showSymbolPalette: false,
toggleSymbolPalette: () => {},
sharejs_doc: null,
open_doc_id: null,
open_doc_name: null,
opening: true,
trackChanges: false,
wantTrackChanges: false,
error_state: false,
}
store.set('editor', editor)
store.persisted(
'editor.showVisual',
showVisualFallbackValue(projectId),
`editor.lastUsedMode`,
{
toPersisted: showVisual => (showVisual ? 'visual' : 'code'),
fromPersisted: mode => mode === 'visual',
}
)
}
function showVisualFallbackValue(projectId: string) {
const editorModeKey = `editor.mode.${projectId}`
const editorModeVal = customLocalStorage.getItem(editorModeKey)
if (editorModeVal) {
// clean up the old key
customLocalStorage.removeItem(editorModeKey)
}
return editorModeVal === 'rich-text'
}

View File

@@ -0,0 +1,14 @@
import { ReactScopeValueStore } from '../scope-value-store/react-scope-value-store'
import getMeta from '@/utils/meta'
const reviewPanelStorageKey = `ui.reviewPanelOpen.${getMeta('ol-project_id')}`
export default function populateLayoutScope(store: ReactScopeValueStore) {
store.set('ui.view', 'editor')
store.set('openFile', null)
store.persisted('ui.chatOpen', false, 'ui.chatOpen')
store.persisted('ui.reviewPanelOpen', false, reviewPanelStorageKey)
store.set('ui.leftMenuShown', false)
store.set('ui.miniReviewPanelVisible', false)
store.set('ui.pdfLayout', 'sideBySide')
}

View File

@@ -0,0 +1,37 @@
import {
ScopeEventEmitter,
ScopeEventName,
} from '../../../../../types/ide/scope-event-emitter'
import { IdeEvents } from '@/features/ide-react/create-ide-event-emitter'
export class ReactScopeEventEmitter implements ScopeEventEmitter {
// eslint-disable-next-line no-useless-constructor
constructor(private readonly eventEmitter: EventTarget) {}
emit<T extends ScopeEventName>(
eventName: T,
broadcast: boolean,
...detail: IdeEvents[T]
) {
this.eventEmitter.dispatchEvent(new CustomEvent(eventName, { detail }))
}
on<T extends ScopeEventName>(
eventName: T,
listener: (event: Event, ...args: IdeEvents[T]) => void
) {
const wrappedListener = (event: CustomEvent<IdeEvents[T]>) => {
listener(event, ...event.detail)
}
this.eventEmitter.addEventListener(
eventName,
wrappedListener as EventListener
)
return () => {
this.eventEmitter.removeEventListener(
eventName,
wrappedListener as EventListener
)
}
}
}

View File

@@ -0,0 +1,333 @@
import { ScopeValueStore } from '../../../../../types/ide/scope-value-store'
import _ from 'lodash'
import customLocalStorage from '../../../infrastructure/local-storage'
import { debugConsole } from '@/utils/debugging'
const NOT_FOUND = Symbol('not found')
type Watcher<T> = {
removed: boolean
callback: (value: T) => void
}
// A value that has been set
type ScopeValueStoreValue<T = any> = {
value?: T
watchers: Watcher<T>[]
}
type WatcherUpdate<T = any> = {
path: string
value: T
watchers: Watcher<T>[]
}
type NonExistentValue = {
value: undefined
}
type AllowedNonExistentPath = {
path: string
deep: boolean
}
type Persister = {
localStorageKey: string
toPersisted?: (value: unknown) => unknown
}
function isObject(value: unknown): value is object {
return (
value !== null &&
typeof value === 'object' &&
!('length' in value && typeof value.length === 'number' && value.length > 0)
)
}
function ancestorPaths(path: string) {
const ancestors: string[] = []
let currentPath = path
let lastPathSeparatorPos: number
while ((lastPathSeparatorPos = currentPath.lastIndexOf('.')) !== -1) {
currentPath = currentPath.slice(0, lastPathSeparatorPos)
ancestors.push(currentPath)
}
return ancestors
}
// Store scope values in a simple map
export class ReactScopeValueStore implements ScopeValueStore {
private readonly items = new Map<string, ScopeValueStoreValue>()
private readonly persisters: Map<string, Persister> = new Map()
private watcherUpdates = new Map<string, WatcherUpdate>()
private watcherUpdateTimer: number | null = null
private allowedNonExistentPaths: AllowedNonExistentPath[] = []
private nonExistentPathAllowed(path: string) {
return this.allowedNonExistentPaths.some(allowedPath => {
return (
allowedPath.path === path ||
(allowedPath.deep && path.startsWith(allowedPath.path + '.'))
)
})
}
// Create an item for a path. Attempt to get a value for the item from its
// ancestors, if there are any.
private findInAncestors(path: string): ScopeValueStoreValue {
// Populate value from the nested property ancestors, if possible
for (const ancestorPath of ancestorPaths(path)) {
const ancestorItem = this.items.get(ancestorPath)
if (
ancestorItem &&
'value' in ancestorItem &&
isObject(ancestorItem.value)
) {
const pathRelativeToAncestor = path.slice(ancestorPath.length + 1)
const ancestorValue = _.get(ancestorItem.value, pathRelativeToAncestor)
if (ancestorValue !== NOT_FOUND) {
return { value: ancestorValue, watchers: [] }
}
}
}
return { watchers: [] }
}
private getItem<T>(path: string): ScopeValueStoreValue<T> | NonExistentValue {
const item = this.items.get(path) || this.findInAncestors(path)
if (!('value' in item)) {
if (this.nonExistentPathAllowed(path)) {
debugConsole.log(
`No value found for key '${path}'. This is allowed because the path is in allowedNonExistentPaths`
)
return { value: undefined }
} else {
throw new Error(`No value found for key '${path}'`)
}
}
return item
}
private reassembleObjectValue(path: string, value: Record<string, any>) {
const newValue: Record<string, any> = { ...value }
const pathPrefix = path + '.'
for (const [key, item] of this.items.entries()) {
if (key.startsWith(pathPrefix)) {
const propName = key.slice(pathPrefix.length)
if (propName.indexOf('.') === -1 && 'value' in item) {
newValue[propName] = item.value
}
}
}
return newValue
}
flushUpdates() {
if (this.watcherUpdateTimer) {
window.clearTimeout(this.watcherUpdateTimer)
this.watcherUpdateTimer = null
}
// Clone watcherUpdates in case a watcher creates new watcherUpdates
const watcherUpdates = [...this.watcherUpdates.values()]
this.watcherUpdates = new Map()
for (const { value, watchers } of watcherUpdates) {
for (const watcher of watchers) {
if (!watcher.removed) {
watcher.callback.call(null, value)
}
}
}
}
private scheduleWatcherUpdate<T>(
path: string,
value: T,
watchers: Watcher<T>[]
) {
// Make a copy of the watchers so that any watcher added before this update
// runs is not triggered
const update: WatcherUpdate = {
value,
path,
watchers: [...watchers],
}
this.watcherUpdates.set(path, update)
if (!this.watcherUpdateTimer) {
this.watcherUpdateTimer = window.setTimeout(() => {
this.watcherUpdateTimer = null
this.flushUpdates()
}, 0)
}
}
get<T>(path: string) {
return this.getItem<T>(path).value
}
private setValue<T>(path: string, value: T): void {
debugConsole.log('setValue', path, value)
let item = this.items.get(path)
if (item === undefined) {
item = { value, watchers: [] }
this.items.set(path, item)
} else if (!('value' in item)) {
item = { ...item, value }
this.items.set(path, item)
} else if (item.value === value) {
// Don't update and trigger watchers if the value hasn't changed
return
} else {
item.value = value
}
this.scheduleWatcherUpdate<T>(path, value, item.watchers)
// Persist to local storage, if configured to do so
const persister = this.persisters.get(path)
if (persister) {
customLocalStorage.setItem(
persister.localStorageKey,
persister.toPersisted?.(value) || value
)
}
}
private setValueAndDescendants<T>(path: string, value: T): void {
this.setValue(path, value)
// Set nested values non-recursively, only updating existing items
if (isObject(value)) {
const pathPrefix = path + '.'
for (const [nestedPath, existingItem] of this.items.entries()) {
if (nestedPath.startsWith(pathPrefix)) {
const newValue = _.get(
value,
nestedPath.slice(pathPrefix.length),
NOT_FOUND
)
// Only update a nested value if it has changed
if (
newValue !== NOT_FOUND &&
(!('value' in existingItem) || newValue !== existingItem.value)
) {
this.setValue(nestedPath, newValue)
}
}
}
// Delete nested items corresponding to properties that do not exist in
// the new object
const pathsToDelete: string[] = []
const newPropNames = new Set(Object.keys(value))
for (const path of this.items.keys()) {
if (path.startsWith(pathPrefix)) {
const propName = path.slice(pathPrefix.length).split('.', 1)[0]
if (!newPropNames.has(propName)) {
pathsToDelete.push(path)
}
}
}
for (const path of pathsToDelete) {
this.items.delete(path)
}
}
}
set(path: string, value: unknown): void {
this.setValueAndDescendants(path, value)
// Reassemble ancestors. For example, if the path is x.y.z, x.y and x have
// now changed too and must be updated
for (const ancestorPath of ancestorPaths(path)) {
const ancestorItem = this.items.get(ancestorPath)
if (ancestorItem && 'value' in ancestorItem) {
ancestorItem.value = this.reassembleObjectValue(
ancestorPath,
ancestorItem.value
)
this.scheduleWatcherUpdate(
ancestorPath,
ancestorItem.value,
ancestorItem.watchers
)
}
}
}
// Watch for changes in a scope value. The value does not need to exist yet.
// Watchers are batched and called asynchronously to avoid chained state
// watcherUpdates, which result in warnings from React (see
// https://github.com/facebook/react/issues/18178)
watch<T>(path: string, callback: Watcher<T>['callback']): () => void {
let item = this.items.get(path)
if (!item) {
item = this.findInAncestors(path)
this.items.set(path, item)
}
const watchers = item.watchers
const watcher = { removed: false, callback }
item.watchers.push(watcher)
// Schedule watcher immediately. This is to work around the fact that there
// is a delay between getting an initial value and adding a watcher in
// useScopeValue, during which the value could change without being
// observed
if ('value' in item) {
// add this watcher to any existing watchers scheduled for this path
const { watchers } = this.watcherUpdates.get(path) ?? { watchers: [] }
this.scheduleWatcherUpdate<T>(path, item.value, [...watchers, watcher])
}
return () => {
// Add a flag to the watcher so that it can be ignored if the watcher is
// removed in the interval between observing a change and being called
watcher.removed = true
_.pull(watchers, watcher)
}
}
persisted<Value, PersistedValue>(
path: string,
fallbackValue: Value,
localStorageKey: string,
converter?: {
toPersisted: (value: Value) => PersistedValue
fromPersisted: (persisted: PersistedValue) => Value
}
) {
const persistedValue = customLocalStorage.getItem(
localStorageKey
) as PersistedValue | null
let value: Value = fallbackValue
if (persistedValue !== null) {
value = converter
? converter.fromPersisted(persistedValue)
: (persistedValue as Value)
}
this.set(path, value)
// Don't persist the value until set() is called
this.persisters.set(path, {
localStorageKey,
toPersisted: converter?.toPersisted as Persister['toPersisted'],
})
}
allowNonExistentPath(path: string, deep = false) {
this.allowedNonExistentPaths.push({ path, deep })
}
// For debugging
dump() {
const entries = []
for (const [path, item] of this.items.entries()) {
entries.push({
path,
value: 'value' in item ? item.value : '[not set]',
watcherCount: item.watchers.length,
})
}
return entries
}
}

View File

@@ -0,0 +1,4 @@
export type CursorPosition = {
row: number
column: number
}

View File

@@ -0,0 +1,34 @@
import { FileRef } from '../../../../../types/file-ref'
import { Folder } from '../../../../../types/folder'
import { Doc } from '../../../../../types/doc'
export type FileTreeFolderFindResultType = 'folder' | 'doc' | 'fileRef'
interface BaseFileTreeFindResult<T> {
type: FileTreeFolderFindResultType
entity: T
parent: T[]
parentFolderId: string
path: string[]
index: number
}
export interface FileTreeFolderFindResult
extends BaseFileTreeFindResult<Folder> {
type: 'folder'
}
export interface FileTreeDocumentFindResult
extends BaseFileTreeFindResult<Doc> {
type: 'doc'
}
export interface FileTreeFileRefFindResult
extends BaseFileTreeFindResult<FileRef> {
type: 'fileRef'
}
export type FileTreeFindResult =
| FileTreeFolderFindResult
| FileTreeDocumentFindResult
| FileTreeFileRefFindResult

View File

@@ -0,0 +1,6 @@
export interface GotoLineOptions {
gotoLine: number
gotoColumn?: number
selectText?: string
syncToPdf?: boolean
}

View File

@@ -0,0 +1,8 @@
export type OutlineItemData = {
line: number
title: string
level?: number
children?: OutlineItemData[]
from?: number
to?: number
}

View File

@@ -0,0 +1,12 @@
export type Permissions = {
read: boolean
comment: boolean
resolveOwnComments: boolean
resolveAllComments: boolean
trackedWrite: boolean
write: boolean
admin: boolean
labelVersion: boolean
}
export type PermissionsLevel = 'owner' | 'readAndWrite' | 'review' | 'readOnly'

View File

@@ -0,0 +1,31 @@
import { FileRef } from '../../../../../types/file-ref'
import { BinaryFile } from '@/features/file-view/types/binary-file'
export function convertFileRefToBinaryFile(fileRef: FileRef): BinaryFile {
const timestamp = fileRef.linkedFileData?.importedAt ?? fileRef.created
return {
_id: fileRef._id,
name: fileRef.name,
id: fileRef._id,
type: 'file',
selected: true,
linkedFileData: fileRef.linkedFileData,
created: timestamp ? new Date(timestamp) : new Date(),
hash: fileRef.hash,
}
}
// `FileViewHeader`, which is TypeScript, expects a BinaryFile, which has a
// `created` property of type `Date`, while `TPRFileViewInfo`, written in JS,
// into which `FileViewHeader` passes its BinaryFile, expects a file object with
// `created` property of type `string`, which is a mismatch. `TPRFileViewInfo`
// is the only one making runtime complaints and it seems that other uses of
// `FileViewHeader` pass in a string for `created`, so that's what this function
// does too.
export function fileViewFile(fileRef: FileRef) {
const converted = convertFileRefToBinaryFile(fileRef)
return {
...converted,
created: converted.created.toISOString(),
}
}

View File

@@ -0,0 +1,20 @@
import { findInTree } from '@/features/file-tree/util/find-in-tree'
import { Folder } from '../../../../../types/folder'
import { Doc } from '../../../../../types/doc'
import { FileRef } from '../../../../../types/file-ref'
export function findDocEntityById(fileTreeData: Folder, docId: string) {
const item = findInTree(fileTreeData, docId)
if (!item || item.type !== 'doc') {
return null
}
return item.entity as Doc
}
export function findFileRefEntityById(fileTreeData: Folder, docId: string) {
const item = findInTree(fileTreeData, docId)
if (!item || item.type !== 'fileRef') {
return null
}
return item.entity as FileRef
}