فهرست منبع

Merge pull request #15342 from overleaf/td-remove-file-tree-manager-in-react

Remove use of FileTreeManager in React code

GitOrigin-RevId: f15bc9b4f84e0f65709b9850ed8cc5d3637efa7f
Tim Down 2 سال پیش
والد
کامیت
13f246a85e
22فایلهای تغییر یافته به همراه644 افزوده شده و 206 حذف شده
  1. 81 0
      services/web/frontend/js/features/file-tree/contexts/file-tree-path.tsx
  2. 1 1
      services/web/frontend/js/features/file-tree/util/mutate-in-tree.js
  3. 138 0
      services/web/frontend/js/features/file-tree/util/path.ts
  4. 81 32
      services/web/frontend/js/features/history/context/hooks/use-restore-deleted-file.ts
  5. 13 9
      services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.jsx
  6. 7 6
      services/web/frontend/js/features/pdf-preview/util/output-files.js
  7. 5 10
      services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts
  8. 3 4
      services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts
  9. 2 5
      services/web/frontend/js/features/source-editor/extensions/visual/visual.ts
  10. 9 1
      services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
  11. 7 0
      services/web/frontend/js/ide/binary-files/BinaryFilesManager.js
  12. 21 8
      services/web/frontend/js/shared/context/local-compile-context.jsx
  13. 16 13
      services/web/frontend/js/shared/context/root-context.jsx
  14. 54 17
      services/web/frontend/stories/decorators/scope.tsx
  15. 27 24
      services/web/frontend/stories/source-editor/source-editor.stories.tsx
  16. 44 17
      services/web/test/frontend/components/pdf-preview/pdf-logs-entries.spec.tsx
  17. 25 13
      services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-readonly.spec.tsx
  18. 18 1
      services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx
  19. 18 1
      services/web/test/frontend/features/source-editor/components/figure-modal.spec.tsx
  20. 65 44
      services/web/test/frontend/helpers/editor-providers.jsx
  21. 5 0
      services/web/types/file-tree-entity.ts
  22. 4 0
      services/web/types/preview-path.ts

+ 81 - 0
services/web/frontend/js/features/file-tree/contexts/file-tree-path.tsx

@@ -0,0 +1,81 @@
+import { createContext, FC, useCallback, useContext, useMemo } from 'react'
+import { Folder } from '../../../../../types/folder'
+import { useFileTreeData } from '@/shared/context/file-tree-data-context'
+import getMeta from '@/utils/meta'
+import {
+  findEntityByPath,
+  previewByPath,
+  dirname,
+  FindResult,
+  pathInFolder,
+} from '@/features/file-tree/util/path'
+import { PreviewPath } from '../../../../../types/preview-path'
+
+type FileTreePathContextValue = {
+  pathInFolder: (id: string) => string | null
+  findEntityByPath: (path: string) => FindResult | null
+  previewByPath: (path: string) => PreviewPath | null
+  dirname: (id: string) => string | null
+}
+
+export const FileTreePathContext = createContext<
+  FileTreePathContextValue | undefined
+>(undefined)
+
+export const FileTreePathProvider: FC = ({ children }) => {
+  const { fileTreeData }: { fileTreeData: Folder } = useFileTreeData()
+  const projectId = getMeta('ol-project_id') as string
+
+  const pathInFileTree = useCallback(
+    (id: string) => pathInFolder(fileTreeData, id),
+    [fileTreeData]
+  )
+
+  const findEntityByPathInFileTree = useCallback(
+    (path: string) => findEntityByPath(fileTreeData, path),
+    [fileTreeData]
+  )
+
+  const previewByPathInFileTree = useCallback(
+    (path: string) => previewByPath(fileTreeData, projectId, path),
+    [fileTreeData, projectId]
+  )
+
+  const dirnameInFileTree = useCallback(
+    (id: string) => dirname(fileTreeData, id),
+    [fileTreeData]
+  )
+
+  const value = useMemo<FileTreePathContextValue>(
+    () => ({
+      pathInFolder: pathInFileTree,
+      findEntityByPath: findEntityByPathInFileTree,
+      previewByPath: previewByPathInFileTree,
+      dirname: dirnameInFileTree,
+    }),
+    [
+      pathInFileTree,
+      findEntityByPathInFileTree,
+      previewByPathInFileTree,
+      dirnameInFileTree,
+    ]
+  )
+
+  return (
+    <FileTreePathContext.Provider value={value}>
+      {children}
+    </FileTreePathContext.Provider>
+  )
+}
+
+export function useFileTreePathContext(): FileTreePathContextValue {
+  const context = useContext(FileTreePathContext)
+
+  if (!context) {
+    throw new Error(
+      'useFileTreePathContext is only available inside FileTreePathProvider'
+    )
+  }
+
+  return context
+}

+ 1 - 1
services/web/frontend/js/features/file-tree/util/mutate-in-tree.js

@@ -46,7 +46,7 @@ export function createEntityInTree(tree, parentFolderId, newEntityData) {
 }
 
 function mutateInTree(tree, id, mutationFunction) {
-  if (tree._id === id) {
+  if (!id || tree._id === id) {
     // covers the root folder case: it has no parent so in order to use
     // mutationFunction we pass an empty array as the parent and return the
     // mutated tree directly

+ 138 - 0
services/web/frontend/js/features/file-tree/util/path.ts

@@ -0,0 +1,138 @@
+import { Folder } from '../../../../../types/folder'
+import { FileTreeEntity } from '../../../../../types/file-tree-entity'
+import { Doc } from '../../../../../types/doc'
+import { FileRef } from '../../../../../types/file-ref'
+import { PreviewPath } from '../../../../../types/preview-path'
+
+type DocFindResult = {
+  entity: Doc
+  type: 'doc'
+}
+
+type FolderFindResult = {
+  entity: Folder
+  type: 'folder'
+}
+
+type FileRefFindResult = {
+  entity: FileRef
+  type: 'fileRef'
+}
+
+export type FindResult = DocFindResult | FolderFindResult | FileRefFindResult
+
+// Finds the entity with a given ID in the tree represented by `folder` and
+// returns a path to that entity, represented by an array of folders starting at
+// the root plus the entity itself
+function pathComponentsInFolder(
+  folder: Folder,
+  id: string,
+  ancestors: FileTreeEntity[] = []
+): FileTreeEntity[] | null {
+  const docOrFileRef =
+    folder.docs.find(doc => doc._id === id) ||
+    folder.fileRefs.find(fileRef => fileRef._id === id)
+  if (docOrFileRef) {
+    return ancestors.concat([docOrFileRef])
+  }
+
+  for (const subfolder of folder.folders) {
+    if (subfolder._id === id) {
+      return ancestors.concat([subfolder])
+    } else {
+      const path = pathComponentsInFolder(
+        subfolder,
+        id,
+        ancestors.concat([subfolder])
+      )
+      if (path !== null) {
+        return path
+      }
+    }
+  }
+
+  return null
+}
+
+// Finds the entity with a given ID in the tree represented by `folder` and
+// returns a path to that entity as a string
+export function pathInFolder(folder: Folder, id: string): string | null {
+  return (
+    pathComponentsInFolder(folder, id)
+      ?.map(entity => entity.name)
+      .join('/') || null
+  )
+}
+
+export function findEntityByPath(
+  folder: Folder,
+  path: string
+): FindResult | null {
+  if (path === '') {
+    return { entity: folder, type: 'folder' }
+  }
+
+  const parts = path.split('/')
+  const name = parts.shift()
+  const rest = parts.join('/')
+
+  if (name === '.') {
+    return findEntityByPath(folder, rest)
+  }
+
+  const doc = folder.docs.find(doc => doc.name === name)
+  if (doc) {
+    return { entity: doc, type: 'doc' }
+  }
+
+  const fileRef = folder.fileRefs.find(fileRef => fileRef.name === name)
+  if (fileRef) {
+    return { entity: fileRef, type: 'fileRef' }
+  }
+
+  for (const subfolder of folder.folders) {
+    if (subfolder.name === name) {
+      if (rest === '') {
+        return { entity: subfolder, type: 'folder' }
+      } else {
+        return findEntityByPath(subfolder, rest)
+      }
+    }
+  }
+
+  return null
+}
+
+export function previewByPath(
+  folder: Folder,
+  projectId: string,
+  path: string
+): PreviewPath | null {
+  for (const suffix of [
+    '',
+    '.png',
+    '.jpg',
+    '.jpeg',
+    '.pdf',
+    '.PNG',
+    '.JPG',
+    '.JPEG',
+    '.PDF',
+  ]) {
+    const result = findEntityByPath(folder, path + suffix)
+
+    if (result) {
+      const { name, _id: id } = result.entity
+      return {
+        url: `/project/${projectId}/file/${id}`,
+        extension: name.slice(name.lastIndexOf('.')),
+      }
+    }
+  }
+  return null
+}
+
+export function dirname(fileTreeData: Folder, id: string) {
+  const path = pathInFolder(fileTreeData, id)
+  return path?.split('/').slice(0, -1).join('/') || null
+}

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

@@ -1,51 +1,100 @@
 import { sendMB } from '../../../../infrastructure/event-tracking'
 import { useIdeContext } from '../../../../shared/context/ide-context'
 import { useLayoutContext } from '../../../../shared/context/layout-context'
-import useAsync from '../../../../shared/hooks/use-async'
 import { restoreFile } from '../../services/api'
 import { isFileRemoved } from '../../utils/file-diff'
-import { waitFor } from '../../utils/wait-for'
 import { useHistoryContext } from '../history-context'
 import type { HistoryContextValue } from '../types/history-context-value'
 import { useErrorHandler } from 'react-error-boundary'
+import { useFileTreeData } from '@/shared/context/file-tree-data-context'
+import { findInTree } from '@/features/file-tree/util/find-in-tree'
+import { useCallback, useEffect, useState } from 'react'
+import { RestoreFileResponse } from '@/features/history/services/types/restore-file'
+
+type RestorationState =
+  | 'idle'
+  | 'restoring'
+  | 'waitingForFileTree'
+  | 'complete'
+  | 'error'
+  | 'timedOut'
 
 export function useRestoreDeletedFile() {
-  const { isLoading, runAsync } = useAsync()
   const { projectId } = useHistoryContext()
   const ide = useIdeContext()
   const { setView } = useLayoutContext()
   const handleError = useErrorHandler()
+  const { fileTreeData } = useFileTreeData()
+  const [state, setState] = useState<RestorationState>('idle')
+  const [restoredFileMetadata, setRestoredFileMetadata] =
+    useState<RestoreFileResponse | null>(null)
+
+  const isLoading = state === 'restoring' || state === 'waitingForFileTree'
+
+  useEffect(() => {
+    if (state === 'waitingForFileTree' && restoredFileMetadata) {
+      const result = findInTree(fileTreeData, restoredFileMetadata.id)
+      if (result) {
+        setState('complete')
+        const { _id: id } = result.entity
+        setView('editor')
+        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 }))
+      }
+    }
+  }, [
+    state,
+    fileTreeData,
+    restoredFileMetadata,
+    ide.editorManager,
+    ide.binaryFilesManager,
+    setView,
+  ])
 
-  const restoreDeletedFile = async (
-    selection: HistoryContextValue['selection']
-  ) => {
-    const { selectedFile } = selection
-
-    if (selectedFile && selectedFile.pathname && isFileRemoved(selectedFile)) {
-      sendMB('history-v2-restore-deleted')
-
-      await runAsync(
-        restoreFile(projectId, selectedFile)
-          .then(async data => {
-            const { id, type } = data
-
-            const entity = await waitFor(
-              () => ide.fileTreeManager.findEntityById(id),
-              3000
-            )
-
-            if (type === 'doc') {
-              ide.editorManager.openDoc(entity)
-            } else {
-              ide.binaryFilesManager.openFile(entity)
-            }
-
-            setView('editor')
-          })
-          .catch(handleError)
-      )
+  useEffect(() => {
+    if (state === 'waitingForFileTree') {
+      const timer = window.setTimeout(() => {
+        setState('timedOut')
+        handleError(new Error('timed out'))
+      }, 3000)
+
+      return () => {
+        window.clearTimeout(timer)
+      }
     }
-  }
+  }, [handleError, state])
+
+  const restoreDeletedFile = useCallback(
+    (selection: HistoryContextValue['selection']) => {
+      const { selectedFile } = selection
+
+      if (
+        selectedFile &&
+        selectedFile.pathname &&
+        isFileRemoved(selectedFile)
+      ) {
+        sendMB('history-v2-restore-deleted')
+
+        setState('restoring')
+        restoreFile(projectId, selectedFile).then(
+          (data: RestoreFileResponse) => {
+            setRestoredFileMetadata(data)
+            setState('waitingForFileTree')
+          },
+          error => {
+            setState('error')
+            handleError(error)
+          }
+        )
+      }
+    },
+    [handleError, projectId]
+  )
 
   return { restoreDeletedFile, isLoading }
 }

+ 13 - 9
services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.jsx

@@ -20,6 +20,7 @@ import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
 import useScopeEventListener from '../../../shared/hooks/use-scope-event-listener'
 import * as eventTracking from '../../../infrastructure/event-tracking'
 import { debugConsole } from '@/utils/debugging'
+import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
 
 function GoToCodeButton({
   position,
@@ -118,7 +119,7 @@ function GoToPdfButton({
 function PdfSynctexControls() {
   const ide = useIdeContext()
 
-  const { _id: projectId } = useProjectContext()
+  const { _id: projectId, rootDocId } = useProjectContext()
 
   const { detachRole } = useLayoutContext()
 
@@ -132,6 +133,7 @@ function PdfSynctexControls() {
   } = useCompileContext()
 
   const { selectedEntities } = useFileTreeData()
+  const { findEntityByPath, dirname, pathInFolder } = useFileTreePathContext()
 
   const [cursorPosition, setCursorPosition] = useState(() => {
     const position = localStorage.getItem(
@@ -162,26 +164,28 @@ function PdfSynctexControls() {
 
   const getCurrentFilePath = useCallback(() => {
     const docId = ide.editorManager.getCurrentDocId()
-    const doc = ide.fileTreeManager.findEntityById(docId)
-
-    let path = ide.fileTreeManager.getEntityPath(doc)
+    let path = pathInFolder(docId)
 
     // If the root file is folder/main.tex, then synctex sees the path as folder/./main.tex
-    const rootDocDirname = ide.fileTreeManager.getRootDocDirname()
+    const rootDocDirname = dirname(rootDocId)
 
     if (rootDocDirname) {
       path = path.replace(RegExp(`^${rootDocDirname}`), `${rootDocDirname}/.`)
     }
 
     return path
-  }, [ide])
+  }, [dirname, ide.editorManager, pathInFolder, rootDocId])
 
   const goToCodeLine = useCallback(
     (file, line) => {
       if (file) {
-        const doc = ide.fileTreeManager.findEntityByPath(file)
+        const doc = findEntityByPath(file)?.entity
+        if (!doc) {
+          debugConsole.warn(`Document with path ${file} not found`)
+          return
+        }
 
-        ide.editorManager.openDoc(doc, {
+        ide.editorManager.openDocId(doc._id, {
           gotoLine: line,
         })
       } else {
@@ -194,7 +198,7 @@ function PdfSynctexControls() {
         }, 4000)
       }
     },
-    [ide, isMounted, setSynctexError]
+    [findEntityByPath, ide.editorManager, isMounted, setSynctexError]
   )
 
   const goToPdfLocation = useCallback(

+ 7 - 6
services/web/frontend/js/features/pdf-preview/util/output-files.js

@@ -4,6 +4,7 @@ import BibLogParser from '../../../ide/log-parser/bib-log-parser'
 import { v4 as uuid } from 'uuid'
 import { enablePdfCaching } from './pdf-caching-flags'
 import { debugConsole } from '@/utils/debugging'
+import { dirname, findEntityByPath } from '@/features/file-tree/util/path'
 
 // Warnings that may disappear after a second LaTeX pass
 const TRANSIENT_WARNING_REGEX = /^(Reference|Citation).+undefined on input line/
@@ -133,8 +134,8 @@ export const handleLogFiles = async (outputFiles, data, signal) => {
   return result
 }
 
-export function buildLogEntryAnnotations(entries, fileTreeManager) {
-  const rootDocDirname = fileTreeManager.getRootDocDirname()
+export function buildLogEntryAnnotations(entries, fileTreeData, rootDocId) {
+  const rootDocDirname = dirname(fileTreeData, rootDocId)
 
   const logEntryAnnotations = {}
 
@@ -142,14 +143,14 @@ export function buildLogEntryAnnotations(entries, fileTreeManager) {
     if (entry.file) {
       entry.file = normalizeFilePath(entry.file, rootDocDirname)
 
-      const entity = fileTreeManager.findEntityByPath(entry.file)
+      const entity = findEntityByPath(fileTreeData, entry.file)?.entity
 
       if (entity) {
-        if (!(entity.id in logEntryAnnotations)) {
-          logEntryAnnotations[entity.id] = []
+        if (!(entity._id in logEntryAnnotations)) {
+          logEntryAnnotations[entity._id] = []
         }
 
-        logEntryAnnotations[entity.id].push({
+        logEntryAnnotations[entity._id].push({
           row: entry.line - 1,
           type: entry.level === 'error' ? 'error' : 'warning',
           text: entry.message,

+ 5 - 10
services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts

@@ -66,13 +66,10 @@ import { skipPreambleWithCursor } from './skip-preamble-cursor'
 import { TableRenderingErrorWidget } from './visual-widgets/table-rendering-error'
 import { GraphicsWidget } from './visual-widgets/graphics'
 import { InlineGraphicsWidget } from './visual-widgets/inline-graphics'
+import { PreviewPath } from '../../../../../../types/preview-path'
 
 type Options = {
-  fileTreeManager: {
-    getPreviewByPath: (
-      path: string
-    ) => { url: string; extension: string } | null
-  }
+  previewByPath: (path: string) => PreviewPath | null
 }
 
 function shouldDecorate(
@@ -135,9 +132,7 @@ const hasClosingBrace = (node: SyntaxNode) =>
  * Decorations that span multiple lines must be contained in a StateField, not a ViewPlugin.
  */
 export const atomicDecorations = (options: Options) => {
-  const getPreviewByPath = (path: string) =>
-    options.fileTreeManager.getPreviewByPath(path)
-
+  const { previewByPath } = options
   const createDecorations = (
     state: EditorState,
     tree: Tree
@@ -895,7 +890,7 @@ export const atomicDecorations = (options: Options) => {
                     Decoration.replace({
                       widget: new Widget(
                         filePath,
-                        getPreviewByPath,
+                        previewByPath,
                         centered,
                         figureData
                       ),
@@ -910,7 +905,7 @@ export const atomicDecorations = (options: Options) => {
                     Decoration.replace({
                       widget: new Widget(
                         filePath,
-                        getPreviewByPath,
+                        previewByPath,
                         centered,
                         figureData
                       ),

+ 3 - 4
services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts

@@ -3,6 +3,7 @@ import { placeSelectionInsideBlock } from '../selection'
 import { isEqual } from 'lodash'
 import { FigureData } from '../../figure-modal'
 import { debugConsole } from '@/utils/debugging'
+import { PreviewPath } from '../../../../../../../types/preview-path'
 
 export class GraphicsWidget extends WidgetType {
   destroyed = false
@@ -10,9 +11,7 @@ export class GraphicsWidget extends WidgetType {
 
   constructor(
     public filePath: string,
-    public getPreviewByPath: (
-      filePath: string
-    ) => { url: string; extension: string } | null,
+    public previewByPath: (path: string) => PreviewPath | null,
     public centered: boolean,
     public figureData: FigureData | null
   ) {
@@ -82,7 +81,7 @@ export class GraphicsWidget extends WidgetType {
   renderGraphic(element: HTMLElement, view: EditorView) {
     element.textContent = '' // ensure the element is empty
 
-    const preview = this.getPreviewByPath(this.filePath)
+    const preview = this.previewByPath(this.filePath)
     element.dataset.filepath = this.filePath
     element.dataset.width = this.figureData?.width?.toString()
 

+ 2 - 5
services/web/frontend/js/features/source-editor/extensions/visual/visual.ts

@@ -24,14 +24,11 @@ import { pasteHtml } from './paste-html'
 import { commandTooltip } from '../command-tooltip'
 import { tableGeneratorTheme } from './table-generator'
 import { debugConsole } from '@/utils/debugging'
+import { PreviewPath } from '../../../../../../types/preview-path'
 
 type Options = {
   visual: boolean
-  fileTreeManager: {
-    getPreviewByPath: (
-      path: string
-    ) => { url: string; extension: string } | null
-  }
+  previewByPath: (path: string) => PreviewPath | null
 }
 
 const visualConf = new Compartment()

+ 9 - 1
services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts

@@ -48,6 +48,7 @@ import { CurrentDoc } from '../../../../../types/current-doc'
 import { useErrorHandler } from 'react-error-boundary'
 import { setVisual } from '../extensions/visual/visual'
 import getMeta from '../../../utils/meta'
+import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
 
 function useCodeMirrorScope(view: EditorView) {
   const ide = useIdeContext()
@@ -244,8 +245,10 @@ function useCodeMirrorScope(view: EditorView) {
 
   const editableRef = useRef(permissionsLevel !== 'readOnly')
 
+  const { previewByPath } = useFileTreePathContext()
+
   const visualRef = useRef({
-    fileTreeManager: ide.fileTreeManager,
+    previewByPath,
     visual,
   })
 
@@ -312,6 +315,11 @@ function useCodeMirrorScope(view: EditorView) {
     window.dispatchEvent(new Event('editor:visual-switch'))
   }, [view, visual])
 
+  useEffect(() => {
+    visualRef.current.previewByPath = previewByPath
+    view.dispatch(setVisual(visualRef.current))
+  }, [view, previewByPath])
+
   useEffect(() => {
     editableRef.current = permissionsLevel !== 'readOnly'
     view.dispatch(setEditable(editableRef.current)) // the editor needs to be locked when there's a problem saving data

+ 7 - 0
services/web/frontend/js/ide/binary-files/BinaryFilesManager.js

@@ -50,6 +50,13 @@ export default BinaryFilesManager = class BinaryFilesManager {
     )
   }
 
+  openFileWithId(id) {
+    const entity = this.ide.fileTreeManager.findEntityById(id)
+    if (entity?.type === 'file') {
+      this.openFile(entity)
+    }
+  }
+
   closeFile() {
     return window.setTimeout(
       () => {

+ 21 - 8
services/web/frontend/js/shared/context/local-compile-context.jsx

@@ -29,6 +29,8 @@ import { useEditorContext } from './editor-context'
 import { buildFileList } from '../../features/pdf-preview/util/file-list'
 import { useLayoutContext } from './layout-context'
 import { useUserContext } from './user-context'
+import { useFileTreeData } from '@/shared/context/file-tree-data-context'
+import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
 
 export const LocalCompileContext = createContext()
 
@@ -95,6 +97,9 @@ export function LocalCompileProvider({ children }) {
 
   const { features } = useUserContext()
 
+  const { fileTreeData } = useFileTreeData()
+  const { findEntityByPath } = useFileTreePathContext()
+
   // whether a compile is in progress
   const [compiling, setCompiling] = useState(false)
 
@@ -245,6 +250,17 @@ export function LocalCompileProvider({ children }) {
     compilingRef.current = compiling
   }, [compiling])
 
+  const _buildLogEntryAnnotations = useCallback(
+    entries => buildLogEntryAnnotations(entries, fileTreeData, rootDocId),
+    [fileTreeData, rootDocId]
+  )
+
+  const buildLogEntryAnnotationsRef = useRef(_buildLogEntryAnnotations)
+
+  useEffect(() => {
+    buildLogEntryAnnotationsRef.current = _buildLogEntryAnnotations
+  }, [_buildLogEntryAnnotations])
+
   // the document compiler
   const [compiler] = useState(() => {
     return new DocumentCompiler({
@@ -349,10 +365,7 @@ export function LocalCompileProvider({ children }) {
             setRawLog(result.log)
             setLogEntries(result.logEntries)
             setLogEntryAnnotations(
-              buildLogEntryAnnotations(
-                result.logEntries.all,
-                ide.fileTreeManager
-              )
+              buildLogEntryAnnotationsRef.current(result.logEntries.all)
             )
 
             // sample compile stats for real users
@@ -521,16 +534,16 @@ export function LocalCompileProvider({ children }) {
 
   const syncToEntry = useCallback(
     entry => {
-      const entity = ide.fileTreeManager.findEntityByPath(entry.file)
+      const result = findEntityByPath(entry.file)
 
-      if (entity && entity.type === 'doc') {
-        ide.editorManager.openDoc(entity, {
+      if (result && result.type === 'doc') {
+        ide.editorManager.openDocId(result.entity._id, {
           gotoLine: entry.line ?? undefined,
           gotoColumn: entry.column ?? undefined,
         })
       }
     },
-    [ide]
+    [findEntityByPath, ide.editorManager]
   )
 
   // clear the cache then run a compile, triggered by a menu item

+ 16 - 13
services/web/frontend/js/shared/context/root-context.jsx

@@ -13,6 +13,7 @@ import { ProjectProvider } from './project-context'
 import { SplitTestProvider } from './split-test-context'
 import { FileTreeDataProvider } from './file-tree-data-context'
 import { ProjectSettingsProvider } from '@/features/editor-left-menu/context/project-settings-context'
+import { FileTreePathProvider } from '@/features/file-tree/contexts/file-tree-path'
 
 export function ContextRoot({ children, ide }) {
   return (
@@ -21,19 +22,21 @@ export function ContextRoot({ children, ide }) {
         <UserProvider>
           <ProjectProvider>
             <FileTreeDataProvider>
-              <DetachProvider>
-                <EditorProvider>
-                  <ProjectSettingsProvider>
-                    <LayoutProvider>
-                      <LocalCompileProvider>
-                        <DetachCompileProvider>
-                          <ChatProvider>{children}</ChatProvider>
-                        </DetachCompileProvider>
-                      </LocalCompileProvider>
-                    </LayoutProvider>
-                  </ProjectSettingsProvider>
-                </EditorProvider>
-              </DetachProvider>
+              <FileTreePathProvider>
+                <DetachProvider>
+                  <EditorProvider>
+                    <ProjectSettingsProvider>
+                      <LayoutProvider>
+                        <LocalCompileProvider>
+                          <DetachCompileProvider>
+                            <ChatProvider>{children}</ChatProvider>
+                          </DetachCompileProvider>
+                        </LocalCompileProvider>
+                      </LayoutProvider>
+                    </ProjectSettingsProvider>
+                  </EditorProvider>
+                </DetachProvider>
+              </FileTreePathProvider>
             </FileTreeDataProvider>
           </ProjectProvider>
         </UserProvider>

+ 54 - 17
services/web/frontend/stories/decorators/scope.tsx

@@ -1,6 +1,5 @@
 import { useEffect, useMemo } from 'react'
 import { get } from 'lodash'
-import { ContextRoot } from '../../js/shared/context/root-context'
 import { User } from '../../../types/user'
 import { Project } from '../../../types/project'
 import {
@@ -10,6 +9,18 @@ import {
 } from '../fixtures/compile'
 import useFetchMock from '../hooks/use-fetch-mock'
 import { useMeta } from '../hooks/use-meta'
+import { SplitTestProvider } from '@/shared/context/split-test-context'
+import { IdeAngularProvider } from '@/shared/context/ide-angular-provider'
+import { UserProvider } from '@/shared/context/user-context'
+import { ProjectProvider } from '@/shared/context/project-context'
+import { FileTreeDataProvider } from '@/shared/context/file-tree-data-context'
+import { EditorProvider } from '@/shared/context/editor-context'
+import { DetachProvider } from '@/shared/context/detach-context'
+import { LayoutProvider } from '@/shared/context/layout-context'
+import { LocalCompileProvider } from '@/shared/context/local-compile-context'
+import { DetachCompileProvider } from '@/shared/context/detach-compile-context'
+import { ProjectSettingsProvider } from '@/features/editor-left-menu/context/project-settings-context'
+import { FileTreePathProvider } from '@/features/file-tree/contexts/file-tree-path'
 
 const scopeWatchers: [string, (value: any) => void][] = []
 
@@ -97,19 +108,6 @@ const initialize = () => {
       on: () => {},
       removeListener: () => {},
     },
-    fileTreeManager: {
-      findEntityById: () => null,
-      findEntityByPath: () => null,
-      getEntityPath: () => null,
-      getRootDocDirname: () => undefined,
-      getPreviewByPath: (path: string) =>
-        path === 'frog.jpg'
-          ? {
-              extension: 'png',
-              url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwAAAEsCAYAAAB5fY51AAABhWlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV9TpaJVETuIOGSogmBBVMRRq1CECqFWaNXB5NIvaNKQpLg4Cq4FBz8Wqw4uzro6uAqC4AeIq4uToouU+L+k0CLWg+N+vLv3uHsHCNUi06y2cUDTbTMRi4qp9KoYeIWAPnShB6Mys4w5SYqj5fi6h4+vdxGe1frcn6NbzVgM8InEs8wwbeIN4ulN2+C8TxxieVklPiceM+mCxI9cVzx+45xzWeCZITOZmCcOEYu5JlaamOVNjXiKOKxqOuULKY9VzluctWKZ1e/JXxjM6CvLXKc5hBgWsQQJIhSUUUARNiK06qRYSNB+tIV/0PVL5FLIVQAjxwJK0CC7fvA/+N2tlZ2c8JKCUaD9xXE+hoHALlCrOM73sePUTgD/M3ClN/ylKjDzSXqloYWPgN5t4OK6oSl7wOUOMPBkyKbsSn6aQjYLvJ/RN6WB/lugc83rrb6P0wcgSV3Fb4CDQ2AkR9nrLd7d0dzbv2fq/f0ARfNylZJUgMQAAAAGYktHRABuAP8AAGHZRr4AAAAJcEhZcwAALiMAAC4jAXilP3YAAAAHdElNRQfnAhELEhgyPeVkAAAAGXRFWHRDb21tZW50AENyZWF0ZWQgd2l0aCBHSU1QV4EOFwAAAyVJREFUeNrt1rEJgDAURVGVNCmS2hS6PziCteIYWjuEbiEfOWeEV1xe35bt6QhjnlYjBJLzbYRABhMAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBbwX6m13QqB5HwbIZBSLyN4WACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBfCFVMtphUBKvYwQSBsPI3hYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIF8IUXjtUMuBMh1xAAAAAASUVORK5CYII=',
-            }
-          : null,
-    },
     editorManager: {
       getCurrentDocId: () => 'foo',
       openDoc: (id: string, options: unknown) => {
@@ -207,6 +205,7 @@ const initialize = () => {
 
 type ScopeDecoratorOptions = {
   mockCompileOnLoad: boolean
+  providers?: Record<string, any>
 }
 
 export const ScopeDecorator = (
@@ -237,9 +236,47 @@ export const ScopeDecorator = (
   // set values on window.metaAttributesCache (created in initialize, above)
   useMeta(meta)
 
+  const Providers = {
+    DetachCompileProvider,
+    DetachProvider,
+    EditorProvider,
+    FileTreeDataProvider,
+    FileTreePathProvider,
+    IdeAngularProvider,
+    LayoutProvider,
+    LocalCompileProvider,
+    ProjectProvider,
+    ProjectSettingsProvider,
+    SplitTestProvider,
+    UserProvider,
+    ...opts.providers,
+  }
+
   return (
-    <ContextRoot ide={ide}>
-      <Story />
-    </ContextRoot>
+    <Providers.SplitTestProvider>
+      <Providers.IdeAngularProvider ide={ide}>
+        <Providers.UserProvider>
+          <Providers.ProjectProvider>
+            <Providers.FileTreeDataProvider>
+              <Providers.FileTreePathProvider>
+                <Providers.DetachProvider>
+                  <Providers.EditorProvider>
+                    <Providers.ProjectSettingsProvider>
+                      <Providers.LayoutProvider>
+                        <Providers.LocalCompileProvider>
+                          <Providers.DetachCompileProvider>
+                            <Story />
+                          </Providers.DetachCompileProvider>
+                        </Providers.LocalCompileProvider>
+                      </Providers.LayoutProvider>
+                    </Providers.ProjectSettingsProvider>
+                  </Providers.EditorProvider>
+                </Providers.DetachProvider>
+              </Providers.FileTreePathProvider>
+            </Providers.FileTreeDataProvider>
+          </Providers.ProjectProvider>
+        </Providers.UserProvider>
+      </Providers.IdeAngularProvider>
+    </Providers.SplitTestProvider>
   )
 }

+ 27 - 24
services/web/frontend/stories/source-editor/source-editor.stories.tsx

@@ -2,12 +2,37 @@ import SourceEditor from '../../js/features/source-editor/components/source-edit
 import { ScopeDecorator } from '../decorators/scope'
 import { useScope } from '../hooks/use-scope'
 import { useMeta } from '../hooks/use-meta'
+import { FC } from 'react'
+import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
+
+const FileTreePathProvider: FC = ({ children }) => (
+  <FileTreePathContext.Provider
+    value={{
+      dirname: () => null,
+      findEntityByPath: () => null,
+      pathInFolder: () => null,
+      previewByPath: (path: string) =>
+        path === 'frog.jpg'
+          ? {
+              extension: 'png',
+              url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwAAAEsCAYAAAB5fY51AAABhWlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV9TpaJVETuIOGSogmBBVMRRq1CECqFWaNXB5NIvaNKQpLg4Cq4FBz8Wqw4uzro6uAqC4AeIq4uToouU+L+k0CLWg+N+vLv3uHsHCNUi06y2cUDTbTMRi4qp9KoYeIWAPnShB6Mys4w5SYqj5fi6h4+vdxGe1frcn6NbzVgM8InEs8wwbeIN4ulN2+C8TxxieVklPiceM+mCxI9cVzx+45xzWeCZITOZmCcOEYu5JlaamOVNjXiKOKxqOuULKY9VzluctWKZ1e/JXxjM6CvLXKc5hBgWsQQJIhSUUUARNiK06qRYSNB+tIV/0PVL5FLIVQAjxwJK0CC7fvA/+N2tlZ2c8JKCUaD9xXE+hoHALlCrOM73sePUTgD/M3ClN/ylKjDzSXqloYWPgN5t4OK6oSl7wOUOMPBkyKbsSn6aQjYLvJ/RN6WB/lugc83rrb6P0wcgSV3Fb4CDQ2AkR9nrLd7d0dzbv2fq/f0ARfNylZJUgMQAAAAGYktHRABuAP8AAGHZRr4AAAAJcEhZcwAALiMAAC4jAXilP3YAAAAHdElNRQfnAhELEhgyPeVkAAAAGXRFWHRDb21tZW50AENyZWF0ZWQgd2l0aCBHSU1QV4EOFwAAAyVJREFUeNrt1rEJgDAURVGVNCmS2hS6PziCteIYWjuEbiEfOWeEV1xe35bt6QhjnlYjBJLzbYRABhMAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBbwX6m13QqB5HwbIZBSLyN4WACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBfCFVMtphUBKvYwQSBsPI3hYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIF8IUXjtUMuBMh1xAAAAAASUVORK5CYII=',
+            }
+          : null,
+    }}
+  >
+    {children}
+  </FileTreePathContext.Provider>
+)
 
 export default {
   title: 'Editor / Source Editor',
   component: SourceEditor,
   decorators: [
-    ScopeDecorator,
+    (Story: any) =>
+      ScopeDecorator(Story, {
+        mockCompileOnLoad: true,
+        providers: { FileTreePathProvider },
+      }),
     (Story: any) => (
       <div style={{ height: '90vh' }}>
         <Story />
@@ -93,29 +118,6 @@ export const Visual = (args: any, { globals: { theme } }: any) => {
       open_doc_name: 'example.tex',
       showVisual: true,
     },
-    rootFolder: {
-      name: 'rootFolder',
-      id: 'root-folder-id',
-      type: 'folder',
-      children: [
-        {
-          name: 'example.tex.tex',
-          id: 'example-doc-id',
-          type: 'doc',
-          selected: false,
-          $$hashKey: 'object:89',
-        },
-        {
-          name: 'frog.jpg',
-          id: 'frog-image-id',
-          type: 'file',
-          linkedFileData: null,
-          created: '2023-05-04T16:11:04.352Z',
-          $$hashKey: 'object:108',
-        },
-      ],
-      selected: false,
-    },
     settings: {
       ...settings,
       overallTheme: theme === 'default-' ? '' : theme,
@@ -127,6 +129,7 @@ export const Visual = (args: any, { globals: { theme } }: any) => {
     'ol-completedTutorials': {
       'table-generator-promotion': '2023-09-01T00:00:00.000Z',
     },
+    'ol-project_id': '63e21c07946dd8c76505f85a',
   })
 
   return <SourceEditor />

+ 44 - 17
services/web/test/frontend/components/pdf-preview/pdf-logs-entries.spec.tsx

@@ -1,9 +1,31 @@
 import { EditorProviders } from '../../helpers/editor-providers'
 import PdfLogsEntries from '../../../../frontend/js/features/pdf-preview/components/pdf-logs-entries'
 import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
+import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
+import { FindResult } from '@/features/file-tree/util/path'
+import { FC } from 'react'
 
 describe('<PdfLogsEntries/>', function () {
-  const fakeEntity = { type: 'doc' }
+  const fakeFindEntityResult: FindResult = {
+    type: 'doc',
+    entity: { _id: '123', name: '123 Doc' },
+  }
+
+  const FileTreePathProvider: FC = ({ children }) => (
+    <FileTreePathContext.Provider
+      value={{
+        dirname: cy.stub(),
+        findEntityByPath: cy
+          .stub()
+          .as('findEntityByPath')
+          .returns(fakeFindEntityResult),
+        pathInFolder: cy.stub(),
+        previewByPath: cy.stub(),
+      }}
+    >
+      {children}
+    </FileTreePathContext.Provider>
+  )
 
   const logEntries = [
     {
@@ -23,11 +45,8 @@ describe('<PdfLogsEntries/>', function () {
 
   beforeEach(function () {
     props = {
-      fileTreeManager: {
-        findEntityByPath: cy.stub().as('findEntityByPath').returns(fakeEntity),
-      },
       editorManager: {
-        openDoc: cy.spy().as('openDoc'),
+        openDocId: cy.spy().as('openDocId'),
       },
     }
 
@@ -47,7 +66,7 @@ describe('<PdfLogsEntries/>', function () {
 
   it('opens doc on click', function () {
     cy.mount(
-      <EditorProviders {...props}>
+      <EditorProviders {...props} providers={{ FileTreePathProvider }}>
         <PdfLogsEntries entries={logEntries} />
       </EditorProviders>
     )
@@ -57,10 +76,14 @@ describe('<PdfLogsEntries/>', function () {
     }).click()
 
     cy.get('@findEntityByPath').should('have.been.calledOnce')
-    cy.get('@openDoc').should('have.been.calledOnceWith', fakeEntity, {
-      gotoLine: 9,
-      gotoColumn: 8,
-    })
+    cy.get('@openDocId').should(
+      'have.been.calledOnceWith',
+      fakeFindEntityResult.entity._id,
+      {
+        gotoLine: 9,
+        gotoColumn: 8,
+      }
+    )
   })
 
   it('opens doc via detached action', function () {
@@ -69,7 +92,7 @@ describe('<PdfLogsEntries/>', function () {
     })
 
     cy.mount(
-      <EditorProviders {...props}>
+      <EditorProviders {...props} providers={{ FileTreePathProvider }}>
         <PdfLogsEntries entries={logEntries} />
       </EditorProviders>
     ).then(() => {
@@ -89,10 +112,14 @@ describe('<PdfLogsEntries/>', function () {
     })
 
     cy.get('@findEntityByPath').should('have.been.calledOnce')
-    cy.get('@openDoc').should('have.been.calledOnceWith', fakeEntity, {
-      gotoLine: 7,
-      gotoColumn: 6,
-    })
+    cy.get('@openDocId').should(
+      'have.been.calledOnceWith',
+      fakeFindEntityResult.entity._id,
+      {
+        gotoLine: 7,
+        gotoColumn: 6,
+      }
+    )
   })
 
   it('sends open doc clicks via detached action', function () {
@@ -101,7 +128,7 @@ describe('<PdfLogsEntries/>', function () {
     })
 
     cy.mount(
-      <EditorProviders {...props}>
+      <EditorProviders {...props} providers={{ FileTreePathProvider }}>
         <PdfLogsEntries entries={logEntries} />
       </EditorProviders>
     )
@@ -113,7 +140,7 @@ describe('<PdfLogsEntries/>', function () {
     }).click()
 
     cy.get('@findEntityByPath').should('not.have.been.called')
-    cy.get('@openDoc').should('not.have.been.called')
+    cy.get('@openDocId').should('not.have.been.called')
     cy.get('@postDetachMessage').should('have.been.calledWith', {
       role: 'detached',
       event: 'action-sync-to-entry',

+ 25 - 13
services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-readonly.spec.tsx

@@ -1,20 +1,24 @@
 import { mockScope } from '../helpers/mock-scope'
 import { EditorProviders } from '../../../helpers/editor-providers'
 import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
-import { FC } from 'react'
+import { FC, ComponentProps } from 'react'
+import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
 
 const Container: FC = ({ children }) => (
   <div style={{ width: 785, height: 785 }}>{children}</div>
 )
 
-const mountEditor = (content: string, ...args: any[]) => {
+const mountEditor = (
+  content: string,
+  props?: Omit<ComponentProps<typeof EditorProviders>, 'children' | 'scope'>
+) => {
   const scope = mockScope(content)
   scope.permissionsLevel = 'readOnly'
   scope.editor.showVisual = true
 
   cy.mount(
     <Container>
-      <EditorProviders scope={scope} {...args}>
+      <EditorProviders scope={scope} {...props}>
         <CodemirrorEditor />
       </EditorProviders>
     </Container>
@@ -81,15 +85,21 @@ describe('<CodeMirrorEditor/> in Visual mode with read-only permission', functio
   })
 
   it('does not display the figure edit button', function () {
-    const fileTreeManager = {
-      findEntityById: cy.stub(),
-      findEntityByPath: cy.stub(),
-      getEntityPath: cy.stub(),
-      getRootDocDirname: cy.stub(),
-      getPreviewByPath: cy
-        .stub()
-        .returns({ url: '/images/frog.jpg', extension: 'jpg' }),
-    }
+    const FileTreePathProvider: FC = ({ children }) => (
+      <FileTreePathContext.Provider
+        value={{
+          dirname: cy.stub(),
+          findEntityByPath: cy.stub(),
+          pathInFolder: cy.stub(),
+          previewByPath: cy
+            .stub()
+            .as('previewByPath')
+            .returns({ url: '/images/frog.jpg', extension: 'jpg' }),
+        }}
+      >
+        {children}
+      </FileTreePathContext.Provider>
+    )
 
     cy.intercept('/images/frog.jpg', { fixture: 'images/gradient.png' })
 
@@ -100,7 +110,9 @@ describe('<CodeMirrorEditor/> in Visual mode with read-only permission', functio
 \\caption{My caption}
 \\label{fig:my-label}
 \\end{figure}`,
-      { fileTreeManager }
+      {
+        providers: { FileTreePathProvider },
+      }
     )
 
     cy.get('img.ol-cm-graphics').should('have.length', 1)

+ 18 - 1
services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx

@@ -5,6 +5,7 @@ import { EditorProviders } from '../../../helpers/editor-providers'
 import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
 import { mockScope } from '../helpers/mock-scope'
 import forEach from 'mocha-each'
+import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
 
 const Container: FC = ({ children }) => (
   <div style={{ width: 785, height: 785 }}>{children}</div>
@@ -23,9 +24,25 @@ describe('<CodeMirrorEditor/> in Visual mode', function () {
     const scope = mockScope(content)
     scope.editor.showVisual = true
 
+    const FileTreePathProvider: FC = ({ children }) => (
+      <FileTreePathContext.Provider
+        value={{
+          dirname: cy.stub(),
+          findEntityByPath: cy.stub(),
+          pathInFolder: cy.stub(),
+          previewByPath: cy
+            .stub()
+            .as('previewByPath')
+            .callsFake(path => ({ url: path, extension: 'png' })),
+        }}
+      >
+        {children}
+      </FileTreePathContext.Provider>
+    )
+
     cy.mount(
       <Container>
-        <EditorProviders scope={scope}>
+        <EditorProviders scope={scope} providers={{ FileTreePathProvider }}>
           <CodemirrorEditor />
         </EditorProviders>
       </Container>

+ 18 - 1
services/web/test/frontend/features/source-editor/components/figure-modal.spec.tsx

@@ -2,6 +2,7 @@ import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/
 import { EditorProviders } from '../../../helpers/editor-providers'
 import { mockScope, rootFolderId } from '../helpers/mock-scope'
 import { FC } from 'react'
+import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
 
 const Container: FC = ({ children }) => (
   <div style={{ width: 1500, height: 785 }}>{children}</div>
@@ -50,9 +51,25 @@ describe('<FigureModal />', function () {
     const scope = mockScope(content)
     scope.editor.showVisual = true
 
+    const FileTreePathProvider: FC = ({ children }) => (
+      <FileTreePathContext.Provider
+        value={{
+          dirname: cy.stub(),
+          findEntityByPath: cy.stub(),
+          pathInFolder: cy.stub(),
+          previewByPath: cy
+            .stub()
+            .as('previewByPath')
+            .returns({ url: 'frog.jpg', extension: 'jpg' }),
+        }}
+      >
+        {children}
+      </FileTreePathContext.Provider>
+    )
+
     cy.mount(
       <Container>
-        <EditorProviders scope={scope}>
+        <EditorProviders scope={scope} providers={{ FileTreePathProvider }}>
           <CodemirrorEditor />
         </EditorProviders>
       </Container>

+ 65 - 44
services/web/test/frontend/helpers/editor-providers.jsx

@@ -1,7 +1,7 @@
 // Disable prop type checks for test harnesses
 /* eslint-disable react/prop-types */
 import sinon from 'sinon'
-import { get } from 'lodash'
+import { get, merge } from 'lodash'
 import { SplitTestProvider } from '@/shared/context/split-test-context'
 import { IdeAngularProvider } from '@/shared/context/ide-angular-provider'
 import { UserProvider } from '@/shared/context/user-context'
@@ -13,6 +13,7 @@ import { LayoutProvider } from '@/shared/context/layout-context'
 import { LocalCompileProvider } from '@/shared/context/local-compile-context'
 import { DetachCompileProvider } from '@/shared/context/detach-compile-context'
 import { ProjectSettingsProvider } from '@/features/editor-left-menu/context/project-settings-context'
+import { FileTreePathProvider } from '@/features/file-tree/contexts/file-tree-path'
 
 // these constants can be imported in tests instead of
 // using magic strings
@@ -70,33 +71,36 @@ export function EditorProviders({
       },
     },
   },
+  providers = {},
 }) {
   window.user = user || window.user
   window.gitBridgePublicBaseUrl = 'https://git.overleaf.test'
   window.project_id = projectId != null ? projectId : window.project_id
   window.isRestrictedTokenMember = isRestrictedTokenMember
 
-  const $scope = {
-    user: window.user,
-    project: {
-      _id: window.project_id,
-      name: PROJECT_NAME,
-      owner: projectOwner,
-      features,
-      rootDoc_id: rootDocId,
-      rootFolder,
-    },
-    ui,
-    $watch: (path, callback) => {
-      callback(get($scope, path))
-      return () => null
+  const $scope = merge(
+    {
+      user: window.user,
+      project: {
+        _id: window.project_id,
+        name: PROJECT_NAME,
+        owner: projectOwner,
+        features,
+        rootDoc_id: rootDocId,
+        rootFolder,
+      },
+      ui,
+      $watch: (path, callback) => {
+        callback(get($scope, path))
+        return () => null
+      },
+      $on: sinon.stub(),
+      $applyAsync: sinon.stub(),
+      toggleHistory: sinon.stub(),
+      permissionsLevel,
     },
-    $on: sinon.stub(),
-    $applyAsync: sinon.stub(),
-    toggleHistory: sinon.stub(),
-    permissionsLevel,
-    ...scope,
-  }
+    scope
+  )
 
   window._ide = {
     $scope,
@@ -109,30 +113,47 @@ export function EditorProviders({
 
   // Add details for useUserContext
   window.metaAttributesCache.set('ol-user', { ...user, features })
+  const Providers = {
+    DetachCompileProvider,
+    DetachProvider,
+    EditorProvider,
+    FileTreeDataProvider,
+    FileTreePathProvider,
+    IdeAngularProvider,
+    LayoutProvider,
+    LocalCompileProvider,
+    ProjectProvider,
+    ProjectSettingsProvider,
+    SplitTestProvider,
+    UserProvider,
+    ...providers,
+  }
 
   return (
-    <SplitTestProvider>
-      <IdeAngularProvider ide={window._ide}>
-        <UserProvider>
-          <ProjectProvider>
-            <FileTreeDataProvider>
-              <DetachProvider>
-                <EditorProvider>
-                  <ProjectSettingsProvider>
-                    <LayoutProvider>
-                      <LocalCompileProvider>
-                        <DetachCompileProvider>
-                          {children}
-                        </DetachCompileProvider>
-                      </LocalCompileProvider>
-                    </LayoutProvider>
-                  </ProjectSettingsProvider>
-                </EditorProvider>
-              </DetachProvider>
-            </FileTreeDataProvider>
-          </ProjectProvider>
-        </UserProvider>
-      </IdeAngularProvider>
-    </SplitTestProvider>
+    <Providers.SplitTestProvider>
+      <Providers.IdeAngularProvider ide={window._ide}>
+        <Providers.UserProvider>
+          <Providers.ProjectProvider>
+            <Providers.FileTreeDataProvider>
+              <Providers.FileTreePathProvider>
+                <Providers.DetachProvider>
+                  <Providers.EditorProvider>
+                    <Providers.ProjectSettingsProvider>
+                      <Providers.LayoutProvider>
+                        <Providers.LocalCompileProvider>
+                          <Providers.DetachCompileProvider>
+                            {children}
+                          </Providers.DetachCompileProvider>
+                        </Providers.LocalCompileProvider>
+                      </Providers.LayoutProvider>
+                    </Providers.ProjectSettingsProvider>
+                  </Providers.EditorProvider>
+                </Providers.DetachProvider>
+              </Providers.FileTreePathProvider>
+            </Providers.FileTreeDataProvider>
+          </Providers.ProjectProvider>
+        </Providers.UserProvider>
+      </Providers.IdeAngularProvider>
+    </Providers.SplitTestProvider>
   )
 }

+ 5 - 0
services/web/types/file-tree-entity.ts

@@ -0,0 +1,5 @@
+import { Folder } from './folder'
+import { Doc } from './doc'
+import { FileRef } from './file-ref'
+
+export type FileTreeEntity = Folder | Doc | FileRef

+ 4 - 0
services/web/types/preview-path.ts

@@ -0,0 +1,4 @@
+export type PreviewPath = {
+  url: string
+  extension: string
+}