Merge pull request #15474 from overleaf/td-ide-page-file-and-multiple-view

React IDE page: implement file views and file delete handling

GitOrigin-RevId: 491cad7b147e55bc4a250da387916c7e2dff14ae
This commit is contained in:
ilkin-overleaf
2023-11-03 09:00:08 +00:00
committed by Copybot
parent 0c403bf8e3
commit 2bbead57ec
28 changed files with 593 additions and 311 deletions
@@ -268,6 +268,10 @@
"do_this_later": "", "do_this_later": "",
"do_you_want_to_change_your_primary_email_address_to": "", "do_you_want_to_change_your_primary_email_address_to": "",
"do_you_want_to_overwrite_them": "", "do_you_want_to_overwrite_them": "",
"document_too_long": "",
"document_too_long_detail": "",
"document_updated_externally": "",
"document_updated_externally_detail": "",
"documentation": "", "documentation": "",
"doesnt_match": "", "doesnt_match": "",
"doing_this_allow_log_in_through_institution": "", "doing_this_allow_log_in_through_institution": "",
@@ -336,6 +340,8 @@
"enter_image_url": "", "enter_image_url": "",
"entry_point": "", "entry_point": "",
"error": "", "error": "",
"error_opening_document": "",
"error_opening_document_detail": "",
"error_performing_request": "", "error_performing_request": "",
"example_project": "", "example_project": "",
"existing_plan_active_until_term_end": "", "existing_plan_active_until_term_end": "",
@@ -366,6 +372,7 @@
"file_outline": "", "file_outline": "",
"file_size": "", "file_size": "",
"files_cannot_include_invalid_characters": "", "files_cannot_include_invalid_characters": "",
"files_selected": "",
"find_out_more": "", "find_out_more": "",
"find_out_more_about_institution_login": "", "find_out_more_about_institution_login": "",
"find_out_more_about_the_file_outline": "", "find_out_more_about_the_file_outline": "",
@@ -745,6 +752,7 @@
"no_projects": "", "no_projects": "",
"no_resolved_threads": "", "no_resolved_threads": "",
"no_search_results": "", "no_search_results": "",
"no_selection_select_file": "",
"no_symbols_found": "", "no_symbols_found": "",
"no_thanks_cancel_now": "", "no_thanks_cancel_now": "",
"normal": "", "normal": "",
@@ -770,6 +778,7 @@
"only_group_admin_or_managers_can_delete_your_account_4": "", "only_group_admin_or_managers_can_delete_your_account_4": "",
"only_group_admin_or_managers_can_delete_your_account_5": "", "only_group_admin_or_managers_can_delete_your_account_5": "",
"only_importer_can_refresh": "", "only_importer_can_refresh": "",
"open_a_file_on_the_left": "",
"open_file": "", "open_file": "",
"open_link": "", "open_link": "",
"open_project": "", "open_project": "",
@@ -1195,6 +1204,8 @@
"token_read_only": "", "token_read_only": "",
"token_read_write": "", "token_read_write": "",
"too_many_attempts": "", "too_many_attempts": "",
"too_many_comments_or_tracked_changes": "",
"too_many_comments_or_tracked_changes_detail": "",
"too_many_files_uploaded_throttled_short_period": "", "too_many_files_uploaded_throttled_short_period": "",
"too_many_requests": "", "too_many_requests": "",
"too_many_search_results": "", "too_many_search_results": "",
@@ -29,6 +29,7 @@ const FileTreeRoot = React.memo(function FileTreeRoot({
setStartedFreeTrial, setStartedFreeTrial,
onSelect, onSelect,
onInit, onInit,
onDelete,
isConnected, isConnected,
}) { }) {
const { _id: projectId } = useProjectContext(projectContextPropTypes) const { _id: projectId } = useProjectContext(projectContextPropTypes)
@@ -52,7 +53,7 @@ const FileTreeRoot = React.memo(function FileTreeRoot({
<FileTreeToolbar /> <FileTreeToolbar />
<FileTreeContextMenu /> <FileTreeContextMenu />
<FileTreeInner> <FileTreeInner>
<FileTreeRootFolder /> <FileTreeRootFolder onDelete={onDelete} />
</FileTreeInner> </FileTreeInner>
<FileTreeModalDelete /> <FileTreeModalDelete />
<FileTreeModalCreateFile /> <FileTreeModalCreateFile />
@@ -62,8 +63,8 @@ const FileTreeRoot = React.memo(function FileTreeRoot({
) )
}) })
function FileTreeRootFolder() { function FileTreeRootFolder({ onDelete }) {
useFileTreeSocketListener() useFileTreeSocketListener(onDelete)
const { fileTreeData } = useFileTreeData() const { fileTreeData } = useFileTreeData()
const { isOver, dropRef } = useDroppable(fileTreeData._id) const { isOver, dropRef } = useDroppable(fileTreeData._id)
@@ -93,9 +94,14 @@ function FileTreeRootFolder() {
) )
} }
FileTreeRootFolder.propTypes = {
onDelete: PropTypes.func,
}
FileTreeRoot.propTypes = { FileTreeRoot.propTypes = {
onSelect: PropTypes.func.isRequired, onSelect: PropTypes.func.isRequired,
onInit: PropTypes.func.isRequired, onInit: PropTypes.func.isRequired,
onDelete: PropTypes.func,
isConnected: PropTypes.bool.isRequired, isConnected: PropTypes.bool.isRequired,
setRefProviderEnabled: PropTypes.func.isRequired, setRefProviderEnabled: PropTypes.func.isRequired,
setStartedFreeTrial: PropTypes.func.isRequired, setStartedFreeTrial: PropTypes.func.isRequired,
@@ -4,9 +4,10 @@ import PropTypes from 'prop-types'
import { useUserContext } from '../../../shared/context/user-context' import { useUserContext } from '../../../shared/context/user-context'
import { useFileTreeData } from '../../../shared/context/file-tree-data-context' import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
import { useFileTreeSelectable } from '../contexts/file-tree-selectable' import { useFileTreeSelectable } from '../contexts/file-tree-selectable'
import { findInTreeOrThrow } from '../util/find-in-tree' import { findInTree, findInTreeOrThrow } from '../util/find-in-tree'
import { useIdeContext } from '@/shared/context/ide-context'
export function useFileTreeSocketListener() { export function useFileTreeSocketListener(onDelete) {
const user = useUserContext({ const user = useUserContext({
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
}) })
@@ -21,7 +22,7 @@ export function useFileTreeSocketListener() {
} = useFileTreeData() } = useFileTreeData()
const { selectedEntityIds, selectedEntityParentIds, select, unselect } = const { selectedEntityIds, selectedEntityParentIds, select, unselect } =
useFileTreeSelectable() useFileTreeSelectable()
const socket = window._ide && window._ide.socket const { socket } = useIdeContext()
const selectEntityIfCreatedByUser = useCallback( const selectEntityIfCreatedByUser = useCallback(
// hack to automatically re-open refreshed linked files // hack to automatically re-open refreshed linked files
@@ -52,6 +53,7 @@ export function useFileTreeSocketListener() {
useEffect(() => { useEffect(() => {
function handleDispatchDelete(entityId) { function handleDispatchDelete(entityId) {
const entity = findInTree(fileTreeData, entityId)
unselect(entityId) unselect(entityId)
if (selectedEntityParentIds.has(entityId)) { if (selectedEntityParentIds.has(entityId)) {
// we're deleting a folder with a selected children so we need to // we're deleting a folder with a selected children so we need to
@@ -67,6 +69,9 @@ export function useFileTreeSocketListener() {
} }
} }
dispatchDelete(entityId) dispatchDelete(entityId)
if (onDelete) {
onDelete(entity)
}
} }
if (socket) socket.on('removeEntity', handleDispatchDelete) if (socket) socket.on('removeEntity', handleDispatchDelete)
return () => { return () => {
@@ -79,6 +84,7 @@ export function useFileTreeSocketListener() {
fileTreeData, fileTreeData,
selectedEntityIds, selectedEntityIds,
selectedEntityParentIds, selectedEntityParentIds,
onDelete,
]) ])
useEffect(() => { useEffect(() => {
@@ -1,4 +1,4 @@
import { useRef } from 'react' import { ReactNode, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
ImperativePanelHandle, ImperativePanelHandle,
@@ -9,21 +9,18 @@ import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/h
import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler' import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel' import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
import { useLayoutContext } from '@/shared/context/layout-context' import { useLayoutContext } from '@/shared/context/layout-context'
import { EditorPane } from '@/features/ide-react/components/editor/editor-pane' import PdfPreview from '@/features/pdf-preview/components/pdf-preview'
import PlaceholderFile from '@/features/ide-react/components/layout/placeholder/placeholder-file' import { DefaultSynctexControl } from '@/features/pdf-preview/components/detach-synctex-control'
import PlaceholderPdf from '@/features/ide-react/components/layout/placeholder/placeholder-pdf'
import classnames from 'classnames' import classnames from 'classnames'
export type EditorProps = { export type EditorProps = {
shouldPersistLayout?: boolean shouldPersistLayout?: boolean
openDocId: string | null editorContent: ReactNode
fileTreeReady: boolean
} }
export default function Editor({ export default function EditorAndPdf({
shouldPersistLayout = false, shouldPersistLayout = false,
openDocId, editorContent,
fileTreeReady,
}: EditorProps) { }: EditorProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { view, pdfLayout, changeLayout } = useLayoutContext() const { view, pdfLayout, changeLayout } = useLayoutContext()
@@ -35,10 +32,6 @@ export default function Editor({
useCollapsiblePanel(pdfIsOpen, pdfPanelRef) useCollapsiblePanel(pdfIsOpen, pdfPanelRef)
if (view === 'file') {
return <PlaceholderFile />
}
const historyIsOpen = view === 'history' const historyIsOpen = view === 'history'
function setPdfIsOpen(isOpen: boolean) { function setPdfIsOpen(isOpen: boolean) {
@@ -58,12 +51,13 @@ export default function Editor({
className={classnames({ hide: historyIsOpen })} className={classnames({ hide: historyIsOpen })}
> >
{editorIsVisible ? ( {editorIsVisible ? (
<Panel id="editor" order={1} defaultSize={50}> <Panel
<EditorPane id="editor"
shouldPersistLayout={shouldPersistLayout} order={1}
openDocId={openDocId} defaultSize={50}
fileTreeReady={fileTreeReady} className="ide-react-panel"
/> >
{editorContent}
</Panel> </Panel>
) : null} ) : null}
{isDualPane ? ( {isDualPane ? (
@@ -76,6 +70,9 @@ export default function Editor({
tooltipWhenOpen={t('tooltip_hide_pdf')} tooltipWhenOpen={t('tooltip_hide_pdf')}
tooltipWhenClosed={t('tooltip_show_pdf')} tooltipWhenClosed={t('tooltip_show_pdf')}
/> />
<div className="synctex-controls">
<DefaultSynctexControl />
</div>
</HorizontalResizeHandle> </HorizontalResizeHandle>
) : null} ) : null}
{pdfIsOpen ? ( {pdfIsOpen ? (
@@ -87,8 +84,9 @@ export default function Editor({
minSize={5} minSize={5}
collapsible collapsible
onCollapse={collapsed => setPdfIsOpen(!collapsed)} onCollapse={collapsed => setPdfIsOpen(!collapsed)}
className="ide-react-panel"
> >
<PlaceholderPdf /> <PdfPreview />
</Panel> </Panel>
) : null} ) : null}
</PanelGroup> </PanelGroup>
@@ -1,13 +1,28 @@
import React, { useCallback, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import TwoColumnMainContent from '@/features/ide-react/components/layout/two-column-main-content' import TwoColumnMainContent from '@/features/ide-react/components/layout/two-column-main-content'
import Editor from '@/features/ide-react/components/editor/editor'
import EditorSidebar from '@/features/ide-react/components/editor-sidebar' import EditorSidebar from '@/features/ide-react/components/editor-sidebar'
import customLocalStorage from '@/infrastructure/local-storage'
import History from '@/features/ide-react/components/history' import History from '@/features/ide-react/components/history'
import { HistoryProvider } from '@/features/history/context/history-context' import { HistoryProvider } from '@/features/history/context/history-context'
import { useProjectContext } from '@/shared/context/project-context' import { useProjectContext } from '@/shared/context/project-context'
import { useLayoutContext } from '@/shared/context/layout-context' import { useLayoutContext } from '@/shared/context/layout-context'
import { FileTreeFindResult } from '@/features/ide-react/types/file-tree' import {
FileTreeDeleteHandler,
FileTreeDocumentFindResult,
FileTreeFileRefFindResult,
FileTreeFindResult,
} from '@/features/ide-react/types/file-tree'
import usePersistedState from '@/shared/hooks/use-persisted-state'
import FileView from '@/features/file-view/components/file-view'
import { FileRef } from '../../../../../types/file-ref'
import { EditorPane } from '@/features/ide-react/components/editor/editor-pane'
import EditorAndPdf from '@/features/ide-react/components/editor-and-pdf'
import MultipleSelectionPane from '@/features/ide-react/components/editor/multiple-selection-pane'
import NoSelectionPane from '@/features/ide-react/components/editor/no-selection-pane'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import NoOpenDocPane from '@/features/ide-react/components/editor/no-open-doc-pane'
import { debugConsole } from '@/utils/debugging'
import { HistorySidebar } from '@/features/ide-react/components/history-sidebar'
type EditorAndSidebarProps = { type EditorAndSidebarProps = {
shouldPersistLayout: boolean shouldPersistLayout: boolean
@@ -15,6 +30,25 @@ type EditorAndSidebarProps = {
setLeftColumnDefaultSize: React.Dispatch<React.SetStateAction<number>> setLeftColumnDefaultSize: React.Dispatch<React.SetStateAction<number>>
} }
// `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.
function fileViewFile(fileRef: FileRef) {
return {
_id: fileRef._id,
name: fileRef.name,
id: fileRef._id,
type: 'file',
selected: true,
linkedFileData: fileRef.linkedFileData,
created: fileRef.created,
}
}
export function EditorAndSidebar({ export function EditorAndSidebar({
shouldPersistLayout, shouldPersistLayout,
leftColumnDefaultSize, leftColumnDefaultSize,
@@ -22,12 +56,21 @@ export function EditorAndSidebar({
}: EditorAndSidebarProps) { }: EditorAndSidebarProps) {
const [leftColumnIsOpen, setLeftColumnIsOpen] = useState(true) const [leftColumnIsOpen, setLeftColumnIsOpen] = useState(true)
const { rootDocId, _id: projectId } = useProjectContext() const { rootDocId, _id: projectId } = useProjectContext()
const { eventEmitter } = useIdeReactContext()
const { openDocId: openDocWithId, currentDocumentId } =
useEditorManagerContext()
const { view } = useLayoutContext() const { view } = useLayoutContext()
const historyIsOpen = view === 'history' const historyIsOpen = view === 'history'
const [openDocId, setOpenDocId] = useState( // Persist the open document ID in local storage
() => customLocalStorage.getItem(`doc.open_id.${projectId}`) || rootDocId const [openDocId, setOpenDocId] = usePersistedState(
`doc.open_id.${projectId}`,
rootDocId
) )
const [openEntity, setOpenEntity] = useState<
FileTreeDocumentFindResult | FileTreeFileRefFindResult | null
>(null)
const [selectedEntityCount, setSelectedEntityCount] = useState(0)
const [fileTreeReady, setFileTreeReady] = useState(false) const [fileTreeReady, setFileTreeReady] = useState(false)
const handleFileTreeInit = useCallback(() => { const handleFileTreeInit = useCallback(() => {
@@ -36,37 +79,140 @@ export function EditorAndSidebar({
const handleFileTreeSelect = useCallback( const handleFileTreeSelect = useCallback(
(selectedEntities: FileTreeFindResult[]) => { (selectedEntities: FileTreeFindResult[]) => {
const firstDocId = debugConsole.log('File tree selection changed', selectedEntities)
selectedEntities.find(result => result.type === 'doc')?.entity._id || setSelectedEntityCount(selectedEntities.length)
null if (selectedEntities.length !== 1) {
setOpenDocId(firstDocId) setOpenEntity(null)
return
}
const [selected] = selectedEntities
if (selected.type === 'folder') {
return
}
setOpenEntity(selected)
if (selected.type === 'doc') {
setOpenDocId(selected.entity._id)
}
}, },
[] [setOpenDocId]
) )
const leftColumnContent = ( const handleFileTreeDelete: FileTreeDeleteHandler = useCallback(
entity => {
eventEmitter.emit('entity:deleted', entity)
// Select the root document if the current document was deleted
if (entity.entity._id === openDocId) {
openDocWithId(rootDocId)
}
},
[eventEmitter, openDocId, openDocWithId, rootDocId]
)
// Synchronize the file tree when openDoc or openDocId is called on the editor
// manager context from elsewhere. If the file tree does change, it will
// trigger the onSelect handler in this component, which will update the local
// state.
useEffect(() => {
debugConsole.log(
`currentDocumentId changed to ${currentDocumentId}. Updating file tree`
)
if (currentDocumentId === null) {
return
}
window.dispatchEvent(
new CustomEvent('editor.openDoc', { detail: currentDocumentId })
)
}, [currentDocumentId])
// Store openDocWithId, which is unstable, in a ref and keep the ref
// synchronized with the source
const openDocWithIdRef = useRef(openDocWithId)
useEffect(() => {
openDocWithIdRef.current = openDocWithId
}, [openDocWithId])
// Open a document in the editor when the local document ID changes
useEffect(() => {
if (!fileTreeReady || !openDocId) {
return
}
debugConsole.log(
`Observed change in local state. Opening document with ID ${openDocId}`
)
openDocWithIdRef.current(openDocId)
}, [fileTreeReady, openDocId])
const leftColumnContent = historyIsOpen ? (
<HistorySidebar />
) : (
<EditorSidebar <EditorSidebar
shouldPersistLayout={shouldPersistLayout} shouldPersistLayout={shouldPersistLayout}
onFileTreeInit={handleFileTreeInit} onFileTreeInit={handleFileTreeInit}
onFileTreeSelect={handleFileTreeSelect} onFileTreeSelect={handleFileTreeSelect}
onFileTreeDelete={handleFileTreeDelete}
/> />
) )
const rightColumnContent = ( let rightColumnContent
<>
{/* Recreate the history context when the history view is toggled */} if (historyIsOpen) {
{historyIsOpen && ( rightColumnContent = (
<HistoryProvider> <HistoryProvider>
<History /> <History />
</HistoryProvider> </HistoryProvider>
)} )
<Editor } else {
let editorContent = null
// Always have the editor mounted when not in history view, and hide and
// show it as necessary
const editorPane = (
<EditorPane
shouldPersistLayout={shouldPersistLayout} shouldPersistLayout={shouldPersistLayout}
openDocId={openDocId} show={openEntity?.type === 'doc' && selectedEntityCount === 1}
fileTreeReady={fileTreeReady}
/> />
</> )
) if (openDocId === undefined) {
rightColumnContent = <NoOpenDocPane />
} else if (selectedEntityCount === 0) {
rightColumnContent = (
<>
{editorPane}
<NoSelectionPane />
</>
)
} else if (selectedEntityCount > 1) {
editorContent = (
<>
{editorPane}
<MultipleSelectionPane selectedEntityCount={selectedEntityCount} />
</>
)
} else if (openEntity) {
editorContent =
openEntity.type === 'doc' ? (
editorPane
) : (
<>
{editorPane}
<FileView file={fileViewFile(openEntity.entity)} />
</>
)
}
if (editorContent) {
rightColumnContent = (
<EditorAndPdf
editorContent={editorContent}
shouldPersistLayout={shouldPersistLayout}
/>
)
}
}
return ( return (
<TwoColumnMainContent <TwoColumnMainContent
@@ -2,31 +2,27 @@ import React from 'react'
import { Panel, PanelGroup } from 'react-resizable-panels' import { Panel, PanelGroup } from 'react-resizable-panels'
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle' import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
import { FileTree } from '@/features/ide-react/components/file-tree' import { FileTree } from '@/features/ide-react/components/file-tree'
import { useLayoutContext } from '@/shared/context/layout-context' import {
import classnames from 'classnames' FileTreeDeleteHandler,
import { FileTreeSelectHandler } from '@/features/ide-react/types/file-tree' FileTreeSelectHandler,
} from '@/features/ide-react/types/file-tree'
type EditorSidebarProps = { type EditorSidebarProps = {
shouldPersistLayout: boolean shouldPersistLayout: boolean
onFileTreeInit: () => void onFileTreeInit: () => void
onFileTreeSelect: FileTreeSelectHandler onFileTreeSelect: FileTreeSelectHandler
onFileTreeDelete: FileTreeDeleteHandler
} }
export default function EditorSidebar({ export default function EditorSidebar({
shouldPersistLayout, shouldPersistLayout,
onFileTreeInit, onFileTreeInit,
onFileTreeSelect, onFileTreeSelect,
onFileTreeDelete,
}: EditorSidebarProps) { }: EditorSidebarProps) {
const { view } = useLayoutContext()
const historyIsOpen = view === 'history'
return ( return (
<> <>
<aside <aside className="ide-react-editor-sidebar">
className={classnames('ide-react-placeholder-editor-sidebar', {
hide: historyIsOpen,
})}
>
<PanelGroup <PanelGroup
autoSaveId={ autoSaveId={
shouldPersistLayout ? 'ide-react-editor-sidebar-layout' : undefined shouldPersistLayout ? 'ide-react-editor-sidebar-layout' : undefined
@@ -34,7 +30,11 @@ export default function EditorSidebar({
direction="vertical" direction="vertical"
> >
<Panel defaultSize={75} className="ide-react-file-tree-panel"> <Panel defaultSize={75} className="ide-react-file-tree-panel">
<FileTree onInit={onFileTreeInit} onSelect={onFileTreeSelect} /> <FileTree
onInit={onFileTreeInit}
onSelect={onFileTreeSelect}
onDelete={onFileTreeDelete}
/>
</Panel> </Panel>
<VerticalResizeHandle /> <VerticalResizeHandle />
<Panel defaultSize={25}> <Panel defaultSize={25}>
@@ -42,7 +42,6 @@ export default function EditorSidebar({
</Panel> </Panel>
</PanelGroup> </PanelGroup>
</aside> </aside>
<aside className="ide-react-placeholder-editor-sidebar history-file-tree" />
</> </>
) )
} }
@@ -1,35 +1,26 @@
import { Panel, PanelGroup } from 'react-resizable-panels' import { Panel, PanelGroup } from 'react-resizable-panels'
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle' import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
import React, { ElementType, useEffect } from 'react' import React, { ElementType } from 'react'
import useScopeValue from '@/shared/hooks/use-scope-value' import useScopeValue from '@/shared/hooks/use-scope-value'
import SourceEditor from '@/features/source-editor/components/source-editor' import SourceEditor from '@/features/source-editor/components/source-editor'
import { import { EditorScopeValue } from '@/features/ide-react/context/editor-manager-context'
EditorScopeValue,
useEditorManagerContext,
} from '@/features/ide-react/context/editor-manager-context'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro' import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
import { EditorProps } from '@/features/ide-react/components/editor/editor' import classNames from 'classnames'
import { useTranslation } from 'react-i18next'
const symbolPaletteComponents = importOverleafModules( const symbolPaletteComponents = importOverleafModules(
'sourceEditorSymbolPalette' 'sourceEditorSymbolPalette'
) as { import: { default: ElementType }; path: string }[] ) as { import: { default: ElementType }; path: string }[]
export function EditorPane({ export type EditorPaneProps = {
shouldPersistLayout, shouldPersistLayout?: boolean
openDocId, show: boolean
fileTreeReady, }
}: EditorProps) {
const { openDocId: openDocWithId } = useEditorManagerContext()
export function EditorPane({ shouldPersistLayout, show }: EditorPaneProps) {
const { t } = useTranslation()
const [editor] = useScopeValue<EditorScopeValue>('editor') const [editor] = useScopeValue<EditorScopeValue>('editor')
useEffect(() => {
if (!fileTreeReady || !openDocId) {
return
}
openDocWithId(openDocId)
}, [fileTreeReady, openDocId, openDocWithId])
return ( return (
<PanelGroup <PanelGroup
autoSaveId={ autoSaveId={
@@ -39,21 +30,27 @@ export function EditorPane({
} }
direction="vertical" direction="vertical"
units="pixels" units="pixels"
className={classNames({ hidden: !show })}
> >
<Panel <Panel
id="editor" id="sourceEditor"
order={1} order={1}
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
}} }}
> >
{!!editor.sharejs_doc && {(!editor.sharejs_doc || editor.opening) &&
!editor.opening && !editor.error_state &&
editor.multiSelectedCount === 0 && !!editor.open_doc_id ? (
!editor.error_state ? ( <div className="loading-panel">
<SourceEditor /> <span>
<i className="fa fa-spin fa-refresh" />
&nbsp;&nbsp;{t('loading')}…
</span>
</div>
) : null} ) : null}
<SourceEditor />
</Panel> </Panel>
{editor.showSymbolPalette ? ( {editor.showSymbolPalette ? (
<> <>
@@ -65,7 +62,7 @@ export function EditorPane({
minSize={250} minSize={250}
maxSize={336} maxSize={336}
> >
<div className="ide-react-placeholder-symbol-palette"> <div className="ide-react-symbol-palette">
{symbolPaletteComponents.map( {symbolPaletteComponents.map(
({ import: { default: Component }, path }) => ( ({ import: { default: Component }, path }) => (
<Component key={path} /> <Component key={path} />
@@ -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>
)
}
@@ -0,0 +1,14 @@
import { useTranslation } from 'react-i18next'
export default function NoOpenDocPane() {
const { t } = useTranslation()
return (
<div className="loading-panel">
<span>
<i className="fa fa-arrow-left" />
&nbsp;&nbsp;{t('open_a_file_on_the_left')}
</span>
</div>
)
}
@@ -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>
)
}
@@ -4,15 +4,19 @@ import { useUserContext } from '@/shared/context/user-context'
import { useReferencesContext } from '@/features/ide-react/context/references-context' import { useReferencesContext } from '@/features/ide-react/context/references-context'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context' import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useConnectionContext } from '@/features/ide-react/context/connection-context' import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { FileTreeSelectHandler } from '@/features/ide-react/types/file-tree' import {
FileTreeDeleteHandler,
FileTreeSelectHandler,
} from '@/features/ide-react/types/file-tree'
import { RefProviders } from '../../../../../types/user' import { RefProviders } from '../../../../../types/user'
type FileTreeProps = { type FileTreeProps = {
onInit: () => void onInit: () => void
onSelect: FileTreeSelectHandler onSelect: FileTreeSelectHandler
onDelete: FileTreeDeleteHandler
} }
export function FileTree({ onInit, onSelect }: FileTreeProps) { export function FileTree({ onInit, onSelect, onDelete }: FileTreeProps) {
const user = useUserContext() const user = useUserContext()
const { indexAllReferences } = useReferencesContext() const { indexAllReferences } = useReferencesContext()
const { setStartedFreeTrial } = useIdeReactContext() const { setStartedFreeTrial } = useIdeReactContext()
@@ -43,6 +47,7 @@ export function FileTree({ onInit, onSelect }: FileTreeProps) {
isConnected={isConnected} isConnected={isConnected}
onInit={onInit} onInit={onInit}
onSelect={onSelect} onSelect={onSelect}
onDelete={onDelete}
/> />
</div> </div>
) )
@@ -0,0 +1,5 @@
import React from 'react'
export function HistorySidebar() {
return <aside className="ide-react-editor-sidebar history-file-tree" />
}
@@ -58,7 +58,7 @@ export default function PlaceholderEditorAndPdf({
minSize={250} minSize={250}
maxSize={336} maxSize={336}
> >
<div className="ide-react-placeholder-symbol-palette "> <div className="ide-react-symbol-palette ">
Symbol palette placeholder Symbol palette placeholder
</div> </div>
</Panel> </Panel>
@@ -10,7 +10,7 @@ export default function PlaceholderEditorSidebar({
shouldPersistLayout, shouldPersistLayout,
}: PlaceholderEditorSidebarProps) { }: PlaceholderEditorSidebarProps) {
return ( return (
<aside className="ide-react-placeholder-editor-sidebar"> <aside className="ide-react-editor-sidebar">
<PanelGroup <PanelGroup
autoSaveId={ autoSaveId={
shouldPersistLayout ? 'ide-react-editor-sidebar-layout' : undefined shouldPersistLayout ? 'ide-react-editor-sidebar-layout' : undefined
@@ -1,8 +1,6 @@
import React from 'react' import React from 'react'
import ChatToggleButton from '@/features/editor-navigation-toolbar/components/chat-toggle-button' import ChatToggleButton from '@/features/editor-navigation-toolbar/components/chat-toggle-button'
import ShareProjectButton from '@/features/editor-navigation-toolbar/components/share-project-button'
import HistoryToggleButton from '@/features/editor-navigation-toolbar/components/history-toggle-button' import HistoryToggleButton from '@/features/editor-navigation-toolbar/components/history-toggle-button'
import LayoutDropdownButton from '@/features/editor-navigation-toolbar/components/layout-dropdown-button'
type PlaceholderHeaderProps = { type PlaceholderHeaderProps = {
chatIsOpen: boolean chatIsOpen: boolean
@@ -17,8 +15,6 @@ export default function PlaceholderHeader({
historyIsOpen, historyIsOpen,
setHistoryIsOpen, setHistoryIsOpen,
}: PlaceholderHeaderProps) { }: PlaceholderHeaderProps) {
function handleOpenShareModal() {}
function toggleChatOpen() { function toggleChatOpen() {
setChatIsOpen(!chatIsOpen) setChatIsOpen(!chatIsOpen)
} }
@@ -31,12 +27,10 @@ export default function PlaceholderHeader({
<header className="toolbar toolbar-header"> <header className="toolbar toolbar-header">
<div className="toolbar-left">Header placeholder</div> <div className="toolbar-left">Header placeholder</div>
<div className="toolbar-right"> <div className="toolbar-right">
<ShareProjectButton onClick={handleOpenShareModal} />
<HistoryToggleButton <HistoryToggleButton
historyIsOpen={historyIsOpen} historyIsOpen={historyIsOpen}
onClick={toggleHistoryOpen} onClick={toggleHistoryOpen}
/> />
<LayoutDropdownButton />
<ChatToggleButton <ChatToggleButton
chatIsOpen={chatIsOpen} chatIsOpen={chatIsOpen}
onClick={toggleChatOpen} onClick={toggleChatOpen}
@@ -15,7 +15,7 @@ export default function PlaceholderHistory({
const [leftColumnIsOpen, setLeftColumnIsOpen] = useState(true) const [leftColumnIsOpen, setLeftColumnIsOpen] = useState(true)
const leftColumnContent = ( const leftColumnContent = (
<aside className="ide-react-placeholder-editor-sidebar history-file-tree"> <aside className="ide-react-editor-sidebar history-file-tree">
History file tree placeholder History file tree placeholder
</aside> </aside>
) )
@@ -74,7 +74,7 @@ export default function TwoColumnMainContent({
tooltipWhenClosed={t('tooltip_show_filetree')} tooltipWhenClosed={t('tooltip_show_filetree')}
/> />
</HorizontalResizeHandle> </HorizontalResizeHandle>
<Panel>{rightColumnContent}</Panel> <Panel className="ide-react-panel">{rightColumnContent}</Panel>
</PanelGroup> </PanelGroup>
) )
} }
@@ -1,4 +1,3 @@
/* eslint-disable no-use-before-define */
import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store' import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
import { import {
createContext, createContext,
@@ -10,7 +9,6 @@ import {
useRef, useRef,
useState, useState,
} from 'react' } from 'react'
import customLocalStorage from '@/infrastructure/local-storage'
import { sendMB } from '@/infrastructure/event-tracking' import { sendMB } from '@/infrastructure/event-tracking'
import useScopeValue from '@/shared/hooks/use-scope-value' import useScopeValue from '@/shared/hooks/use-scope-value'
import { useIdeContext } from '@/shared/context/ide-context' import { useIdeContext } from '@/shared/context/ide-context'
@@ -27,6 +25,8 @@ import { Doc } from '../../../../../types/doc'
import { useFileTreeData } from '@/shared/context/file-tree-data-context' import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { findDocEntityById } from '@/features/ide-react/util/find-doc-entity-by-id' import { findDocEntityById } from '@/features/ide-react/util/find-doc-entity-by-id'
import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter' import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
import { useTranslation } from 'react-i18next'
interface GotoOffsetOptions { interface GotoOffsetOptions {
gotoOffset: number gotoOffset: number
@@ -43,17 +43,16 @@ type EditorManager = {
getEditorType: () => 'cm6' | 'cm6-rich-text' | null getEditorType: () => 'cm6' | 'cm6-rich-text' | null
showSymbolPalette: boolean showSymbolPalette: boolean
currentDocument: Document currentDocument: Document
currentDocumentId: string | null
getCurrentDocValue: () => string | null getCurrentDocValue: () => string | null
getCurrentDocId: () => string | null getCurrentDocId: () => string | null
startIgnoringExternalUpdates: () => void startIgnoringExternalUpdates: () => void
stopIgnoringExternalUpdates: () => void stopIgnoringExternalUpdates: () => void
openDocId: (docId: string, options?: OpenDocOptions) => void openDocId: (docId: string, options?: OpenDocOptions) => void
openDoc: (document: Document, options?: OpenDocOptions) => void openDoc: (document: Doc, options?: OpenDocOptions) => void
jumpToLine: (options: GotoLineOptions) => void jumpToLine: (options: GotoLineOptions) => void
} }
type DocumentOpenCallback = (error: Error | null, document?: Document) => void
function hasGotoLine(options: OpenDocOptions): options is GotoLineOptions { function hasGotoLine(options: OpenDocOptions): options is GotoLineOptions {
return typeof options.gotoLine === 'number' return typeof options.gotoLine === 'number'
} }
@@ -73,13 +72,13 @@ export type EditorScopeValue = {
wantTrackChanges: boolean wantTrackChanges: boolean
showVisual: boolean showVisual: boolean
newSourceEditor: boolean newSourceEditor: boolean
multiSelectedCount: number
error_state: boolean error_state: boolean
} }
export function populateEditorScope(store: ReactScopeValueStore) { export function populateEditorScope(
const projectId = window.project_id store: ReactScopeValueStore,
projectId: string
) {
// This value is not used in the React code. It's just here to prevent errors // This value is not used in the React code. It's just here to prevent errors
// from EditorProvider // from EditorProvider
store.set('state.loading', false) store.set('state.loading', false)
@@ -97,10 +96,6 @@ export function populateEditorScope(store: ReactScopeValueStore) {
wantTrackChanges: false, wantTrackChanges: false,
// No Ace here // No Ace here
newSourceEditor: true, newSourceEditor: true,
// TODO: Ignore multiSelectedCount and just default it to zero for now until
// we get to the file tree. It seems to be stored in two places in the
// Angular scope and needs further investigation.
multiSelectedCount: 0,
error_state: false, error_state: false,
}) })
store.persisted('editor.showVisual', false, `editor.mode.${projectId}`, { store.persisted('editor.showVisual', false, `editor.mode.${projectId}`, {
@@ -112,11 +107,12 @@ export function populateEditorScope(store: ReactScopeValueStore) {
const EditorManagerContext = createContext<EditorManager | undefined>(undefined) const EditorManagerContext = createContext<EditorManager | undefined>(undefined)
export const EditorManagerProvider: FC = ({ children }) => { export const EditorManagerProvider: FC = ({ children }) => {
const { t } = useTranslation()
const ide = useIdeContext() const ide = useIdeContext()
const { reportError, eventEmitter, eventLog, projectId } = const { reportError, eventEmitter, eventLog } = useIdeReactContext()
useIdeReactContext()
const { socket, disconnect } = useConnectionContext() const { socket, disconnect } = useConnectionContext()
const { view, setView } = useLayoutContext() const { view, setView } = useLayoutContext()
const { showGenericMessageModal, genericModalVisible } = useModalsContext()
const [showSymbolPalette, setShowSymbolPalette] = useScopeValue<boolean>( const [showSymbolPalette, setShowSymbolPalette] = useScopeValue<boolean>(
'editor.showSymbolPalette' 'editor.showSymbolPalette'
@@ -153,8 +149,24 @@ export const EditorManagerProvider: FC = ({ children }) => {
}) })
) )
// 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: Document
error: Error | string
meta?: Record<string, any>
editorContent?: string
} | null>(null)
const [docTooLongErrorShown, setDocTooLongErrorShown] = useState(false) const [docTooLongErrorShown, setDocTooLongErrorShown] = useState(false)
useEffect(() => {
if (!genericModalVisible) {
setDocTooLongErrorShown(false)
}
}, [genericModalVisible])
const [openDocs] = useState( const [openDocs] = useState(
() => () =>
new OpenDocuments( new OpenDocuments(
@@ -206,9 +218,6 @@ export const EditorManagerProvider: FC = ({ children }) => {
[] []
) )
// Ignore insertSymbol from Angular EditorManager because it's only required
// for Ace.
const jumpToLine = useCallback( const jumpToLine = useCallback(
(options: GotoLineOptions) => { (options: GotoLineOptions) => {
goToLineEmitter( goToLineEmitter(
@@ -224,19 +233,6 @@ export const EditorManagerProvider: FC = ({ children }) => {
document.off() document.off()
} }
const openDocWithId = useCallback(
(docId: string, options: OpenDocOptions = {}) => {
const doc = findDocEntityById(fileTreeData, docId)
if (!doc) {
return
}
openDoc(doc, options)
},
// @ts-ignore
[fileTreeData, openDoc]
)
// @ts-ignore
const attachErrorHandlerToDocument = useCallback( const attachErrorHandlerToDocument = useCallback(
(doc: Doc, document: Document) => { (doc: Doc, document: Document) => {
document.on( document.on(
@@ -246,74 +242,11 @@ export const EditorManagerProvider: FC = ({ children }) => {
meta?: Record<string, any>, meta?: Record<string, any>,
editorContent?: string editorContent?: string
) => { ) => {
const message = setDocError({ doc, document, error, meta, editorContent })
typeof error === 'string' ? error : error?.message ?? ''
if (/maxDocLength/.test(message)) {
setDocTooLongErrorShown(true)
openDoc(doc, { forceReopen: true })
// TODO: MIGRATION: Show generic modal here
// const genericMessageModal = this.ide.showGenericMessageModal(
// 'Document Too Long',
// 'Sorry, this file is too long to be edited manually. Please upload it directly.'
// )
// genericMessageModal.result.finally(() => {
// this.$scope.docTooLongErrorShown = false
// })
} else if (/too many comments or tracked changes/.test(message)) {
// TODO: MIGRATION: Show generic modal here
// this.ide.showGenericMessageModal(
// 'Too many comments or tracked changes',
// 'Sorry, this file has too many comments or tracked changes. Please try accepting or rejecting some existing changes, or resolving and deleting some comments.'
// )
} else if (!docTooLongErrorShown) {
// Do not allow this doc to open another error modal.
document.off('error')
// Preserve the sharejs contents before the teardown.
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()
disconnect()
reportError(error, meta)
// Tell the user about the error state.
setIsInErrorState(true)
// TODO: MIGRATION: Show out-of-sync modal
// this.ide.showOutOfSyncModal(
// 'Out of sync',
// "Sorry, this file has gone out of sync and we need to do a full refresh. <br> <a target='_blank' rel='noopener noreferrer' href='/learn/Kb/Editor_out_of_sync_problems'>Please see this help guide for more information</a>",
// editorContent
// )
// Do not forceReopen the document.
return
}
eventEmitter.once('project:joined', () => {
openDoc(doc, { forceReopen: true })
})
} }
) )
}, },
[ []
disconnect,
docTooLongErrorShown,
eventEmitter,
// @ts-ignore
openDoc,
reportError,
setIsInErrorState,
]
) )
const bindToDocumentEvents = useCallback( const bindToDocumentEvents = useCallback(
@@ -329,17 +262,20 @@ export const EditorManagerProvider: FC = ({ children }) => {
_.property(['meta', 'type'])(update) === 'external' && _.property(['meta', 'type'])(update) === 'external' &&
_.property(['meta', 'source'])(update) === 'git-bridge' _.property(['meta', 'source'])(update) === 'git-bridge'
) { ) {
// eslint-disable-next-line no-useless-return
return return
} }
// TODO: MIGRATION: Show generic modal here showGenericMessageModal(
// this.ide.showGenericMessageModal( t('document_updated_externally'),
// 'Document Updated Externally', t('document_updated_externally_detail')
// 'This document was just updated externally. Any recent changes you have made may have been overwritten. To see previous versions please look in the history.' )
// )
}) })
}, },
[attachErrorHandlerToDocument, ignoringExternalUpdates] [
attachErrorHandlerToDocument,
ignoringExternalUpdates,
showGenericMessageModal,
t,
]
) )
const syncTimeoutRef = useRef<number | null>(null) const syncTimeoutRef = useRef<number | null>(null)
@@ -378,39 +314,42 @@ export const EditorManagerProvider: FC = ({ children }) => {
) )
const doOpenNewDocument = useCallback( const doOpenNewDocument = useCallback(
(doc: Doc, callback: DocumentOpenCallback) => { (doc: Doc) =>
debugConsole.log('[doOpenNewDocument] Opening...') new Promise<Document>((resolve, reject) => {
const newDocument = openDocs.getDocument(doc._id) debugConsole.log('[doOpenNewDocument] Opening...')
if (!newDocument) { const newDocument = openDocs.getDocument(doc._id)
debugConsole.error(`No open document with ID '${doc._id}' found`) if (!newDocument) {
return debugConsole.error(`No open document with ID '${doc._id}' found`)
} reject(new Error('no open document found'))
const preJoinEpoch = ++editorOpenDocEpochRef.current return
newDocument.join(error => {
if (error) {
debugConsole.log(
`[doOpenNewDocument] error joining doc ${doc._id}`,
error
)
callback(error)
} }
if (editorOpenDocEpochRef.current !== preJoinEpoch) { const preJoinEpoch = ++editorOpenDocEpochRef.current
debugConsole.log( newDocument.join(error => {
`[openNewDocument] editorOpenDocEpoch mismatch ${editorOpenDocEpochRef.current} vs ${preJoinEpoch}` if (error) {
) debugConsole.log(
newDocument.leaveAndCleanUp() `[doOpenNewDocument] error joining doc ${doc._id}`,
return callback(new Error('another document was loaded')) error
} )
bindToDocumentEvents(doc, newDocument) reject(error)
return callback(null, newDocument) return
}) }
},
if (editorOpenDocEpochRef.current !== preJoinEpoch) {
debugConsole.log(
`[doOpenNewDocument] editorOpenDocEpoch mismatch ${editorOpenDocEpochRef.current} vs ${preJoinEpoch}`
)
newDocument.leaveAndCleanUp()
reject(new Error('another document was loaded'))
}
bindToDocumentEvents(doc, newDocument)
resolve(newDocument)
})
}),
[bindToDocumentEvents, openDocs] [bindToDocumentEvents, openDocs]
) )
// @ts-ignore
const openNewDocument = useCallback( const openNewDocument = useCallback(
(doc: Doc, callback: DocumentOpenCallback) => { async (doc: Doc): Promise<Document> => {
// Leave the current document // Leave the current document
// - when we are opening a different new one, to avoid race conditions // - when we are opening a different new one, to avoid race conditions
// between leaving and joining the same document // between leaving and joining the same document
@@ -434,32 +373,30 @@ export const EditorManagerProvider: FC = ({ children }) => {
// from scratch -- read: no corrupted internal state. // from scratch -- read: no corrupted internal state.
const preLeaveEpoch = ++editorOpenDocEpochRef.current const preLeaveEpoch = ++editorOpenDocEpochRef.current
currentDocument.leaveAndCleanUp(error => { try {
if (error) { await currentDocument.leaveAndCleanUpPromise()
debugConsole.log( } catch (error) {
`[_openNewDocument] error leaving doc ${currentDocId}`, debugConsole.log(
error `[openNewDocument] error leaving doc ${currentDocId}`,
) error
return callback(error) )
} throw error
if (editorOpenDocEpochRef.current !== preLeaveEpoch) { }
debugConsole.log(
`[openNewDocument] editorOpenDocEpoch mismatch ${editorOpenDocEpochRef.current} vs ${preLeaveEpoch}` if (editorOpenDocEpochRef.current !== preLeaveEpoch) {
) debugConsole.log(
callback(new Error('another document was loaded')) `[openNewDocument] editorOpenDocEpoch mismatch ${editorOpenDocEpochRef.current} vs ${preLeaveEpoch}`
} )
doOpenNewDocument(doc, callback) throw new Error('another document was loaded')
}) }
} else {
doOpenNewDocument(doc, callback)
} }
return doOpenNewDocument(doc)
}, },
[attachErrorHandlerToDocument, doOpenNewDocument, currentDocument] [attachErrorHandlerToDocument, doOpenNewDocument, currentDocument]
) )
// @ts-ignore
const openDoc = useCallback( const openDoc = useCallback(
(doc: Doc, options: OpenDocOptions = {}) => { async (doc: Doc, options: OpenDocOptions = {}) => {
debugConsole.log(`[openDoc] Opening ${doc._id}`) debugConsole.log(`[openDoc] Opening ${doc._id}`)
if (view === 'editor') { if (view === 'editor') {
// store position of previous doc before switching docs // store position of previous doc before switching docs
@@ -491,9 +428,6 @@ export const EditorManagerProvider: FC = ({ children }) => {
// Note: only use forceReopen:true to override this when the document is // Note: only use forceReopen:true to override this when the document is
// out of sync and needs to be reloaded from the server. // out of sync and needs to be reloaded from the server.
if (doc._id === openDocId && !options.forceReopen) { if (doc._id === openDocId && !options.forceReopen) {
window.dispatchEvent(
new CustomEvent('editor.openDoc', { detail: doc._id })
)
done(false) done(false)
return return
} }
@@ -501,56 +435,139 @@ export const EditorManagerProvider: FC = ({ children }) => {
// We're now either opening a new document or reloading a broken one. // We're now either opening a new document or reloading a broken one.
setOpenDocId(doc._id) setOpenDocId(doc._id)
setOpenDocName(doc.name) setOpenDocName(doc.name)
customLocalStorage.setItem(`doc.open_id.${projectId}`, doc._id)
setOpening(true) setOpening(true)
openNewDocument(doc, (error: Error | null, document: Document) => { try {
const document = await openNewDocument(doc)
syncTrackChangesState(document)
eventEmitter.emit('doc:opened')
setOpening(false)
setCurrentDocument(document)
done(true)
} catch (error: any) {
if (error?.message === 'another document was loaded') { if (error?.message === 'another document was loaded') {
debugConsole.log( debugConsole.log(
`[openDoc] another document was loaded while ${doc._id} was loading` `[openDoc] another document was loaded while ${doc._id} was loading`
) )
return return
} }
if (error != null) { showGenericMessageModal(
// TODO: MIGRATION: Show generic modal here t('error_opening_document'),
// this.ide.showGenericMessageModal( t('error_opening_document_detail')
// 'Error opening document', )
// 'Sorry, something went wrong opening this document. Please try again.' }
// )
return
}
syncTrackChangesState(document)
eventEmitter.emit('doc:opened')
setOpening(false)
setCurrentDocument(document)
done(true)
})
}, },
[ [
eventEmitter, eventEmitter,
jumpToLine, jumpToLine,
openDocId, openDocId,
openNewDocument, openNewDocument,
projectId,
setCurrentDocument, setCurrentDocument,
setOpenDocId, setOpenDocId,
setOpenDocName, setOpenDocName,
setOpening, setOpening,
setView, setView,
showGenericMessageModal,
syncTrackChangesState, syncTrackChangesState,
t,
view, view,
] ]
) )
const openDocWithId = useCallback(
(docId: string, options: OpenDocOptions = {}) => {
const doc = findDocEntityById(fileTreeData, docId)
if (!doc) {
return
}
openDoc(doc, options)
},
[fileTreeData, openDoc]
)
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 })
showGenericMessageModal(
t('document_too_long'),
t('document_too_long_detail')
)
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()
disconnect()
reportError(error, meta)
// Tell the user about the error state.
setIsInErrorState(true)
// TODO: MIGRATION: Show out-of-sync modal
// this.ide.showOutOfSyncModal(
// 'Out of sync',
// "Sorry, this file has gone out of sync and we need to do a full refresh. <br> <a target='_blank' rel='noopener noreferrer' href='/learn/Kb/Editor_out_of_sync_problems'>Please see this help guide for more information</a>",
// editorContent
// )
// Do not forceReopen the document.
return
}
const handleProjectJoined = () => {
openDoc(doc, { forceReopen: true })
}
eventEmitter.once('project:joined', handleProjectJoined)
return () => {
eventEmitter.off('project:joined', handleProjectJoined)
}
}
}, [
disconnect,
docError,
docTooLongErrorShown,
eventEmitter,
openDoc,
reportError,
setIsInErrorState,
showGenericMessageModal,
t,
])
const editorManager = useMemo( const editorManager = useMemo(
() => ({ () => ({
getEditorType, getEditorType,
showSymbolPalette, showSymbolPalette,
currentDocument, currentDocument,
currentDocumentId: openDocId,
getCurrentDocValue, getCurrentDocValue,
getCurrentDocId, getCurrentDocId,
startIgnoringExternalUpdates, startIgnoringExternalUpdates,
@@ -563,6 +580,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
getEditorType, getEditorType,
showSymbolPalette, showSymbolPalette,
currentDocument, currentDocument,
openDocId,
getCurrentDocValue, getCurrentDocValue,
getCurrentDocId, getCurrentDocId,
startIgnoringExternalUpdates, startIgnoringExternalUpdates,
@@ -25,6 +25,7 @@ import { populateSettingsScope } from '@/features/ide-react/scope-adapters/setti
import { populateOnlineUsersScope } from '@/features/ide-react/context/online-users-context' import { populateOnlineUsersScope } from '@/features/ide-react/context/online-users-context'
import { populateReferenceScope } from '@/features/ide-react/context/references-context' import { populateReferenceScope } from '@/features/ide-react/context/references-context'
import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter' import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter'
import getMeta from '@/utils/meta'
type IdeReactContextValue = { type IdeReactContextValue = {
projectId: string projectId: string
@@ -43,7 +44,6 @@ const IdeReactContext = createContext<IdeReactContextValue | undefined>(
function populateIdeReactScope(store: ReactScopeValueStore) { function populateIdeReactScope(store: ReactScopeValueStore) {
store.set('sync_tex_error', false) store.set('sync_tex_error', false)
store.set('settings', window.userSettings)
} }
function populateProjectScope(store: ReactScopeValueStore) { function populateProjectScope(store: ReactScopeValueStore) {
@@ -59,7 +59,7 @@ function populateFileTreeScope(store: ReactScopeValueStore) {
store.set('docs', []) store.set('docs', [])
} }
function createReactScopeValueStore() { function createReactScopeValueStore(projectId: string) {
const scopeStore = new ReactScopeValueStore() const scopeStore = new ReactScopeValueStore()
// Populate the scope value store with default values that will be used by // Populate the scope value store with default values that will be used by
@@ -68,7 +68,7 @@ function createReactScopeValueStore() {
// initialization code together with the context and would only populate // initialization code together with the context and would only populate
// necessary values in the store, but this is simpler for now // necessary values in the store, but this is simpler for now
populateIdeReactScope(scopeStore) populateIdeReactScope(scopeStore)
populateEditorScope(scopeStore) populateEditorScope(scopeStore, projectId)
populateLayoutScope(scopeStore) populateLayoutScope(scopeStore)
populateProjectScope(scopeStore) populateProjectScope(scopeStore)
populatePdfScope(scopeStore) populatePdfScope(scopeStore)
@@ -87,7 +87,7 @@ function createReactScopeValueStore() {
const projectId = window.project_id const projectId = window.project_id
export const IdeReactProvider: FC = ({ children }) => { export const IdeReactProvider: FC = ({ children }) => {
const [scopeStore] = useState(createReactScopeValueStore) const [scopeStore] = useState(() => createReactScopeValueStore(projectId))
const [eventEmitter] = useState(createIdeEventEmitter) const [eventEmitter] = useState(createIdeEventEmitter)
const [scopeEventEmitter] = useState( const [scopeEventEmitter] = useState(
() => new ReactScopeEventEmitter(eventEmitter) () => new ReactScopeEventEmitter(eventEmitter)
@@ -101,7 +101,7 @@ export const IdeReactProvider: FC = ({ children }) => {
(error: any, meta?: Record<string, any>) => { (error: any, meta?: Record<string, any>) => {
const metadata = { const metadata = {
...meta, ...meta,
user_id: window.user_id, user_id: getMeta('ol-user_id'),
project_id: projectId, project_id: projectId,
// @ts-ignore // @ts-ignore
client_id: socket.socket.sessionid, client_id: socket.socket.sessionid,
@@ -18,6 +18,8 @@ import { useEditorContext } from '@/shared/context/editor-context'
import { useIdeContext } from '@/shared/context/ide-context' import { useIdeContext } from '@/shared/context/ide-context'
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener' import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
import useEventListener from '@/shared/hooks/use-event-listener' import useEventListener from '@/shared/hooks/use-event-listener'
import { FileTreeFindResult } from '@/features/ide-react/types/file-tree'
import { Project } from '../../../../../types/project'
type DocumentMetadata = { type DocumentMetadata = {
labels: string[] labels: string[]
@@ -55,11 +57,19 @@ export const MetadataProvider: FC = ({ children }) => {
const debouncerRef = useRef<Map<string, number>>(new Map()) // DocId => Timeout const debouncerRef = useRef<Map<string, number>>(new Map()) // DocId => Timeout
useEffect(() => { useEffect(() => {
eventEmitter.on('entity:deleted', entity => { const handleEntityDeleted = (entity: FileTreeFindResult) => {
if (entity.type === 'doc') { if (entity.type === 'doc') {
setDocuments(documents => _.omit(documents, entity.id)) setDocuments(documents => {
return _.omit(documents, entity.entity._id)
})
} }
}) }
eventEmitter.on('entity:deleted', handleEntityDeleted)
return () => {
eventEmitter.off('entity:deleted', handleEntityDeleted)
}
}, [eventEmitter]) }, [eventEmitter])
useEffect(() => { useEffect(() => {
@@ -76,10 +86,7 @@ export const MetadataProvider: FC = ({ children }) => {
}, []) }, [])
const getAllLabels = useCallback( const getAllLabels = useCallback(
() => () => _.flattenDeep(Object.values(documents).map(meta => meta.labels)),
_.flattenDeep(
Array.from(Object.values(documents)).map(meta => meta.labels)
),
[documents] [documents]
) )
@@ -171,7 +178,7 @@ export const MetadataProvider: FC = ({ children }) => {
useEventListener('editor:metadata-outdated', handleMetadataOutdated) useEventListener('editor:metadata-outdated', handleMetadataOutdated)
useEffect(() => { useEffect(() => {
eventEmitter.once('project:joined', ({ project }) => { const handleProjectJoined = ({ project }: { project: Project }) => {
if (project.deletedByExternalDataSource) { if (project.deletedByExternalDataSource) {
// TODO: MIGRATION: Show generic message modal here // TODO: MIGRATION: Show generic message modal here
/* /*
@@ -190,7 +197,13 @@ If the project has been renamed please look in your project list for a new proje
loadProjectMetaFromServer() loadProjectMetaFromServer()
} }
}, 200) }, 200)
}) }
eventEmitter.once('project:joined', handleProjectJoined)
return () => {
eventEmitter.off('project:joined', handleProjectJoined)
}
}, [eventEmitter, loadProjectMetaFromServer, permissionsLevel]) }, [eventEmitter, loadProjectMetaFromServer, permissionsLevel])
const value = useMemo<MetadataContextValue>( const value = useMemo<MetadataContextValue>(
@@ -11,6 +11,7 @@ import GenericMessageModal, {
} from '@/features/ide-react/components/modals/generic-message-modal' } from '@/features/ide-react/components/modals/generic-message-modal'
type ModalsContextValue = { type ModalsContextValue = {
genericModalVisible: boolean
showGenericMessageModal: ( showGenericMessageModal: (
title: GenericMessageModalOwnProps['title'], title: GenericMessageModalOwnProps['title'],
message: GenericMessageModalOwnProps['message'] message: GenericMessageModalOwnProps['message']
@@ -42,8 +43,9 @@ export const ModalsContextProvider: FC = ({ children }) => {
const value = useMemo<ModalsContextValue>( const value = useMemo<ModalsContextValue>(
() => ({ () => ({
showGenericMessageModal, showGenericMessageModal,
genericModalVisible: showGenericModal,
}), }),
[showGenericMessageModal] [showGenericMessageModal, showGenericModal]
) )
return ( return (
@@ -36,15 +36,15 @@ export const ReactContextRoot: FC = ({ children }) => {
<LocalCompileProvider> <LocalCompileProvider>
<DetachCompileProvider> <DetachCompileProvider>
<ChatProvider> <ChatProvider>
<EditorManagerProvider> <ModalsContextProvider>
<OnlineUsersProvider> <EditorManagerProvider>
<MetadataProvider> <OnlineUsersProvider>
<ModalsContextProvider> <MetadataProvider>
{children} {children}
</ModalsContextProvider> </MetadataProvider>
</MetadataProvider> </OnlineUsersProvider>
</OnlineUsersProvider> </EditorManagerProvider>
</EditorManagerProvider> </ModalsContextProvider>
</ChatProvider> </ChatProvider>
</DetachCompileProvider> </DetachCompileProvider>
</LocalCompileProvider> </LocalCompileProvider>
@@ -4,6 +4,7 @@ import { PermissionsLevel } from '@/features/ide-react/types/permissions-level'
import { ShareJsDoc } from '@/features/ide-react/editor/share-js-doc' import { ShareJsDoc } from '@/features/ide-react/editor/share-js-doc'
import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options' import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
import { CursorPosition } from '@/features/ide-react/types/cursor-position' import { CursorPosition } from '@/features/ide-react/types/cursor-position'
import { FileTreeFindResult } from '@/features/ide-react/types/file-tree'
export type IdeEvents = { export type IdeEvents = {
'project:joined': [{ project: Project; permissionsLevel: PermissionsLevel }] 'project:joined': [{ project: Project; permissionsLevel: PermissionsLevel }]
@@ -26,8 +27,7 @@ export type IdeEvents = {
'comment:start_adding': [] 'comment:start_adding': []
'references:should-reindex': [] 'references:should-reindex': []
// TODO: MIGRATION: Create a proper type for entity when migrating the file tree 'entity:deleted': [entity: FileTreeFindResult]
'entity:deleted': [entity: Record<string, any>]
} }
export type IdeEventEmitter = Emitter<IdeEvents> export type IdeEventEmitter = Emitter<IdeEvents>
@@ -224,6 +224,18 @@ export class Document extends EventEmitter {
}) })
} }
leaveAndCleanUpPromise() {
return new Promise<void>((resolve, reject) => {
this.leaveAndCleanUp((error?: Error) => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}
join(callback?: JoinCallback) { join(callback?: JoinCallback) {
this.wantToBeJoined = true this.wantToBeJoined = true
this.cancelLeave() this.cancelLeave()
@@ -36,3 +36,5 @@ export type FileTreeFindResult =
export type FileTreeSelectHandler = ( export type FileTreeSelectHandler = (
selectedEntities: FileTreeFindResult[] selectedEntities: FileTreeFindResult[]
) => void ) => void
export type FileTreeDeleteHandler = (entity: FileTreeFindResult) => void
@@ -37,6 +37,8 @@
.ide-react-body { .ide-react-body {
flex-grow: 1; flex-grow: 1;
background-color: @pdf-bg;
overflow-y: hidden;
} }
.horizontal-resize-handle { .horizontal-resize-handle {
@@ -71,6 +73,13 @@
&::after { &::after {
top: 75%; top: 75%;
} }
.synctex-controls {
left: -8px;
margin: 0;
// Ensure that SyncTex controls appear in front of PDF viewer controls and logs pane
z-index: 12;
}
} }
.vertical-resize-handle { .vertical-resize-handle {
@@ -91,20 +100,13 @@
} }
} }
// Styles for placeholder elements that will eventually be replaced .ide-react-editor-sidebar {
.ide-react-placeholder-chat {
background-color: var(--editor-toolbar-bg);
color: var(--neutral-20);
height: 100%;
}
.ide-react-placeholder-editor-sidebar {
height: 100%; height: 100%;
background-color: @file-tree-bg; background-color: @file-tree-bg;
color: var(--neutral-20); color: var(--neutral-20);
} }
.ide-react-placeholder-symbol-palette { .ide-react-symbol-palette {
height: 100%; height: 100%;
background-color: @symbol-palette-bg; background-color: @symbol-palette-bg;
color: var(--neutral-20); color: var(--neutral-20);
@@ -113,3 +115,15 @@
.ide-react-file-tree-panel { .ide-react-file-tree-panel {
display: flex; display: flex;
} }
// Ensure an element with class "full-size", such as the binary file view, stays within the bounds of the panel
.ide-react-panel {
position: relative;
}
// Styles for placeholder elements that will eventually be replaced
.ide-react-placeholder-chat {
background-color: var(--editor-toolbar-bg);
color: var(--neutral-20);
height: 100%;
}
+8
View File
@@ -410,6 +410,10 @@
"do_you_want_to_change_your_primary_email_address_to": "Do you want to change your primary email address to <b>__email__</b>?", "do_you_want_to_change_your_primary_email_address_to": "Do you want to change your primary email address to <b>__email__</b>?",
"do_you_want_to_overwrite_them": "Do you want to overwrite them?", "do_you_want_to_overwrite_them": "Do you want to overwrite them?",
"document_history": "Document history", "document_history": "Document history",
"document_too_long": "Document Too Long",
"document_too_long_detail": "Sorry, this file is too long to be edited manually. Please upload it directly.",
"document_updated_externally": "Document Updated Externally",
"document_updated_externally_detail": "This document was just updated externally. Any recent changes you have made may have been overwritten. To see previous versions, please look in the history.",
"documentation": "Documentation", "documentation": "Documentation",
"does_not_contain_or_significantly_match_your_email": "does not contain or significantly match your email", "does_not_contain_or_significantly_match_your_email": "does not contain or significantly match your email",
"doesnt_match": "Doesn’t match", "doesnt_match": "Doesn’t match",
@@ -512,6 +516,8 @@
"enter_your_new_password": "Enter your new password", "enter_your_new_password": "Enter your new password",
"entry_point": "Entry point", "entry_point": "Entry point",
"error": "Error", "error": "Error",
"error_opening_document": "Error opening document",
"error_opening_document_detail": "Sorry, something went wrong opening this document. Please try again.",
"error_performing_request": "An error has occurred while performing your request.", "error_performing_request": "An error has occurred while performing your request.",
"es": "Spanish", "es": "Spanish",
"estimated_number_of_users": "Estimated Number of Users", "estimated_number_of_users": "Estimated Number of Users",
@@ -1825,6 +1831,8 @@
"token_read_only": "token read-only", "token_read_only": "token read-only",
"token_read_write": "token read-write", "token_read_write": "token read-write",
"too_many_attempts": "Too many attempts. Please wait for a while and try again.", "too_many_attempts": "Too many attempts. Please wait for a while and try again.",
"too_many_comments_or_tracked_changes": "Too many comments or tracked changes",
"too_many_comments_or_tracked_changes_detail": "Sorry, this file has too many comments or tracked changes. Please try accepting or rejecting some existing changes, or resolving and deleting some comments.",
"too_many_files_uploaded_throttled_short_period": "Too many files uploaded, your uploads have been throttled for a short period. Please wait 15 minutes and try again.", "too_many_files_uploaded_throttled_short_period": "Too many files uploaded, your uploads have been throttled for a short period. Please wait 15 minutes and try again.",
"too_many_requests": "Too many requests were received in a short space of time. Please wait for a few moments and try again.", "too_many_requests": "Too many requests were received in a short space of time. Please wait for a few moments and try again.",
"too_many_search_results": "There are more than 100 results. Please refine your search.", "too_many_search_results": "There are more than 100 results. Please refine your search.",
+2
View File
@@ -1,4 +1,6 @@
export type FileRef = { export type FileRef = {
_id: string _id: string
name: string name: string
created?: string
linkedFileData?: any
} }