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,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
}