Просмотр исходного кода

Merge pull request #15581 from overleaf/td-ide-page-restore-file

React IDE page: implement file restore and simplify some state

GitOrigin-RevId: ff63eb4f649156b58d9f8c4573cb6bd5b516a299
Tim Down 2 лет назад
Родитель
Сommit
a4b9947fe4

+ 3 - 2
services/web/frontend/js/features/history/context/hooks/use-restore-deleted-file.ts

@@ -38,13 +38,14 @@ export function useRestoreDeletedFile() {
         setState('complete')
         const { _id: id } = result.entity
         setView('editor')
+
+        // Once Angular is gone, these can be replaced with calls to context
+        // methods
         if (restoredFileMetadata.type === 'doc') {
           ide.editorManager.openDocId(id)
         } else {
           ide.binaryFilesManager.openFileWithId(id)
         }
-        // Get the file tree to select the entity that has just been restored
-        window.dispatchEvent(new CustomEvent('editor.openDoc', { detail: id }))
       }
     }
   }, [

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

@@ -11,7 +11,6 @@ import {
   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'
@@ -23,6 +22,8 @@ import { useEditorManagerContext } from '@/features/ide-react/context/editor-man
 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'
+import { BinaryFile } from '@/features/file-view/types/binary-file'
+import useScopeValue from '@/shared/hooks/use-scope-value'
 
 type EditorAndSidebarProps = {
   shouldPersistLayout: boolean
@@ -30,6 +31,18 @@ type EditorAndSidebarProps = {
   setLeftColumnDefaultSize: React.Dispatch<React.SetStateAction<number>>
 }
 
+function convertFileRefToBinaryFile(fileRef: FileRef): BinaryFile {
+  return {
+    _id: fileRef._id,
+    name: fileRef.name,
+    id: fileRef._id,
+    type: 'file',
+    selected: true,
+    linkedFileData: fileRef.linkedFileData,
+    created: fileRef.created ? new Date(fileRef.created) : new Date(),
+  }
+}
+
 // `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
@@ -39,12 +52,7 @@ type EditorAndSidebarProps = {
 // does too.
 function fileViewFile(fileRef: FileRef) {
   return {
-    _id: fileRef._id,
-    name: fileRef.name,
-    id: fileRef._id,
-    type: 'file',
-    selected: true,
-    linkedFileData: fileRef.linkedFileData,
+    ...convertFileRefToBinaryFile(fileRef),
     created: fileRef.created,
   }
 }
@@ -55,18 +63,18 @@ export function EditorAndSidebar({
   setLeftColumnDefaultSize,
 }: EditorAndSidebarProps) {
   const [leftColumnIsOpen, setLeftColumnIsOpen] = useState(true)
-  const { rootDocId, _id: projectId } = useProjectContext()
+  const { rootDocId } = useProjectContext()
   const { eventEmitter } = useIdeReactContext()
-  const { openDocId: openDocWithId, currentDocumentId } =
-    useEditorManagerContext()
+  const {
+    openDocId: openDocWithId,
+    currentDocumentId: openDocId,
+    openInitialDoc,
+  } = useEditorManagerContext()
   const { view } = useLayoutContext()
+  const [, setOpenFile] = useScopeValue<BinaryFile | null>('openFile')
+
   const historyIsOpen = view === 'history'
 
-  // 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)
@@ -92,11 +100,21 @@ export function EditorAndSidebar({
       }
 
       setOpenEntity(selected)
-      if (selected.type === 'doc') {
-        setOpenDocId(selected.entity._id)
+      if (selected.type === 'doc' && fileTreeReady) {
+        openDocWithId(selected.entity._id)
+      }
+
+      // Keep openFile scope value in sync with the file tree
+      const openFile =
+        selected.type === 'fileRef'
+          ? convertFileRefToBinaryFile(selected.entity)
+          : null
+      setOpenFile(openFile)
+      if (openFile) {
+        window.dispatchEvent(new CustomEvent('file-view:file-opened'))
       }
     },
-    [setOpenDocId]
+    [fileTreeReady, setOpenFile, openDocWithId]
   )
 
   const handleFileTreeDelete: FileTreeDeleteHandler = useCallback(
@@ -115,46 +133,38 @@ export function EditorAndSidebar({
   // 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) {
+    debugConsole.log(`openDocId changed to ${openDocId}`)
+    if (openDocId === null) {
       return
     }
 
     window.dispatchEvent(
-      new CustomEvent('editor.openDoc', { detail: currentDocumentId })
+      new CustomEvent('editor.openDoc', { detail: openDocId })
     )
-  }, [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])
+  }, [openDocId])
 
-  // Open a document in the editor when the local document ID changes
+  // Open a document once the file tree is ready
+  const initialOpenDoneRef = useRef(false)
   useEffect(() => {
-    if (!fileTreeReady || !openDocId) {
-      return
+    if (fileTreeReady && !initialOpenDoneRef.current) {
+      initialOpenDoneRef.current = true
+      openInitialDoc(rootDocId)
     }
-    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}
-    />
+  }, [fileTreeReady, openInitialDoc, rootDocId])
+
+  // Keep the editor file tree around so that it is available and up to date
+  // when restoring a file
+  const leftColumnContent = (
+    <>
+      <EditorSidebar
+        shouldShow={!historyIsOpen}
+        shouldPersistLayout={shouldPersistLayout}
+        onFileTreeInit={handleFileTreeInit}
+        onFileTreeSelect={handleFileTreeSelect}
+        onFileTreeDelete={handleFileTreeDelete}
+      />
+      {historyIsOpen ? <HistorySidebar /> : null}
+    </>
   )
 
   let rightColumnContent

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

@@ -6,8 +6,10 @@ import {
   FileTreeDeleteHandler,
   FileTreeSelectHandler,
 } from '@/features/ide-react/types/file-tree'
+import classNames from 'classnames'
 
 type EditorSidebarProps = {
+  shouldShow: boolean
   shouldPersistLayout: boolean
   onFileTreeInit: () => void
   onFileTreeSelect: FileTreeSelectHandler
@@ -15,6 +17,7 @@ type EditorSidebarProps = {
 }
 
 export default function EditorSidebar({
+  shouldShow,
   shouldPersistLayout,
   onFileTreeInit,
   onFileTreeSelect,
@@ -22,7 +25,11 @@ export default function EditorSidebar({
 }: EditorSidebarProps) {
   return (
     <>
-      <aside className="ide-react-editor-sidebar">
+      <aside
+        className={classNames('ide-react-editor-sidebar', {
+          hidden: !shouldShow,
+        })}
+      >
         <PanelGroup
           autoSaveId={
             shouldPersistLayout ? 'ide-react-editor-sidebar-layout' : undefined

+ 7 - 0
services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx

@@ -10,6 +10,7 @@ import EditorNavigationToolbar from '@/features/ide-react/components/editor-navi
 import ChatPane from '@/features/chat/components/chat-pane'
 import { useLayoutEventTracking } from '@/features/ide-react/hooks/use-layout-event-tracking'
 import useSocketListeners from '@/features/ide-react/hooks/use-socket-listeners'
+import { useOpenFile } from '@/features/ide-react/hooks/use-open-file'
 
 // This is filled with placeholder content while the real content is migrated
 // away from Angular
@@ -17,6 +18,12 @@ export default function IdePage() {
   useLayoutEventTracking()
   useSocketListeners()
 
+  // This returns a function to open a binary file but for now we just use the
+  // fact that it also patches in ide.binaryFilesManager. Once Angular is gone,
+  // we can remove this hook from here and use it in the history file restore
+  // component instead.
+  useOpenFile()
+
   const [leftColumnDefaultSize, setLeftColumnDefaultSize] = useState(20)
   const { registerUserActivity } = useConnectionContext()
 

+ 25 - 1
services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx

@@ -27,6 +27,7 @@ import { findDocEntityById } from '@/features/ide-react/util/find-doc-entity-by-
 import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
 import { useModalsContext } from '@/features/ide-react/context/modals-context'
 import { useTranslation } from 'react-i18next'
+import customLocalStorage from '@/infrastructure/local-storage'
 
 interface GotoOffsetOptions {
   gotoOffset: number
@@ -50,6 +51,7 @@ type EditorManager = {
   stopIgnoringExternalUpdates: () => void
   openDocId: (docId: string, options?: OpenDocOptions) => void
   openDoc: (document: Doc, options?: OpenDocOptions) => void
+  openInitialDoc: (docId: string) => void
   jumpToLine: (options: GotoLineOptions) => void
 }
 
@@ -109,6 +111,7 @@ const EditorManagerContext = createContext<EditorManager | undefined>(undefined)
 export const EditorManagerProvider: FC = ({ children }) => {
   const { t } = useTranslation()
   const ide = useIdeContext()
+  const { projectId } = useIdeReactContext()
   const { reportError, eventEmitter, eventLog } = useIdeReactContext()
   const { socket, disconnect } = useConnectionContext()
   const { view, setView } = useLayoutContext()
@@ -136,7 +139,6 @@ export const EditorManagerProvider: FC = ({ children }) => {
 
   const { fileTreeData } = useFileTreeData()
 
-  // eslint-disable-next-line no-unused-vars
   const [ignoringExternalUpdates, setIgnoringExternalUpdates] = useState(false)
 
   const [globalEditorWatchdogManager] = useState(
@@ -177,6 +179,15 @@ export const EditorManagerProvider: FC = ({ children }) => {
       )
   )
 
+  const openDocIdStorageKey = `doc.open_id.${projectId}`
+
+  // Persist the open document ID to local storage
+  useEffect(() => {
+    if (openDocId) {
+      customLocalStorage.setItem(openDocIdStorageKey, openDocId)
+    }
+  }, [openDocId, openDocIdStorageKey])
+
   const editorOpenDocEpochRef = useRef(0)
 
   // TODO: This looks dodgy because it wraps a state setter and is itself
@@ -485,6 +496,17 @@ export const EditorManagerProvider: FC = ({ children }) => {
     [fileTreeData, openDoc]
   )
 
+  const openInitialDoc = useCallback(
+    (fallbackDocId: string) => {
+      const docId =
+        customLocalStorage.getItem(openDocIdStorageKey) || fallbackDocId
+      if (docId) {
+        openDocWithId(docId)
+      }
+    },
+    [openDocIdStorageKey, openDocWithId]
+  )
+
   useEffect(() => {
     if (docError) {
       const { doc, document, error, meta } = docError
@@ -574,6 +596,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
       stopIgnoringExternalUpdates,
       openDocId: openDocWithId,
       openDoc,
+      openInitialDoc,
       jumpToLine,
     }),
     [
@@ -587,6 +610,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
       stopIgnoringExternalUpdates,
       openDocWithId,
       openDoc,
+      openInitialDoc,
       jumpToLine,
     ]
   )

+ 25 - 0
services/web/frontend/js/features/ide-react/hooks/use-open-file.ts

@@ -0,0 +1,25 @@
+import { useIdeContext } from '@/shared/context/ide-context'
+import { useFileTreeData } from '@/shared/context/file-tree-data-context'
+import { useCallback } from 'react'
+import { findInTree } from '@/features/file-tree/util/find-in-tree'
+
+export function useOpenFile() {
+  const ide = useIdeContext()
+  const { fileTreeData } = useFileTreeData()
+
+  const openFileWithId = useCallback(
+    (id: string) => {
+      const result = findInTree(fileTreeData, id)
+      if (result?.type === 'fileRef') {
+        window.dispatchEvent(new CustomEvent('editor.openDoc', { detail: id }))
+      }
+    },
+    [fileTreeData]
+  )
+
+  // Expose BinaryFilesManager via ide object solely for the benefit of the file
+  // restore feature in history. This can be removed once Angular is gone.
+  ide.binaryFilesManager = { openFileWithId }
+
+  return openFileWithId
+}

+ 0 - 2
services/web/frontend/js/features/ide-react/scope-adapters/review-panel-context-adapter.ts

@@ -17,8 +17,6 @@ export default function populateReviewPanelScope(store: ReactScopeValueStore) {
   })
   store.set('users', {})
   store.set('reviewPanel.resolvedComments', {})
-  store.set('editor.wantTrackChanges', false)
-  store.set('editor.open_doc_id', null)
   store.set('reviewPanel.fullTCStateCollapsed', true)
   store.set('reviewPanel.rendererData.lineHeight', 0)
   store.set('reviewPanel.trackChangesState', {})