فهرست منبع

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
ilkin-overleaf 2 سال پیش
والد
کامیت
2bbead57ec
28فایلهای تغییر یافته به همراه593 افزوده شده و 311 حذف شده
  1. 11 0
      services/web/frontend/extracted-translations.json
  2. 9 3
      services/web/frontend/js/features/file-tree/components/file-tree-root.jsx
  3. 9 3
      services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.js
  4. 18 20
      services/web/frontend/js/features/ide-react/components/editor-and-pdf.tsx
  5. 171 25
      services/web/frontend/js/features/ide-react/components/editor-and-sidebar.tsx
  6. 12 13
      services/web/frontend/js/features/ide-react/components/editor-sidebar.tsx
  7. 23 26
      services/web/frontend/js/features/ide-react/components/editor/editor-pane.tsx
  8. 17 0
      services/web/frontend/js/features/ide-react/components/editor/multiple-selection-pane.tsx
  9. 14 0
      services/web/frontend/js/features/ide-react/components/editor/no-open-doc-pane.tsx
  10. 13 0
      services/web/frontend/js/features/ide-react/components/editor/no-selection-pane.tsx
  11. 7 2
      services/web/frontend/js/features/ide-react/components/file-tree.tsx
  12. 5 0
      services/web/frontend/js/features/ide-react/components/history-sidebar.tsx
  13. 1 1
      services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-and-pdf.tsx
  14. 1 1
      services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-sidebar.tsx
  15. 0 6
      services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-header.tsx
  16. 1 1
      services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-history.tsx
  17. 1 1
      services/web/frontend/js/features/ide-react/components/layout/two-column-main-content.tsx
  18. 193 175
      services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx
  19. 5 5
      services/web/frontend/js/features/ide-react/context/ide-react-context.tsx
  20. 22 9
      services/web/frontend/js/features/ide-react/context/metadata-context.tsx
  21. 3 1
      services/web/frontend/js/features/ide-react/context/modals-context.tsx
  22. 8 8
      services/web/frontend/js/features/ide-react/context/react-context-root.tsx
  23. 2 2
      services/web/frontend/js/features/ide-react/create-ide-event-emitter.ts
  24. 12 0
      services/web/frontend/js/features/ide-react/editor/document.ts
  25. 2 0
      services/web/frontend/js/features/ide-react/types/file-tree.ts
  26. 23 9
      services/web/frontend/stylesheets/app/editor/ide-react.less
  27. 8 0
      services/web/locales/en.json
  28. 2 0
      services/web/types/file-ref.ts

+ 11 - 0
services/web/frontend/extracted-translations.json

@@ -268,6 +268,10 @@
   "do_this_later": "",
   "do_you_want_to_change_your_primary_email_address_to": "",
   "do_you_want_to_overwrite_them": "",
+  "document_too_long": "",
+  "document_too_long_detail": "",
+  "document_updated_externally": "",
+  "document_updated_externally_detail": "",
   "documentation": "",
   "doesnt_match": "",
   "doing_this_allow_log_in_through_institution": "",
@@ -336,6 +340,8 @@
   "enter_image_url": "",
   "entry_point": "",
   "error": "",
+  "error_opening_document": "",
+  "error_opening_document_detail": "",
   "error_performing_request": "",
   "example_project": "",
   "existing_plan_active_until_term_end": "",
@@ -366,6 +372,7 @@
   "file_outline": "",
   "file_size": "",
   "files_cannot_include_invalid_characters": "",
+  "files_selected": "",
   "find_out_more": "",
   "find_out_more_about_institution_login": "",
   "find_out_more_about_the_file_outline": "",
@@ -745,6 +752,7 @@
   "no_projects": "",
   "no_resolved_threads": "",
   "no_search_results": "",
+  "no_selection_select_file": "",
   "no_symbols_found": "",
   "no_thanks_cancel_now": "",
   "normal": "",
@@ -770,6 +778,7 @@
   "only_group_admin_or_managers_can_delete_your_account_4": "",
   "only_group_admin_or_managers_can_delete_your_account_5": "",
   "only_importer_can_refresh": "",
+  "open_a_file_on_the_left": "",
   "open_file": "",
   "open_link": "",
   "open_project": "",
@@ -1195,6 +1204,8 @@
   "token_read_only": "",
   "token_read_write": "",
   "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_requests": "",
   "too_many_search_results": "",

+ 9 - 3
services/web/frontend/js/features/file-tree/components/file-tree-root.jsx

@@ -29,6 +29,7 @@ const FileTreeRoot = React.memo(function FileTreeRoot({
   setStartedFreeTrial,
   onSelect,
   onInit,
+  onDelete,
   isConnected,
 }) {
   const { _id: projectId } = useProjectContext(projectContextPropTypes)
@@ -52,7 +53,7 @@ const FileTreeRoot = React.memo(function FileTreeRoot({
       <FileTreeToolbar />
       <FileTreeContextMenu />
       <FileTreeInner>
-        <FileTreeRootFolder />
+        <FileTreeRootFolder onDelete={onDelete} />
       </FileTreeInner>
       <FileTreeModalDelete />
       <FileTreeModalCreateFile />
@@ -62,8 +63,8 @@ const FileTreeRoot = React.memo(function FileTreeRoot({
   )
 })
 
-function FileTreeRootFolder() {
-  useFileTreeSocketListener()
+function FileTreeRootFolder({ onDelete }) {
+  useFileTreeSocketListener(onDelete)
   const { fileTreeData } = useFileTreeData()
 
   const { isOver, dropRef } = useDroppable(fileTreeData._id)
@@ -93,9 +94,14 @@ function FileTreeRootFolder() {
   )
 }
 
+FileTreeRootFolder.propTypes = {
+  onDelete: PropTypes.func,
+}
+
 FileTreeRoot.propTypes = {
   onSelect: PropTypes.func.isRequired,
   onInit: PropTypes.func.isRequired,
+  onDelete: PropTypes.func,
   isConnected: PropTypes.bool.isRequired,
   setRefProviderEnabled: PropTypes.func.isRequired,
   setStartedFreeTrial: PropTypes.func.isRequired,

+ 9 - 3
services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.js

@@ -4,9 +4,10 @@ import PropTypes from 'prop-types'
 import { useUserContext } from '../../../shared/context/user-context'
 import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
 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({
     id: PropTypes.string.isRequired,
   })
@@ -21,7 +22,7 @@ export function useFileTreeSocketListener() {
   } = useFileTreeData()
   const { selectedEntityIds, selectedEntityParentIds, select, unselect } =
     useFileTreeSelectable()
-  const socket = window._ide && window._ide.socket
+  const { socket } = useIdeContext()
 
   const selectEntityIfCreatedByUser = useCallback(
     // hack to automatically re-open refreshed linked files
@@ -52,6 +53,7 @@ export function useFileTreeSocketListener() {
 
   useEffect(() => {
     function handleDispatchDelete(entityId) {
+      const entity = findInTree(fileTreeData, entityId)
       unselect(entityId)
       if (selectedEntityParentIds.has(entityId)) {
         // we're deleting a folder with a selected children so we need to
@@ -67,6 +69,9 @@ export function useFileTreeSocketListener() {
         }
       }
       dispatchDelete(entityId)
+      if (onDelete) {
+        onDelete(entity)
+      }
     }
     if (socket) socket.on('removeEntity', handleDispatchDelete)
     return () => {
@@ -79,6 +84,7 @@ export function useFileTreeSocketListener() {
     fileTreeData,
     selectedEntityIds,
     selectedEntityParentIds,
+    onDelete,
   ])
 
   useEffect(() => {

+ 18 - 20
services/web/frontend/js/features/ide-react/components/editor/editor.tsx → services/web/frontend/js/features/ide-react/components/editor-and-pdf.tsx

@@ -1,4 +1,4 @@
-import { useRef } from 'react'
+import { ReactNode, useRef } from 'react'
 import { useTranslation } from 'react-i18next'
 import {
   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 useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
 import { useLayoutContext } from '@/shared/context/layout-context'
-import { EditorPane } from '@/features/ide-react/components/editor/editor-pane'
-import PlaceholderFile from '@/features/ide-react/components/layout/placeholder/placeholder-file'
-import PlaceholderPdf from '@/features/ide-react/components/layout/placeholder/placeholder-pdf'
+import PdfPreview from '@/features/pdf-preview/components/pdf-preview'
+import { DefaultSynctexControl } from '@/features/pdf-preview/components/detach-synctex-control'
 import classnames from 'classnames'
 
 export type EditorProps = {
   shouldPersistLayout?: boolean
-  openDocId: string | null
-  fileTreeReady: boolean
+  editorContent: ReactNode
 }
 
-export default function Editor({
+export default function EditorAndPdf({
   shouldPersistLayout = false,
-  openDocId,
-  fileTreeReady,
+  editorContent,
 }: EditorProps) {
   const { t } = useTranslation()
   const { view, pdfLayout, changeLayout } = useLayoutContext()
@@ -35,10 +32,6 @@ export default function Editor({
 
   useCollapsiblePanel(pdfIsOpen, pdfPanelRef)
 
-  if (view === 'file') {
-    return <PlaceholderFile />
-  }
-
   const historyIsOpen = view === 'history'
 
   function setPdfIsOpen(isOpen: boolean) {
@@ -58,12 +51,13 @@ export default function Editor({
       className={classnames({ hide: historyIsOpen })}
     >
       {editorIsVisible ? (
-        <Panel id="editor" order={1} defaultSize={50}>
-          <EditorPane
-            shouldPersistLayout={shouldPersistLayout}
-            openDocId={openDocId}
-            fileTreeReady={fileTreeReady}
-          />
+        <Panel
+          id="editor"
+          order={1}
+          defaultSize={50}
+          className="ide-react-panel"
+        >
+          {editorContent}
         </Panel>
       ) : null}
       {isDualPane ? (
@@ -76,6 +70,9 @@ export default function Editor({
             tooltipWhenOpen={t('tooltip_hide_pdf')}
             tooltipWhenClosed={t('tooltip_show_pdf')}
           />
+          <div className="synctex-controls">
+            <DefaultSynctexControl />
+          </div>
         </HorizontalResizeHandle>
       ) : null}
       {pdfIsOpen ? (
@@ -87,8 +84,9 @@ export default function Editor({
           minSize={5}
           collapsible
           onCollapse={collapsed => setPdfIsOpen(!collapsed)}
+          className="ide-react-panel"
         >
-          <PlaceholderPdf />
+          <PdfPreview />
         </Panel>
       ) : null}
     </PanelGroup>

+ 171 - 25
services/web/frontend/js/features/ide-react/components/editor-and-sidebar.tsx

@@ -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 Editor from '@/features/ide-react/components/editor/editor'
 import EditorSidebar from '@/features/ide-react/components/editor-sidebar'
-import customLocalStorage from '@/infrastructure/local-storage'
 import History from '@/features/ide-react/components/history'
 import { HistoryProvider } from '@/features/history/context/history-context'
 import { useProjectContext } from '@/shared/context/project-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 = {
   shouldPersistLayout: boolean
@@ -15,6 +30,25 @@ type EditorAndSidebarProps = {
   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({
   shouldPersistLayout,
   leftColumnDefaultSize,
@@ -22,12 +56,21 @@ export function EditorAndSidebar({
 }: EditorAndSidebarProps) {
   const [leftColumnIsOpen, setLeftColumnIsOpen] = useState(true)
   const { rootDocId, _id: projectId } = useProjectContext()
+  const { eventEmitter } = useIdeReactContext()
+  const { openDocId: openDocWithId, currentDocumentId } =
+    useEditorManagerContext()
   const { view } = useLayoutContext()
   const historyIsOpen = view === 'history'
 
-  const [openDocId, setOpenDocId] = useState(
-    () => customLocalStorage.getItem(`doc.open_id.${projectId}`) || rootDocId
+  // Persist the open document ID in local storage
+  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 handleFileTreeInit = useCallback(() => {
@@ -36,37 +79,140 @@ export function EditorAndSidebar({
 
   const handleFileTreeSelect = useCallback(
     (selectedEntities: FileTreeFindResult[]) => {
-      const firstDocId =
-        selectedEntities.find(result => result.type === 'doc')?.entity._id ||
-        null
-      setOpenDocId(firstDocId)
+      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') {
+        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
       shouldPersistLayout={shouldPersistLayout}
       onFileTreeInit={handleFileTreeInit}
       onFileTreeSelect={handleFileTreeSelect}
+      onFileTreeDelete={handleFileTreeDelete}
     />
   )
 
-  const rightColumnContent = (
-    <>
-      {/* Recreate the history context when the history view is toggled */}
-      {historyIsOpen && (
-        <HistoryProvider>
-          <History />
-        </HistoryProvider>
-      )}
-      <Editor
+  let rightColumnContent
+
+  if (historyIsOpen) {
+    rightColumnContent = (
+      <HistoryProvider>
+        <History />
+      </HistoryProvider>
+    )
+  } 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}
-        openDocId={openDocId}
-        fileTreeReady={fileTreeReady}
+        show={openEntity?.type === 'doc' && selectedEntityCount === 1}
       />
-    </>
-  )
+    )
+    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 (
     <TwoColumnMainContent

+ 12 - 13
services/web/frontend/js/features/ide-react/components/editor-sidebar.tsx

@@ -2,31 +2,27 @@ import React from 'react'
 import { Panel, PanelGroup } from 'react-resizable-panels'
 import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
 import { FileTree } from '@/features/ide-react/components/file-tree'
-import { useLayoutContext } from '@/shared/context/layout-context'
-import classnames from 'classnames'
-import { FileTreeSelectHandler } from '@/features/ide-react/types/file-tree'
+import {
+  FileTreeDeleteHandler,
+  FileTreeSelectHandler,
+} from '@/features/ide-react/types/file-tree'
 
 type EditorSidebarProps = {
   shouldPersistLayout: boolean
   onFileTreeInit: () => void
   onFileTreeSelect: FileTreeSelectHandler
+  onFileTreeDelete: FileTreeDeleteHandler
 }
 
 export default function EditorSidebar({
   shouldPersistLayout,
   onFileTreeInit,
   onFileTreeSelect,
+  onFileTreeDelete,
 }: EditorSidebarProps) {
-  const { view } = useLayoutContext()
-  const historyIsOpen = view === 'history'
-
   return (
     <>
-      <aside
-        className={classnames('ide-react-placeholder-editor-sidebar', {
-          hide: historyIsOpen,
-        })}
-      >
+      <aside className="ide-react-editor-sidebar">
         <PanelGroup
           autoSaveId={
             shouldPersistLayout ? 'ide-react-editor-sidebar-layout' : undefined
@@ -34,7 +30,11 @@ export default function EditorSidebar({
           direction="vertical"
         >
           <Panel defaultSize={75} className="ide-react-file-tree-panel">
-            <FileTree onInit={onFileTreeInit} onSelect={onFileTreeSelect} />
+            <FileTree
+              onInit={onFileTreeInit}
+              onSelect={onFileTreeSelect}
+              onDelete={onFileTreeDelete}
+            />
           </Panel>
           <VerticalResizeHandle />
           <Panel defaultSize={25}>
@@ -42,7 +42,6 @@ export default function EditorSidebar({
           </Panel>
         </PanelGroup>
       </aside>
-      <aside className="ide-react-placeholder-editor-sidebar history-file-tree" />
     </>
   )
 }

+ 23 - 26
services/web/frontend/js/features/ide-react/components/editor/editor-pane.tsx

@@ -1,35 +1,26 @@
 import { Panel, PanelGroup } from 'react-resizable-panels'
 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 SourceEditor from '@/features/source-editor/components/source-editor'
-import {
-  EditorScopeValue,
-  useEditorManagerContext,
-} from '@/features/ide-react/context/editor-manager-context'
+import { EditorScopeValue } from '@/features/ide-react/context/editor-manager-context'
 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(
   'sourceEditorSymbolPalette'
 ) as { import: { default: ElementType }; path: string }[]
 
-export function EditorPane({
-  shouldPersistLayout,
-  openDocId,
-  fileTreeReady,
-}: EditorProps) {
-  const { openDocId: openDocWithId } = useEditorManagerContext()
+export type EditorPaneProps = {
+  shouldPersistLayout?: boolean
+  show: boolean
+}
 
+export function EditorPane({ shouldPersistLayout, show }: EditorPaneProps) {
+  const { t } = useTranslation()
   const [editor] = useScopeValue<EditorScopeValue>('editor')
 
-  useEffect(() => {
-    if (!fileTreeReady || !openDocId) {
-      return
-    }
-    openDocWithId(openDocId)
-  }, [fileTreeReady, openDocId, openDocWithId])
-
   return (
     <PanelGroup
       autoSaveId={
@@ -39,21 +30,27 @@ export function EditorPane({
       }
       direction="vertical"
       units="pixels"
+      className={classNames({ hidden: !show })}
     >
       <Panel
-        id="editor"
+        id="sourceEditor"
         order={1}
         style={{
           display: 'flex',
           flexDirection: 'column',
         }}
       >
-        {!!editor.sharejs_doc &&
-        !editor.opening &&
-        editor.multiSelectedCount === 0 &&
-        !editor.error_state ? (
-          <SourceEditor />
+        {(!editor.sharejs_doc || editor.opening) &&
+        !editor.error_state &&
+        !!editor.open_doc_id ? (
+          <div className="loading-panel">
+            <span>
+              <i className="fa fa-spin fa-refresh" />
+              &nbsp;&nbsp;{t('loading')}…
+            </span>
+          </div>
         ) : null}
+        <SourceEditor />
       </Panel>
       {editor.showSymbolPalette ? (
         <>
@@ -65,7 +62,7 @@ export function EditorPane({
             minSize={250}
             maxSize={336}
           >
-            <div className="ide-react-placeholder-symbol-palette">
+            <div className="ide-react-symbol-palette">
               {symbolPaletteComponents.map(
                 ({ import: { default: Component }, path }) => (
                   <Component key={path} />

+ 17 - 0
services/web/frontend/js/features/ide-react/components/editor/multiple-selection-pane.tsx

@@ -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>
+  )
+}

+ 14 - 0
services/web/frontend/js/features/ide-react/components/editor/no-open-doc-pane.tsx

@@ -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>
+  )
+}

+ 13 - 0
services/web/frontend/js/features/ide-react/components/editor/no-selection-pane.tsx

@@ -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>
+  )
+}

+ 7 - 2
services/web/frontend/js/features/ide-react/components/file-tree.tsx

@@ -4,15 +4,19 @@ import { useUserContext } from '@/shared/context/user-context'
 import { useReferencesContext } from '@/features/ide-react/context/references-context'
 import { useIdeReactContext } from '@/features/ide-react/context/ide-react-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'
 
 type FileTreeProps = {
   onInit: () => void
   onSelect: FileTreeSelectHandler
+  onDelete: FileTreeDeleteHandler
 }
 
-export function FileTree({ onInit, onSelect }: FileTreeProps) {
+export function FileTree({ onInit, onSelect, onDelete }: FileTreeProps) {
   const user = useUserContext()
   const { indexAllReferences } = useReferencesContext()
   const { setStartedFreeTrial } = useIdeReactContext()
@@ -43,6 +47,7 @@ export function FileTree({ onInit, onSelect }: FileTreeProps) {
         isConnected={isConnected}
         onInit={onInit}
         onSelect={onSelect}
+        onDelete={onDelete}
       />
     </div>
   )

+ 5 - 0
services/web/frontend/js/features/ide-react/components/history-sidebar.tsx

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

+ 1 - 1
services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-and-pdf.tsx

@@ -58,7 +58,7 @@ export default function PlaceholderEditorAndPdf({
                 minSize={250}
                 maxSize={336}
               >
-                <div className="ide-react-placeholder-symbol-palette ">
+                <div className="ide-react-symbol-palette ">
                   Symbol palette placeholder
                 </div>
               </Panel>

+ 1 - 1
services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-sidebar.tsx

@@ -10,7 +10,7 @@ export default function PlaceholderEditorSidebar({
   shouldPersistLayout,
 }: PlaceholderEditorSidebarProps) {
   return (
-    <aside className="ide-react-placeholder-editor-sidebar">
+    <aside className="ide-react-editor-sidebar">
       <PanelGroup
         autoSaveId={
           shouldPersistLayout ? 'ide-react-editor-sidebar-layout' : undefined

+ 0 - 6
services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-header.tsx

@@ -1,8 +1,6 @@
 import React from 'react'
 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 LayoutDropdownButton from '@/features/editor-navigation-toolbar/components/layout-dropdown-button'
 
 type PlaceholderHeaderProps = {
   chatIsOpen: boolean
@@ -17,8 +15,6 @@ export default function PlaceholderHeader({
   historyIsOpen,
   setHistoryIsOpen,
 }: PlaceholderHeaderProps) {
-  function handleOpenShareModal() {}
-
   function toggleChatOpen() {
     setChatIsOpen(!chatIsOpen)
   }
@@ -31,12 +27,10 @@ export default function PlaceholderHeader({
     <header className="toolbar toolbar-header">
       <div className="toolbar-left">Header placeholder</div>
       <div className="toolbar-right">
-        <ShareProjectButton onClick={handleOpenShareModal} />
         <HistoryToggleButton
           historyIsOpen={historyIsOpen}
           onClick={toggleHistoryOpen}
         />
-        <LayoutDropdownButton />
         <ChatToggleButton
           chatIsOpen={chatIsOpen}
           onClick={toggleChatOpen}

+ 1 - 1
services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-history.tsx

@@ -15,7 +15,7 @@ export default function PlaceholderHistory({
   const [leftColumnIsOpen, setLeftColumnIsOpen] = useState(true)
 
   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
     </aside>
   )

+ 1 - 1
services/web/frontend/js/features/ide-react/components/layout/two-column-main-content.tsx

@@ -74,7 +74,7 @@ export default function TwoColumnMainContent({
           tooltipWhenClosed={t('tooltip_show_filetree')}
         />
       </HorizontalResizeHandle>
-      <Panel>{rightColumnContent}</Panel>
+      <Panel className="ide-react-panel">{rightColumnContent}</Panel>
     </PanelGroup>
   )
 }

+ 193 - 175
services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx

@@ -1,4 +1,3 @@
-/* eslint-disable no-use-before-define */
 import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
 import {
   createContext,
@@ -10,7 +9,6 @@ import {
   useRef,
   useState,
 } from 'react'
-import customLocalStorage from '@/infrastructure/local-storage'
 import { sendMB } from '@/infrastructure/event-tracking'
 import useScopeValue from '@/shared/hooks/use-scope-value'
 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 { findDocEntityById } 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'
 
 interface GotoOffsetOptions {
   gotoOffset: number
@@ -43,17 +43,16 @@ type EditorManager = {
   getEditorType: () => 'cm6' | 'cm6-rich-text' | null
   showSymbolPalette: boolean
   currentDocument: Document
+  currentDocumentId: string | null
   getCurrentDocValue: () => string | null
   getCurrentDocId: () => string | null
   startIgnoringExternalUpdates: () => void
   stopIgnoringExternalUpdates: () => void
   openDocId: (docId: string, options?: OpenDocOptions) => void
-  openDoc: (document: Document, options?: OpenDocOptions) => void
+  openDoc: (document: Doc, options?: OpenDocOptions) => void
   jumpToLine: (options: GotoLineOptions) => void
 }
 
-type DocumentOpenCallback = (error: Error | null, document?: Document) => void
-
 function hasGotoLine(options: OpenDocOptions): options is GotoLineOptions {
   return typeof options.gotoLine === 'number'
 }
@@ -73,13 +72,13 @@ export type EditorScopeValue = {
   wantTrackChanges: boolean
   showVisual: boolean
   newSourceEditor: boolean
-  multiSelectedCount: number
   error_state: boolean
 }
 
-export function populateEditorScope(store: ReactScopeValueStore) {
-  const projectId = window.project_id
-
+export function populateEditorScope(
+  store: ReactScopeValueStore,
+  projectId: string
+) {
   // This value is not used in the React code. It's just here to prevent errors
   // from EditorProvider
   store.set('state.loading', false)
@@ -97,10 +96,6 @@ export function populateEditorScope(store: ReactScopeValueStore) {
     wantTrackChanges: false,
     // No Ace here
     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,
   })
   store.persisted('editor.showVisual', false, `editor.mode.${projectId}`, {
@@ -112,11 +107,12 @@ export function populateEditorScope(store: ReactScopeValueStore) {
 const EditorManagerContext = createContext<EditorManager | undefined>(undefined)
 
 export const EditorManagerProvider: FC = ({ children }) => {
+  const { t } = useTranslation()
   const ide = useIdeContext()
-  const { reportError, eventEmitter, eventLog, projectId } =
-    useIdeReactContext()
+  const { reportError, eventEmitter, eventLog } = useIdeReactContext()
   const { socket, disconnect } = useConnectionContext()
   const { view, setView } = useLayoutContext()
+  const { showGenericMessageModal, genericModalVisible } = useModalsContext()
 
   const [showSymbolPalette, setShowSymbolPalette] = useScopeValue<boolean>(
     '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)
 
+  useEffect(() => {
+    if (!genericModalVisible) {
+      setDocTooLongErrorShown(false)
+    }
+  }, [genericModalVisible])
+
   const [openDocs] = useState(
     () =>
       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(
     (options: GotoLineOptions) => {
       goToLineEmitter(
@@ -224,19 +233,6 @@ export const EditorManagerProvider: FC = ({ children }) => {
     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(
     (doc: Doc, document: Document) => {
       document.on(
@@ -246,74 +242,11 @@ export const EditorManagerProvider: FC = ({ children }) => {
           meta?: Record<string, any>,
           editorContent?: string
         ) => {
-          const message =
-            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 })
-          })
+          setDocError({ doc, document, error, meta, editorContent })
         }
       )
     },
-    [
-      disconnect,
-      docTooLongErrorShown,
-      eventEmitter,
-      // @ts-ignore
-      openDoc,
-      reportError,
-      setIsInErrorState,
-    ]
+    []
   )
 
   const bindToDocumentEvents = useCallback(
@@ -329,17 +262,20 @@ export const EditorManagerProvider: FC = ({ children }) => {
           _.property(['meta', 'type'])(update) === 'external' &&
           _.property(['meta', 'source'])(update) === 'git-bridge'
         ) {
-          // eslint-disable-next-line no-useless-return
           return
         }
-        // TODO: MIGRATION: Show generic modal here
-        // this.ide.showGenericMessageModal(
-        //   'Document Updated Externally',
-        //   '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.'
-        // )
+        showGenericMessageModal(
+          t('document_updated_externally'),
+          t('document_updated_externally_detail')
+        )
       })
     },
-    [attachErrorHandlerToDocument, ignoringExternalUpdates]
+    [
+      attachErrorHandlerToDocument,
+      ignoringExternalUpdates,
+      showGenericMessageModal,
+      t,
+    ]
   )
 
   const syncTimeoutRef = useRef<number | null>(null)
@@ -378,39 +314,42 @@ export const EditorManagerProvider: FC = ({ children }) => {
   )
 
   const doOpenNewDocument = useCallback(
-    (doc: Doc, callback: DocumentOpenCallback) => {
-      debugConsole.log('[doOpenNewDocument] Opening...')
-      const newDocument = openDocs.getDocument(doc._id)
-      if (!newDocument) {
-        debugConsole.error(`No open document with ID '${doc._id}' found`)
-        return
-      }
-      const preJoinEpoch = ++editorOpenDocEpochRef.current
-      newDocument.join(error => {
-        if (error) {
-          debugConsole.log(
-            `[doOpenNewDocument] error joining doc ${doc._id}`,
-            error
-          )
-          callback(error)
-        }
-        if (editorOpenDocEpochRef.current !== preJoinEpoch) {
-          debugConsole.log(
-            `[openNewDocument] editorOpenDocEpoch mismatch ${editorOpenDocEpochRef.current} vs ${preJoinEpoch}`
-          )
-          newDocument.leaveAndCleanUp()
-          return callback(new Error('another document was loaded'))
+    (doc: Doc) =>
+      new Promise<Document>((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
         }
-        bindToDocumentEvents(doc, newDocument)
-        return callback(null, newDocument)
-      })
-    },
+        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'))
+          }
+          bindToDocumentEvents(doc, newDocument)
+          resolve(newDocument)
+        })
+      }),
     [bindToDocumentEvents, openDocs]
   )
 
-  // @ts-ignore
   const openNewDocument = useCallback(
-    (doc: Doc, callback: DocumentOpenCallback) => {
+    async (doc: Doc): Promise<Document> => {
       // Leave the current document
       //  - when we are opening a different new one, to avoid race conditions
       //     between leaving and joining the same document
@@ -434,32 +373,30 @@ export const EditorManagerProvider: FC = ({ children }) => {
         //  from scratch -- read: no corrupted internal state.
         const preLeaveEpoch = ++editorOpenDocEpochRef.current
 
-        currentDocument.leaveAndCleanUp(error => {
-          if (error) {
-            debugConsole.log(
-              `[_openNewDocument] error leaving doc ${currentDocId}`,
-              error
-            )
-            return callback(error)
-          }
-          if (editorOpenDocEpochRef.current !== preLeaveEpoch) {
-            debugConsole.log(
-              `[openNewDocument] editorOpenDocEpoch mismatch ${editorOpenDocEpochRef.current} vs ${preLeaveEpoch}`
-            )
-            callback(new Error('another document was loaded'))
-          }
-          doOpenNewDocument(doc, callback)
-        })
-      } else {
-        doOpenNewDocument(doc, callback)
+        try {
+          await currentDocument.leaveAndCleanUpPromise()
+        } catch (error) {
+          debugConsole.log(
+            `[openNewDocument] error leaving doc ${currentDocId}`,
+            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]
   )
 
-  // @ts-ignore
   const openDoc = useCallback(
-    (doc: Doc, options: OpenDocOptions = {}) => {
+    async (doc: Doc, options: OpenDocOptions = {}) => {
       debugConsole.log(`[openDoc] Opening ${doc._id}`)
       if (view === 'editor') {
         // 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
       // out of sync and needs to be reloaded from the server.
       if (doc._id === openDocId && !options.forceReopen) {
-        window.dispatchEvent(
-          new CustomEvent('editor.openDoc', { detail: doc._id })
-        )
         done(false)
         return
       }
@@ -501,56 +435,139 @@ export const EditorManagerProvider: FC = ({ children }) => {
       // We're now either opening a new document or reloading a broken one.
       setOpenDocId(doc._id)
       setOpenDocName(doc.name)
-      customLocalStorage.setItem(`doc.open_id.${projectId}`, doc._id)
-
       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') {
           debugConsole.log(
             `[openDoc] another document was loaded while ${doc._id} was loading`
           )
           return
         }
-        if (error != null) {
-          // TODO: MIGRATION: Show generic modal here
-          // this.ide.showGenericMessageModal(
-          //   '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)
-      })
+        showGenericMessageModal(
+          t('error_opening_document'),
+          t('error_opening_document_detail')
+        )
+      }
     },
     [
       eventEmitter,
       jumpToLine,
       openDocId,
       openNewDocument,
-      projectId,
       setCurrentDocument,
       setOpenDocId,
       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]
+  )
+
+  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(
     () => ({
       getEditorType,
       showSymbolPalette,
       currentDocument,
+      currentDocumentId: openDocId,
       getCurrentDocValue,
       getCurrentDocId,
       startIgnoringExternalUpdates,
@@ -563,6 +580,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
       getEditorType,
       showSymbolPalette,
       currentDocument,
+      openDocId,
       getCurrentDocValue,
       getCurrentDocId,
       startIgnoringExternalUpdates,

+ 5 - 5
services/web/frontend/js/features/ide-react/context/ide-react-context.tsx

@@ -25,6 +25,7 @@ import { populateSettingsScope } from '@/features/ide-react/scope-adapters/setti
 import { populateOnlineUsersScope } from '@/features/ide-react/context/online-users-context'
 import { populateReferenceScope } from '@/features/ide-react/context/references-context'
 import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter'
+import getMeta from '@/utils/meta'
 
 type IdeReactContextValue = {
   projectId: string
@@ -43,7 +44,6 @@ const IdeReactContext = createContext<IdeReactContextValue | undefined>(
 
 function populateIdeReactScope(store: ReactScopeValueStore) {
   store.set('sync_tex_error', false)
-  store.set('settings', window.userSettings)
 }
 
 function populateProjectScope(store: ReactScopeValueStore) {
@@ -59,7 +59,7 @@ function populateFileTreeScope(store: ReactScopeValueStore) {
   store.set('docs', [])
 }
 
-function createReactScopeValueStore() {
+function createReactScopeValueStore(projectId: string) {
   const scopeStore = new ReactScopeValueStore()
 
   // 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
   // necessary values in the store, but this is simpler for now
   populateIdeReactScope(scopeStore)
-  populateEditorScope(scopeStore)
+  populateEditorScope(scopeStore, projectId)
   populateLayoutScope(scopeStore)
   populateProjectScope(scopeStore)
   populatePdfScope(scopeStore)
@@ -87,7 +87,7 @@ function createReactScopeValueStore() {
 const projectId = window.project_id
 
 export const IdeReactProvider: FC = ({ children }) => {
-  const [scopeStore] = useState(createReactScopeValueStore)
+  const [scopeStore] = useState(() => createReactScopeValueStore(projectId))
   const [eventEmitter] = useState(createIdeEventEmitter)
   const [scopeEventEmitter] = useState(
     () => new ReactScopeEventEmitter(eventEmitter)
@@ -101,7 +101,7 @@ export const IdeReactProvider: FC = ({ children }) => {
     (error: any, meta?: Record<string, any>) => {
       const metadata = {
         ...meta,
-        user_id: window.user_id,
+        user_id: getMeta('ol-user_id'),
         project_id: projectId,
         // @ts-ignore
         client_id: socket.socket.sessionid,

+ 22 - 9
services/web/frontend/js/features/ide-react/context/metadata-context.tsx

@@ -18,6 +18,8 @@ import { useEditorContext } from '@/shared/context/editor-context'
 import { useIdeContext } from '@/shared/context/ide-context'
 import useSocketListener from '@/features/ide-react/hooks/use-socket-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 = {
   labels: string[]
@@ -55,11 +57,19 @@ export const MetadataProvider: FC = ({ children }) => {
   const debouncerRef = useRef<Map<string, number>>(new Map()) // DocId => Timeout
 
   useEffect(() => {
-    eventEmitter.on('entity:deleted', entity => {
+    const handleEntityDeleted = (entity: FileTreeFindResult) => {
       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])
 
   useEffect(() => {
@@ -76,10 +86,7 @@ export const MetadataProvider: FC = ({ children }) => {
   }, [])
 
   const getAllLabels = useCallback(
-    () =>
-      _.flattenDeep(
-        Array.from(Object.values(documents)).map(meta => meta.labels)
-      ),
+    () => _.flattenDeep(Object.values(documents).map(meta => meta.labels)),
     [documents]
   )
 
@@ -171,7 +178,7 @@ export const MetadataProvider: FC = ({ children }) => {
   useEventListener('editor:metadata-outdated', handleMetadataOutdated)
 
   useEffect(() => {
-    eventEmitter.once('project:joined', ({ project }) => {
+    const handleProjectJoined = ({ project }: { project: Project }) => {
       if (project.deletedByExternalDataSource) {
         // 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()
         }
       }, 200)
-    })
+    }
+
+    eventEmitter.once('project:joined', handleProjectJoined)
+
+    return () => {
+      eventEmitter.off('project:joined', handleProjectJoined)
+    }
   }, [eventEmitter, loadProjectMetaFromServer, permissionsLevel])
 
   const value = useMemo<MetadataContextValue>(

+ 3 - 1
services/web/frontend/js/features/ide-react/context/modals-context.tsx

@@ -11,6 +11,7 @@ import GenericMessageModal, {
 } from '@/features/ide-react/components/modals/generic-message-modal'
 
 type ModalsContextValue = {
+  genericModalVisible: boolean
   showGenericMessageModal: (
     title: GenericMessageModalOwnProps['title'],
     message: GenericMessageModalOwnProps['message']
@@ -42,8 +43,9 @@ export const ModalsContextProvider: FC = ({ children }) => {
   const value = useMemo<ModalsContextValue>(
     () => ({
       showGenericMessageModal,
+      genericModalVisible: showGenericModal,
     }),
-    [showGenericMessageModal]
+    [showGenericMessageModal, showGenericModal]
   )
 
   return (

+ 8 - 8
services/web/frontend/js/features/ide-react/context/react-context-root.tsx

@@ -36,15 +36,15 @@ export const ReactContextRoot: FC = ({ children }) => {
                             <LocalCompileProvider>
                               <DetachCompileProvider>
                                 <ChatProvider>
-                                  <EditorManagerProvider>
-                                    <OnlineUsersProvider>
-                                      <MetadataProvider>
-                                        <ModalsContextProvider>
+                                  <ModalsContextProvider>
+                                    <EditorManagerProvider>
+                                      <OnlineUsersProvider>
+                                        <MetadataProvider>
                                           {children}
-                                        </ModalsContextProvider>
-                                      </MetadataProvider>
-                                    </OnlineUsersProvider>
-                                  </EditorManagerProvider>
+                                        </MetadataProvider>
+                                      </OnlineUsersProvider>
+                                    </EditorManagerProvider>
+                                  </ModalsContextProvider>
                                 </ChatProvider>
                               </DetachCompileProvider>
                             </LocalCompileProvider>

+ 2 - 2
services/web/frontend/js/features/ide-react/create-ide-event-emitter.ts

@@ -4,6 +4,7 @@ import { PermissionsLevel } from '@/features/ide-react/types/permissions-level'
 import { ShareJsDoc } from '@/features/ide-react/editor/share-js-doc'
 import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
 import { CursorPosition } from '@/features/ide-react/types/cursor-position'
+import { FileTreeFindResult } from '@/features/ide-react/types/file-tree'
 
 export type IdeEvents = {
   'project:joined': [{ project: Project; permissionsLevel: PermissionsLevel }]
@@ -26,8 +27,7 @@ export type IdeEvents = {
   'comment:start_adding': []
   'references:should-reindex': []
 
-  // TODO: MIGRATION: Create a proper type for entity when migrating the file tree
-  'entity:deleted': [entity: Record<string, any>]
+  'entity:deleted': [entity: FileTreeFindResult]
 }
 
 export type IdeEventEmitter = Emitter<IdeEvents>

+ 12 - 0
services/web/frontend/js/features/ide-react/editor/document.ts

@@ -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) {
     this.wantToBeJoined = true
     this.cancelLeave()

+ 2 - 0
services/web/frontend/js/features/ide-react/types/file-tree.ts

@@ -36,3 +36,5 @@ export type FileTreeFindResult =
 export type FileTreeSelectHandler = (
   selectedEntities: FileTreeFindResult[]
 ) => void
+
+export type FileTreeDeleteHandler = (entity: FileTreeFindResult) => void

+ 23 - 9
services/web/frontend/stylesheets/app/editor/ide-react.less

@@ -37,6 +37,8 @@
 
 .ide-react-body {
   flex-grow: 1;
+  background-color: @pdf-bg;
+  overflow-y: hidden;
 }
 
 .horizontal-resize-handle {
@@ -71,6 +73,13 @@
   &::after {
     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 {
@@ -91,20 +100,13 @@
   }
 }
 
-// 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%;
-}
-
-.ide-react-placeholder-editor-sidebar {
+.ide-react-editor-sidebar {
   height: 100%;
   background-color: @file-tree-bg;
   color: var(--neutral-20);
 }
 
-.ide-react-placeholder-symbol-palette {
+.ide-react-symbol-palette {
   height: 100%;
   background-color: @symbol-palette-bg;
   color: var(--neutral-20);
@@ -113,3 +115,15 @@
 .ide-react-file-tree-panel {
   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 - 0
services/web/locales/en.json

@@ -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_overwrite_them": "Do you want to overwrite them?",
   "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",
   "does_not_contain_or_significantly_match_your_email": "does not contain or significantly match your email",
   "doesnt_match": "Doesn’t match",
@@ -512,6 +516,8 @@
   "enter_your_new_password": "Enter your new password",
   "entry_point": "Entry point",
   "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.",
   "es": "Spanish",
   "estimated_number_of_users": "Estimated Number of Users",
@@ -1825,6 +1831,8 @@
   "token_read_only": "token read-only",
   "token_read_write": "token read-write",
   "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_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.",

+ 2 - 0
services/web/types/file-ref.ts

@@ -1,4 +1,6 @@
 export type FileRef = {
   _id: string
   name: string
+  created?: string
+  linkedFileData?: any
 }