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

[ide-react] Improve file tree and outline components in the editor sidebar (#16225)

* Upgrade react-resizable-panels
* Add FileTreeOpenProvider
* Add OutlineProvider and OutlineContainer
* Convert Outline tests to Cypress

GitOrigin-RevId: afd9ae8190edf37642e36a4ffb331f1182c8982d
Alf Eaton 2 лет назад
Родитель
Сommit
c2b553e915
37 измененных файлов с 869 добавлено и 721 удалено
  1. 8 8
      package-lock.json
  2. 1 1
      services/web/app/views/project/editor/file-tree-react.pug
  3. 1 1
      services/web/frontend/js/features/file-tree/components/file-tree-root.jsx
  4. 9 8
      services/web/frontend/js/features/ide-react/components/editor-and-pdf.tsx
  5. 25 27
      services/web/frontend/js/features/ide-react/components/editor-sidebar.tsx
  6. 12 10
      services/web/frontend/js/features/ide-react/components/editor/editor-pane.tsx
  7. 8 15
      services/web/frontend/js/features/ide-react/components/file-tree.tsx
  8. 12 30
      services/web/frontend/js/features/ide-react/components/layout/main-layout.tsx
  9. 7 1
      services/web/frontend/js/features/ide-react/components/resize/vertical-resize-handle.tsx
  10. 57 40
      services/web/frontend/js/features/ide-react/context/file-tree-open-context.tsx
  11. 195 0
      services/web/frontend/js/features/ide-react/context/outline-context.tsx
  12. 11 5
      services/web/frontend/js/features/ide-react/context/react-context-root.tsx
  13. 2 4
      services/web/frontend/js/features/ide-react/hooks/use-fixed-size-column.ts
  14. 44 0
      services/web/frontend/js/features/ide-react/hooks/use-outline-pane.ts
  15. 0 6
      services/web/frontend/js/features/ide-react/types/file-tree.ts
  16. 28 0
      services/web/frontend/js/features/ide-react/util/file-view.ts
  17. 95 0
      services/web/frontend/js/features/outline/components/outline-container.tsx
  18. 8 33
      services/web/frontend/js/features/outline/components/outline-pane.jsx
  19. 9 0
      services/web/frontend/js/features/outline/controllers/outline-controller.js
  20. 17 174
      services/web/frontend/js/features/source-editor/components/codemirror-outline.tsx
  21. 8 8
      services/web/frontend/js/features/source-editor/utils/tree-operations/outline.ts
  22. 1 0
      services/web/frontend/js/ide.js
  23. 4 1
      services/web/frontend/js/shared/context/root-context.jsx
  24. 5 1
      services/web/frontend/stories/decorators/scope.tsx
  25. 3 4
      services/web/frontend/stories/outline.stories.jsx
  26. 10 2
      services/web/frontend/stylesheets/app/editor/ide-react.less
  27. 1 0
      services/web/frontend/stylesheets/app/editor/outline.less
  28. 1 1
      services/web/package.json
  29. 97 0
      services/web/test/frontend/features/outline/components/outline-item.spec.tsx
  30. 0 109
      services/web/test/frontend/features/outline/components/outline-item.test.jsx
  31. 45 0
      services/web/test/frontend/features/outline/components/outline-list.spec.tsx
  32. 0 52
      services/web/test/frontend/features/outline/components/outline-list.test.jsx
  33. 118 0
      services/web/test/frontend/features/outline/components/outline-pane.spec.tsx
  34. 0 149
      services/web/test/frontend/features/outline/components/outline-pane.test.jsx
  35. 22 0
      services/web/test/frontend/features/outline/components/outline-root.spec.tsx
  36. 0 30
      services/web/test/frontend/features/outline/components/outline-root.test.jsx
  37. 5 1
      services/web/test/frontend/helpers/editor-providers.jsx

+ 8 - 8
package-lock.json

@@ -36995,9 +36995,9 @@
       }
     },
     "node_modules/react-resizable-panels": {
-      "version": "0.0.63",
-      "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-0.0.63.tgz",
-      "integrity": "sha512-AfA8b6kouhL4rBvgUGs17uzWVlYPaJIwwTCVeWRxNpUHJlCG1h9RIMlzA1849AZGsaNJO3j/SNdI5SS4GZDE3g==",
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-1.0.1.tgz",
+      "integrity": "sha512-bFKrVLO6VCDp9+zXvLybB3Ldd7MF+Q6E+qE6sxlDfVAlIwEWksJ94CD5RNXTN9a0E3YyAZUkhJEw3a9aCgymzA==",
       "dev": true,
       "peerDependencies": {
         "react": "^16.14.0 || ^17.0.0 || ^18.0.0",
@@ -46748,7 +46748,7 @@
         "react-i18next": "^13.3.1",
         "react-linkify": "^1.0.0-alpha",
         "react-refresh": "^0.14.0",
-        "react-resizable-panels": "^0.0.63",
+        "react-resizable-panels": "^1.0.1",
         "react2angular": "^4.0.6",
         "react2angular-shared-context": "^1.1.0",
         "requirejs": "^2.3.6",
@@ -55574,7 +55574,7 @@
         "react-i18next": "^13.3.1",
         "react-linkify": "^1.0.0-alpha",
         "react-refresh": "^0.14.0",
-        "react-resizable-panels": "^0.0.63",
+        "react-resizable-panels": "^1.0.1",
         "react2angular": "^4.0.6",
         "react2angular-shared-context": "^1.1.0",
         "recurly": "^4.0.0",
@@ -78887,9 +78887,9 @@
       }
     },
     "react-resizable-panels": {
-      "version": "0.0.63",
-      "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-0.0.63.tgz",
-      "integrity": "sha512-AfA8b6kouhL4rBvgUGs17uzWVlYPaJIwwTCVeWRxNpUHJlCG1h9RIMlzA1849AZGsaNJO3j/SNdI5SS4GZDE3g==",
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-1.0.1.tgz",
+      "integrity": "sha512-bFKrVLO6VCDp9+zXvLybB3Ldd7MF+Q6E+qE6sxlDfVAlIwEWksJ94CD5RNXTN9a0E3YyAZUkhJEw3a9aCgymzA==",
       "dev": true,
       "requires": {}
     },

+ 1 - 1
services/web/app/views/project/editor/file-tree-react.pug

@@ -22,6 +22,6 @@ aside.editor-sidebar.full-size(
 			set-started-free-trial="setStartedFreeTrial"
 		)
 
-	.outline-container(
+	outline-container(
 		vertical-resizable-bottom
 	)

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

@@ -34,7 +34,7 @@ const FileTreeRoot = React.memo(function FileTreeRoot({
 }) {
   const { _id: projectId } = useProjectContext(projectContextPropTypes)
   const { fileTreeData } = useFileTreeData()
-  const isReady = projectId && fileTreeData
+  const isReady = Boolean(projectId && fileTreeData)
 
   useEffect(() => {
     if (isReady) onInit()

+ 9 - 8
services/web/frontend/js/features/ide-react/components/editor-and-pdf.tsx

@@ -2,7 +2,6 @@ import React, { FC, useState } from 'react'
 import { Panel, PanelGroup } from 'react-resizable-panels'
 import NoSelectionPane from '@/features/ide-react/components/editor/no-selection-pane'
 import FileView from '@/features/file-view/components/file-view'
-import { fileViewFile } from '@/features/ide-react/hooks/use-file-tree'
 import MultipleSelectionPane from '@/features/ide-react/components/editor/multiple-selection-pane'
 import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
 import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
@@ -12,12 +11,12 @@ import { usePdfPane } from '@/features/ide-react/hooks/use-pdf-pane'
 import { useLayoutContext } from '@/shared/context/layout-context'
 import { useTranslation } from 'react-i18next'
 import classNames from 'classnames'
+import { fileViewFile } from '@/features/ide-react/util/file-view'
+import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
 
 export const EditorAndPdf: FC<{
   editorPane: React.ReactNode
-  selectedEntityCount: number
-  openEntity: any // TODO
-}> = ({ editorPane, selectedEntityCount, openEntity }) => {
+}> = ({ editorPane }) => {
   const [resizing, setResizing] = useState(false)
 
   const { t } = useTranslation()
@@ -33,6 +32,8 @@ export const EditorAndPdf: FC<{
 
   const { view, pdfLayout } = useLayoutContext()
 
+  const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
+
   const editorIsOpen =
     view === 'editor' || view === 'file' || pdfLayout === 'sideBySide'
 
@@ -50,8 +51,8 @@ export const EditorAndPdf: FC<{
           <Panel
             id="panel-main"
             order={1}
-            defaultSizePercentage={50}
-            minSizePercentage={25}
+            defaultSize={50}
+            minSize={25}
             className={classNames('ide-react-panel', {
               'ide-panel-group-resizing': resizing,
             })}
@@ -96,8 +97,8 @@ export const EditorAndPdf: FC<{
         ref={pdfPanelRef}
         id="panel-pdf"
         order={2}
-        defaultSizePercentage={50}
-        minSizePercentage={25}
+        defaultSize={50}
+        minSize={25}
         collapsible
         onCollapse={handlePdfPaneCollapse}
         onExpand={handlePdfPaneExpand}

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

@@ -1,47 +1,45 @@
-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 {
-  FileTreeDeleteHandler,
-  FileTreeSelectHandler,
-} from '@/features/ide-react/types/file-tree'
 import classNames from 'classnames'
+import { useLayoutContext } from '@/shared/context/layout-context'
+import { OutlineContainer } from '@/features/outline/components/outline-container'
+import { useOutlinePane } from '@/features/ide-react/hooks/use-outline-pane'
 
-type EditorSidebarProps = {
-  shouldShow?: boolean
-  onFileTreeInit: () => void
-  onFileTreeSelect: FileTreeSelectHandler
-  onFileTreeDelete: FileTreeDeleteHandler
-}
+export default function EditorSidebar() {
+  const { view } = useLayoutContext()
+
+  const { outlineDisabled, outlineRef } = useOutlinePane()
 
-export default function EditorSidebar({
-  shouldShow = false,
-  onFileTreeInit,
-  onFileTreeSelect,
-  onFileTreeDelete,
-}: EditorSidebarProps) {
   return (
     <aside
       className={classNames('ide-react-editor-sidebar', {
-        hidden: !shouldShow,
+        hidden: view === 'history',
       })}
     >
       <PanelGroup autoSaveId="ide-editor-sidebar-layout" direction="vertical">
         <Panel
-          defaultSizePercentage={75}
+          defaultSize={50}
+          minSize={25}
           className="ide-react-file-tree-panel"
           id="panel-file-tree"
+          order={1}
         >
-          <FileTree
-            onInit={onFileTreeInit}
-            onSelect={onFileTreeSelect}
-            onDelete={onFileTreeDelete}
-          />
+          <FileTree />
         </Panel>
-        <VerticalResizeHandle />
-        <Panel defaultSizePercentage={25} id="panel-outline">
-          <div className="outline-container" />
+
+        <VerticalResizeHandle disabled={outlineDisabled} />
+
+        <Panel
+          defaultSize={50}
+          maxSize={75}
+          id="panel-outline"
+          order={2}
+          collapsible
+          ref={outlineRef}
+          style={{ minHeight: 32 }} // keep the header visible
+        >
+          <OutlineContainer />
         </Panel>
       </PanelGroup>
     </aside>

+ 12 - 10
services/web/frontend/js/features/ide-react/components/editor/editor-pane.tsx

@@ -7,13 +7,15 @@ import classNames from 'classnames'
 import { LoadingPane } from '@/features/ide-react/components/editor/loading-pane'
 import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
 import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
+import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
 
 const SymbolPalettePane = lazy(
   () => import('@/features/ide-react/components/editor/symbol-palette-pane')
 )
 
-export const EditorPane: FC<{ show: boolean }> = ({ show }) => {
+export const EditorPane: FC = () => {
   const [editor] = useScopeValue<EditorScopeValue>('editor')
+  const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
 
   const isLoading = Boolean(
     (!editor.sharejs_doc || editor.opening) &&
@@ -22,12 +24,12 @@ export const EditorPane: FC<{ show: boolean }> = ({ show }) => {
   )
 
   return (
-    <div className="ide-react-editor-content full-size">
-      <PanelGroup
-        autoSaveId="ide-editor-layout"
-        direction="vertical"
-        className={classNames({ hidden: !show })}
-      >
+    <div
+      className={classNames('ide-react-editor-content', 'full-size', {
+        hidden: openEntity?.type !== 'doc' || selectedEntityCount !== 1,
+      })}
+    >
+      <PanelGroup autoSaveId="ide-editor-layout" direction="vertical">
         <Panel
           id="panel-source-editor"
           order={1}
@@ -43,9 +45,9 @@ export const EditorPane: FC<{ show: boolean }> = ({ show }) => {
             <Panel
               id="panel-symbol-palette"
               order={2}
-              defaultSizePixels={250}
-              minSizePixels={250}
-              maxSizePixels={336}
+              defaultSize={25}
+              minSize={10}
+              maxSize={50}
             >
               <Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
                 <SymbolPalettePane />

+ 8 - 15
services/web/frontend/js/features/ide-react/components/file-tree.tsx

@@ -1,26 +1,19 @@
-import FileTreeRoot from '@/features/file-tree/components/file-tree-root'
 import React, { useCallback, useState } from 'react'
 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 {
-  FileTreeDeleteHandler,
-  FileTreeSelectHandler,
-} from '@/features/ide-react/types/file-tree'
 import { RefProviders } from '../../../../../types/user'
+import FileTreeRoot from '@/features/file-tree/components/file-tree-root'
+import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
 
-type FileTreeProps = {
-  onInit: () => void
-  onSelect: FileTreeSelectHandler
-  onDelete: FileTreeDeleteHandler
-}
-
-export function FileTree({ onInit, onSelect, onDelete }: FileTreeProps) {
+export function FileTree() {
   const user = useUserContext()
   const { indexAllReferences } = useReferencesContext()
   const { setStartedFreeTrial } = useIdeReactContext()
   const { isConnected } = useConnectionContext()
+  const { handleFileTreeInit, handleFileTreeSelect, handleFileTreeDelete } =
+    useFileTreeOpenContext()
 
   const [refProviders, setRefProviders] = useState<RefProviders>(
     () => user.refProviders || {}
@@ -45,9 +38,9 @@ export function FileTree({ onInit, onSelect, onDelete }: FileTreeProps) {
         setRefProviderEnabled={setRefProviderEnabled}
         setStartedFreeTrial={setStartedFreeTrial}
         isConnected={isConnected}
-        onInit={onInit}
-        onSelect={onSelect}
-        onDelete={onDelete}
+        onInit={handleFileTreeInit}
+        onSelect={handleFileTreeSelect}
+        onDelete={handleFileTreeDelete}
       />
     </div>
   )

+ 12 - 30
services/web/frontend/js/features/ide-react/components/layout/main-layout.tsx

@@ -11,11 +11,11 @@ import { HistoryProvider } from '@/features/history/context/history-context'
 import History from '@/features/ide-react/components/history'
 import EditorSidebar from '@/features/ide-react/components/editor-sidebar'
 import { EditorPane } from '@/features/ide-react/components/editor/editor-pane'
-import { useFileTree } from '@/features/ide-react/hooks/use-file-tree'
 import { useTranslation } from 'react-i18next'
 import { useSidebarPane } from '@/features/ide-react/hooks/use-sidebar-pane'
 import { useChatPane } from '@/features/ide-react/hooks/use-chat-pane'
 import { EditorAndPdf } from '@/features/ide-react/components/editor-and-pdf'
+import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
 
 export const MainLayout: FC = () => {
   const { view } = useLayoutContext()
@@ -43,23 +43,12 @@ export const MainLayout: FC = () => {
     handlePaneExpand: handleChatExpand,
   } = useChatPane()
 
-  const {
-    selectedEntityCount,
-    openEntity,
-    openDocId,
-    handleFileTreeInit,
-    handleFileTreeSelect,
-    handleFileTreeDelete,
-  } = useFileTree()
+  const { currentDocumentId } = useEditorManagerContext()
 
   const { t } = useTranslation()
 
-  // keep the editor pane open
-  const editorPane = openDocId ? (
-    <EditorPane
-      show={openEntity?.type === 'doc' && selectedEntityCount === 1}
-    />
-  ) : null
+  // keep the editor pane open when a doc is open, even if the history view is open
+  const editorPane = currentDocumentId ? <EditorPane /> : null
 
   return (
     <div className="ide-react-main">
@@ -78,18 +67,14 @@ export const MainLayout: FC = () => {
             ref={sidebarPanelRef}
             id="panel-sidebar"
             order={1}
-            defaultSizePixels={200}
-            minSizePixels={150}
+            defaultSize={10}
+            minSize={10}
+            maxSize={30}
             collapsible
             onCollapse={handleSidebarCollapse}
             onExpand={handleSidebarExpand}
           >
-            <EditorSidebar
-              shouldShow={view !== 'history'}
-              onFileTreeInit={handleFileTreeInit}
-              onFileTreeSelect={handleFileTreeSelect}
-              onFileTreeDelete={handleFileTreeDelete}
-            />
+            <EditorSidebar />
             {view === 'history' && <HistorySidebar />}
           </Panel>
 
@@ -120,11 +105,7 @@ export const MainLayout: FC = () => {
                     <History />
                   </HistoryProvider>
                 ) : (
-                  <EditorAndPdf
-                    editorPane={editorPane}
-                    selectedEntityCount={selectedEntityCount}
-                    openEntity={openEntity}
-                  />
+                  <EditorAndPdf editorPane={editorPane} />
                 )}
               </Panel>
 
@@ -139,8 +120,9 @@ export const MainLayout: FC = () => {
                 ref={chatPanelRef}
                 id="panel-chat"
                 order={2}
-                defaultSizePixels={200}
-                minSizePixels={150}
+                defaultSize={20}
+                minSize={10}
+                maxSize={30}
                 collapsible
                 onCollapse={handleChatCollapse}
                 onExpand={handleChatExpand}

+ 7 - 1
services/web/frontend/js/features/ide-react/components/resize/vertical-resize-handle.tsx

@@ -1,13 +1,19 @@
 import { PanelResizeHandle } from 'react-resizable-panels'
 import { useTranslation } from 'react-i18next'
 import { PanelResizeHandleProps } from 'react-resizable-panels/dist/declarations/src/PanelResizeHandle'
+import classNames from 'classnames'
 
 export function VerticalResizeHandle(props: PanelResizeHandleProps) {
   const { t } = useTranslation()
 
   return (
     <PanelResizeHandle {...props}>
-      <div className="vertical-resize-handle" title={t('resize')} />
+      <div
+        className={classNames('vertical-resize-handle', {
+          'vertical-resize-handle-enabled': !props.disabled,
+        })}
+        title={t('resize')}
+      />
     </PanelResizeHandle>
   )
 }

+ 57 - 40
services/web/frontend/js/features/ide-react/hooks/use-file-tree.ts → services/web/frontend/js/features/ide-react/context/file-tree-open-context.tsx

@@ -1,28 +1,46 @@
-import { FileRef } from '../../../../../types/file-ref'
-import { BinaryFile } from '@/features/file-view/types/binary-file'
+import {
+  createContext,
+  FC,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react'
+import { useProjectContext } from '@/shared/context/project-context'
+import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
+import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
 import { useSelectFileTreeEntity } from '@/features/ide-react/hooks/use-select-file-tree-entity'
 import useScopeValue from '@/shared/hooks/use-scope-value'
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { BinaryFile } from '@/features/file-view/types/binary-file'
 import {
-  FileTreeDeleteHandler,
   FileTreeDocumentFindResult,
   FileTreeFileRefFindResult,
   FileTreeFindResult,
 } from '@/features/ide-react/types/file-tree'
 import { debugConsole } from '@/utils/debugging'
-import { useProjectContext } from '@/shared/context/project-context'
-import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
-import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import { convertFileRefToBinaryFile } from '@/features/ide-react/util/file-view'
 
-export const useFileTree = () => {
+const FileTreeOpenContext = createContext<
+  | {
+      selectedEntityCount: number
+      openEntity: FileTreeDocumentFindResult | FileTreeFileRefFindResult | null
+      handleFileTreeInit: () => void
+      handleFileTreeSelect: (selectedEntities: FileTreeFindResult[]) => void
+      handleFileTreeDelete: (entity: FileTreeFindResult) => void
+    }
+  | undefined
+>(undefined)
+
+export const FileTreeOpenProvider: FC = ({ children }) => {
   const { rootDocId } = useProjectContext()
-  const { eventEmitter } = useIdeReactContext()
+  const { eventEmitter, projectJoined } = useIdeReactContext()
   const {
     openDocId: openDocWithId,
     currentDocumentId: openDocId,
     openInitialDoc,
   } = useEditorManagerContext()
-  const { projectJoined } = useIdeReactContext()
   const { selectEntity } = useSelectFileTreeEntity()
   const [, setOpenFile] = useScopeValue<BinaryFile | null>('openFile')
   const [openEntity, setOpenEntity] = useState<
@@ -68,8 +86,8 @@ export const useFileTree = () => {
     [fileTreeReady, setOpenFile, openDocWithId]
   )
 
-  const handleFileTreeDelete: FileTreeDeleteHandler = useCallback(
-    entity => {
+  const handleFileTreeDelete = useCallback(
+    (entity: FileTreeFindResult) => {
       eventEmitter.emit('entity:deleted', entity)
       // Select the root document if the current document was deleted
       if (entity.entity._id === openDocId) {
@@ -104,38 +122,37 @@ export const useFileTree = () => {
     }
   }, [fileTreeReady, openInitialDoc, projectJoined, rootDocId])
 
-  return {
-    selectedEntityCount,
-    openEntity,
-    openDocId,
+  const value = useMemo(() => {
+    return {
+      selectedEntityCount,
+      openEntity,
+      handleFileTreeInit,
+      handleFileTreeSelect,
+      handleFileTreeDelete,
+    }
+  }, [
+    handleFileTreeDelete,
     handleFileTreeInit,
     handleFileTreeSelect,
-    handleFileTreeDelete,
-  }
-}
+    openEntity,
+    selectedEntityCount,
+  ])
 
-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(),
-  }
+  return (
+    <FileTreeOpenContext.Provider value={value}>
+      {children}
+    </FileTreeOpenContext.Provider>
+  )
 }
 
-// `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.
-export function fileViewFile(fileRef: FileRef) {
-  return {
-    ...convertFileRefToBinaryFile(fileRef),
-    created: fileRef.created,
+export const useFileTreeOpenContext = () => {
+  const context = useContext(FileTreeOpenContext)
+
+  if (!context) {
+    throw new Error(
+      'useFileTreeOpenContext is only available inside FileTreeOpenProvider'
+    )
   }
+
+  return context
 }

+ 195 - 0
services/web/frontend/js/features/ide-react/context/outline-context.tsx

@@ -0,0 +1,195 @@
+import {
+  createContext,
+  Dispatch,
+  FC,
+  SetStateAction,
+  useCallback,
+  useContext,
+  useMemo,
+  useState,
+} from 'react'
+import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
+import useEventListener from '@/shared/hooks/use-event-listener'
+import * as eventTracking from '@/infrastructure/event-tracking'
+import useScopeValue from '@/shared/hooks/use-scope-value'
+import isValidTeXFile from '@/main/is-valid-tex-file'
+import localStorage from '@/infrastructure/local-storage'
+import { useProjectContext } from '@/shared/context/project-context'
+
+export type PartialFlatOutline = {
+  level: number
+  title: string
+  line: number
+}[]
+
+export type FlatOutlineState =
+  | {
+      items: PartialFlatOutline
+      partial: boolean
+    }
+  | undefined
+
+const OutlineContext = createContext<
+  | {
+      flatOutline: FlatOutlineState
+      setFlatOutline: Dispatch<SetStateAction<FlatOutlineState>>
+      highlightedLine: number
+      jumpToLine: (lineNumber: number, syncToPdf: boolean) => void
+      canShowOutline: boolean
+      outlineExpanded: boolean
+      toggleOutlineExpanded: () => void
+    }
+  | undefined
+>(undefined)
+
+export const OutlineProvider: FC = ({ children }) => {
+  const [flatOutline, setFlatOutline] = useState<FlatOutlineState>(undefined)
+  const [currentlyHighlightedLine, setCurrentlyHighlightedLine] =
+    useState<number>(-1)
+  const [binaryFileOpened, setBinaryFileOpened] = useState<boolean>(false)
+  const [ignoreNextCursorUpdate, setIgnoreNextCursorUpdate] =
+    useState<boolean>(false)
+  const [ignoreNextScroll, setIgnoreNextScroll] = useState<boolean>(false)
+
+  const goToLineEmitter = useScopeEventEmitter('editor:gotoLine', true)
+
+  useEventListener(
+    'file-view:file-opened',
+    useCallback(_ => {
+      setBinaryFileOpened(true)
+    }, [])
+  )
+
+  useEventListener(
+    'scroll:editor:update',
+    useCallback(
+      evt => {
+        if (ignoreNextScroll) {
+          setIgnoreNextScroll(false)
+          return
+        }
+        setCurrentlyHighlightedLine(evt.detail + 1)
+      },
+      [ignoreNextScroll]
+    )
+  )
+
+  useEventListener(
+    'cursor:editor:update',
+    useCallback(
+      evt => {
+        if (ignoreNextCursorUpdate) {
+          setIgnoreNextCursorUpdate(false)
+          return
+        }
+        setCurrentlyHighlightedLine(evt.detail.row + 1)
+      },
+      [ignoreNextCursorUpdate]
+    )
+  )
+
+  useEventListener(
+    'doc:after-opened',
+    useCallback(evt => {
+      if (evt.detail) {
+        setIgnoreNextCursorUpdate(true)
+      }
+      setBinaryFileOpened(false)
+      setIgnoreNextScroll(true)
+    }, [])
+  )
+
+  const jumpToLine = useCallback(
+    (lineNumber: number, syncToPdf: boolean) => {
+      setIgnoreNextScroll(true)
+      goToLineEmitter(lineNumber, 0, syncToPdf)
+      eventTracking.sendMB('outline-jump-to-line')
+    },
+    [goToLineEmitter]
+  )
+
+  const highlightedLine = useMemo(
+    () =>
+      closestSectionLineNumber(flatOutline?.items, currentlyHighlightedLine),
+    [flatOutline, currentlyHighlightedLine]
+  )
+
+  // TODO: update when the file is renamed
+  const [docName] = useScopeValue<string | null>('editor.open_doc_name')
+  const isTexFile = useMemo(
+    () => (docName ? isValidTeXFile(docName) : false),
+    [docName]
+  )
+
+  const { _id: projectId } = useProjectContext()
+  const storageKey = `file_outline.expanded.${projectId}`
+
+  const [outlineExpanded, setOutlineExpanded] = useState(
+    () => localStorage.getItem(storageKey) !== false
+  )
+
+  const canShowOutline = isTexFile && !binaryFileOpened
+
+  const toggleOutlineExpanded = useCallback(() => {
+    if (canShowOutline) {
+      localStorage.setItem(storageKey, !outlineExpanded)
+      eventTracking.sendMB(
+        outlineExpanded ? 'outline-collapse' : 'outline-expand'
+      )
+      setOutlineExpanded(!outlineExpanded)
+    }
+  }, [canShowOutline, outlineExpanded, storageKey])
+
+  const value = useMemo(
+    () => ({
+      flatOutline,
+      setFlatOutline,
+      highlightedLine,
+      jumpToLine,
+      canShowOutline,
+      outlineExpanded,
+      toggleOutlineExpanded,
+    }),
+    [
+      flatOutline,
+      highlightedLine,
+      jumpToLine,
+      canShowOutline,
+      outlineExpanded,
+      toggleOutlineExpanded,
+    ]
+  )
+
+  return (
+    <OutlineContext.Provider value={value}>{children}</OutlineContext.Provider>
+  )
+}
+
+export const useOutlineContext = () => {
+  const context = useContext(OutlineContext)
+
+  if (!context) {
+    throw new Error(
+      'useOutlineProvider is only available inside OutlineProvider'
+    )
+  }
+
+  return context
+}
+
+const closestSectionLineNumber = (
+  outline: { line: number }[] | undefined,
+  lineNumber: number
+): number => {
+  if (!outline) {
+    return -1
+  }
+  let highestLine = -1
+  for (const section of outline) {
+    if (section.line > lineNumber) {
+      return highestLine
+    }
+    highestLine = section.line
+  }
+  return highestLine
+}

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

@@ -20,6 +20,8 @@ import { ModalsContextProvider } from '@/features/ide-react/context/modals-conte
 import { FileTreePathProvider } from '@/features/file-tree/contexts/file-tree-path'
 import { UserSettingsProvider } from '@/shared/context/user-settings-context'
 import { PermissionsProvider } from '@/features/ide-react/context/permissions-context'
+import { FileTreeOpenProvider } from '@/features/ide-react/context/file-tree-open-context'
+import { OutlineProvider } from '@/features/ide-react/context/outline-context'
 
 export const ReactContextRoot: FC = ({ children }) => {
   return (
@@ -42,11 +44,15 @@ export const ReactContextRoot: FC = ({ children }) => {
                                     <DetachCompileProvider>
                                       <ChatProvider>
                                         <EditorManagerProvider>
-                                          <OnlineUsersProvider>
-                                            <MetadataProvider>
-                                              {children}
-                                            </MetadataProvider>
-                                          </OnlineUsersProvider>
+                                          <FileTreeOpenProvider>
+                                            <OnlineUsersProvider>
+                                              <MetadataProvider>
+                                                <OutlineProvider>
+                                                  {children}
+                                                </OutlineProvider>
+                                              </MetadataProvider>
+                                            </OnlineUsersProvider>
+                                          </FileTreeOpenProvider>
                                         </EditorManagerProvider>
                                       </ChatProvider>
                                     </DetachCompileProvider>

+ 2 - 4
services/web/frontend/js/features/ide-react/hooks/use-fixed-size-column.ts

@@ -12,7 +12,7 @@ export default function useFixedSizeColumn(isOpen: boolean) {
 
   const handleLayout: PanelGroupOnLayout = useCallback(() => {
     if (fixedPanelRef.current) {
-      fixedPanelSizeRef.current = fixedPanelRef.current.getSize().sizePixels
+      fixedPanelSizeRef.current = fixedPanelRef.current.getSize()
       setInitialLayoutDone(true)
     }
   }, [])
@@ -43,9 +43,7 @@ export default function useFixedSizeColumn(isOpen: boolean) {
 
     const resizeObserver = new ResizeObserver(() => {
       // when the panel group resizes, set the size of this panel to the previous size, in pixels
-      fixedPanelRef.current?.resize({
-        sizePixels: fixedPanelSizeRef.current,
-      })
+      fixedPanelRef.current?.resize(fixedPanelSizeRef.current)
     })
 
     resizeObserver.observe(panelGroupElement)

+ 44 - 0
services/web/frontend/js/features/ide-react/hooks/use-outline-pane.ts

@@ -0,0 +1,44 @@
+import { useOutlineContext } from '@/features/ide-react/context/outline-context'
+import { useProjectContext } from '@/shared/context/project-context'
+import { useEffect, useRef } from 'react'
+import { ImperativePanelHandle } from 'react-resizable-panels'
+import localStorage from '@/infrastructure/local-storage'
+
+export const useOutlinePane = () => {
+  const { canShowOutline, outlineExpanded } = useOutlineContext()
+  const { _id: projectId } = useProjectContext()
+  const outlineDisabled = !canShowOutline || !outlineExpanded
+
+  const outlineRef = useRef<ImperativePanelHandle>(null)
+
+  // store the expanded height in localStorage when collapsing,
+  // so it can be restored when expanding after reloading the page
+  useEffect(() => {
+    const outlinePane = outlineRef.current
+
+    if (outlinePane) {
+      // NOTE: outline size is shared across projects
+      const storageKey = 'ide-panel.outline.size'
+
+      if (outlineDisabled) {
+        // collapsing, so store the current size if > 0
+        const size = outlinePane.getSize()
+        if (size > 0) {
+          localStorage.setItem(storageKey, size)
+        }
+
+        outlinePane.collapse()
+      } else {
+        outlinePane.expand()
+
+        // if the panel has been expanded to zero height, use the stored height instead
+        if (outlinePane.getSize() === 0) {
+          const size = Number(localStorage.getItem(storageKey) || 50)
+          outlinePane.resize(size)
+        }
+      }
+    }
+  }, [outlineDisabled, projectId])
+
+  return { outlineDisabled, outlineRef }
+}

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

@@ -32,9 +32,3 @@ export type FileTreeFindResult =
   | FileTreeFolderFindResult
   | FileTreeDocumentFindResult
   | FileTreeFileRefFindResult
-
-export type FileTreeSelectHandler = (
-  selectedEntities: FileTreeFindResult[]
-) => void
-
-export type FileTreeDeleteHandler = (entity: FileTreeFindResult) => void

+ 28 - 0
services/web/frontend/js/features/ide-react/util/file-view.ts

@@ -0,0 +1,28 @@
+import { FileRef } from '../../../../../types/file-ref'
+import { BinaryFile } from '@/features/file-view/types/binary-file'
+
+export 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
+// `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.
+export function fileViewFile(fileRef: FileRef) {
+  return {
+    ...convertFileRefToBinaryFile(fileRef),
+    created: fileRef.created,
+  }
+}

+ 95 - 0
services/web/frontend/js/features/outline/components/outline-container.tsx

@@ -0,0 +1,95 @@
+import { FC, memo, useEffect, useRef, useState } from 'react'
+import OutlinePane from '@/features/outline/components/outline-pane'
+import {
+  FlatOutlineState,
+  PartialFlatOutline,
+  useOutlineContext,
+} from '@/features/ide-react/context/outline-context'
+import {
+  nestOutline,
+  Outline,
+} from '@/features/source-editor/utils/tree-operations/outline'
+import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
+import { debugConsole } from '@/utils/debugging'
+
+export const OutlineContainer: FC = memo(() => {
+  const {
+    flatOutline,
+    highlightedLine,
+    canShowOutline,
+    jumpToLine,
+    outlineExpanded,
+    toggleOutlineExpanded,
+  } = useOutlineContext()
+
+  const outlineToggledEmitter = useScopeEventEmitter('outline-toggled')
+
+  const [outline, setOutline] = useState<{
+    items: Outline[]
+    partial: boolean
+  }>(() => ({ items: [], partial: false }))
+
+  const prevFlatOutlineRef = useRef<FlatOutlineState>(undefined)
+
+  // when the flat outline changes, calculate the nested outline
+  // TODO: only calculate when outlineExpanded is true
+  useEffect(() => {
+    const prevFlatOutline = prevFlatOutlineRef.current
+    prevFlatOutlineRef.current = flatOutline
+
+    if (flatOutline) {
+      if (outlineChanged(prevFlatOutline?.items, flatOutline.items)) {
+        debugConsole.log('Rebuilding changed outline')
+        setOutline({
+          items: nestOutline(flatOutline.items),
+          partial: flatOutline.partial,
+        })
+      }
+    } else {
+      setOutline({ items: [], partial: false })
+    }
+  }, [flatOutline])
+
+  return (
+    <div className="outline-container">
+      <OutlinePane
+        outline={outline.items}
+        onToggle={outlineToggledEmitter}
+        isTexFile={canShowOutline}
+        jumpToLine={jumpToLine}
+        highlightedLine={highlightedLine}
+        isPartial={outline.partial}
+        expanded={outlineExpanded}
+        toggleExpanded={toggleOutlineExpanded}
+      />
+    </div>
+  )
+})
+OutlineContainer.displayName = 'OutlineContainer'
+
+const outlineChanged = (
+  a: PartialFlatOutline | undefined,
+  b: PartialFlatOutline
+): boolean => {
+  if (!a) {
+    return true
+  }
+
+  if (a.length !== b.length) {
+    return true
+  }
+
+  for (let i = 0; i < a.length; i++) {
+    const aItem = a[i]
+    const bItem = b[i]
+    if (
+      aItem.level !== bItem.level ||
+      aItem.line !== bItem.line ||
+      aItem.title !== bItem.title
+    ) {
+      return true
+    }
+  }
+
+  return false
+}

+ 8 - 33
services/web/frontend/js/features/outline/components/outline-pane.jsx

@@ -1,13 +1,11 @@
-import React, { useState, useEffect } from 'react'
+import React, { useEffect } from 'react'
 import PropTypes from 'prop-types'
 import classNames from 'classnames'
 import { useTranslation } from 'react-i18next'
 
 import OutlineRoot from './outline-root'
 import Icon from '../../../shared/components/icon'
-import localStorage from '../../../infrastructure/local-storage'
 import withErrorBoundary from '../../../infrastructure/error-boundary'
-import { useProjectContext } from '../../../shared/context/project-context'
 import Tooltip from '../../../shared/components/tooltip'
 
 const OutlinePane = React.memo(function OutlinePane({
@@ -15,22 +13,13 @@ const OutlinePane = React.memo(function OutlinePane({
   outline,
   jumpToLine,
   onToggle,
-  eventTracking,
   highlightedLine,
-  show,
   isPartial = false,
+  expanded,
+  toggleExpanded,
 }) {
   const { t } = useTranslation()
 
-  const { _id: projectId } = useProjectContext({
-    _id: PropTypes.string.isRequired,
-  })
-
-  const storageKey = `file_outline.expanded.${projectId}`
-  const [expanded, setExpanded] = useState(() => {
-    const storedExpandedState = localStorage.getItem(storageKey) !== false
-    return storedExpandedState
-  })
   const isOpen = isTexFile && expanded
 
   useEffect(() => {
@@ -41,27 +30,13 @@ const OutlinePane = React.memo(function OutlinePane({
     'outline-pane-disabled': !isTexFile,
   })
 
-  function handleExpandCollapseClick() {
-    if (isTexFile) {
-      localStorage.setItem(storageKey, !expanded)
-      eventTracking.sendMB(expanded ? 'outline-collapse' : 'outline-expand')
-      setExpanded(!expanded)
-    }
-  }
-
-  // NOTE: This flag is for disabling the rendering of the component. Used while
-  // both an Angular and React-based file outline is present in the code base.
-  if (!show) {
-    return null
-  }
-
   return (
     <div className={headerClasses}>
       <header className="outline-header">
         <button
           className="outline-header-expand-collapse-btn"
           disabled={!isTexFile}
-          onClick={handleExpandCollapseClick}
+          onClick={toggleExpanded}
           aria-label={expanded ? t('hide_outline') : t('show_outline')}
         >
           <Icon
@@ -85,7 +60,7 @@ const OutlinePane = React.memo(function OutlinePane({
           )}
         </button>
       </header>
-      {expanded && isTexFile ? (
+      {isOpen && (
         <div className="outline-body">
           <OutlineRoot
             outline={outline}
@@ -93,7 +68,7 @@ const OutlinePane = React.memo(function OutlinePane({
             highlightedLine={highlightedLine}
           />
         </div>
-      ) : null}
+      )}
     </div>
   )
 })
@@ -103,10 +78,10 @@ OutlinePane.propTypes = {
   outline: PropTypes.array.isRequired,
   jumpToLine: PropTypes.func.isRequired,
   onToggle: PropTypes.func.isRequired,
-  eventTracking: PropTypes.object.isRequired,
   highlightedLine: PropTypes.number,
-  show: PropTypes.bool.isRequired,
   isPartial: PropTypes.bool,
+  expanded: PropTypes.bool,
+  toggleExpanded: PropTypes.func.isRequired,
 }
 
 export default withErrorBoundary(OutlinePane)

+ 9 - 0
services/web/frontend/js/features/outline/controllers/outline-controller.js

@@ -0,0 +1,9 @@
+import App from '@/base'
+import { react2angular } from 'react2angular'
+import { rootContext } from '@/shared/context/root-context'
+import { OutlineContainer } from '@/features/outline/components/outline-container'
+
+App.component(
+  'outlineContainer',
+  react2angular(rootContext.use(OutlineContainer))
+)

+ 17 - 174
services/web/frontend/js/features/source-editor/components/codemirror-outline.tsx

@@ -1,190 +1,33 @@
-import { createPortal } from 'react-dom'
 import { useCodeMirrorStateContext } from './codemirror-editor'
-import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import OutlinePane from '../../outline/components/outline-pane'
+import React, { useEffect } from 'react'
 import { documentOutline } from '../languages/latex/document-outline'
-import isValidTeXFile from '../../../main/is-valid-tex-file'
-import useScopeValue from '../../../shared/hooks/use-scope-value'
-import useScopeEventEmitter from '../../../shared/hooks/use-scope-event-emitter'
-import * as eventTracking from '../../../infrastructure/event-tracking'
-import { nestOutline, Outline } from '../utils/tree-query'
 import { ProjectionStatus } from '../utils/tree-operations/projection'
-import useEventListener from '../../../shared/hooks/use-event-listener'
 import useDebounce from '../../../shared/hooks/use-debounce'
-
-const closestSectionLineNumber = (
-  outline: { line: number }[] | undefined,
-  lineNumber: number
-): number => {
-  if (!outline) {
-    return -1
-  }
-  let highestLine = -1
-  for (const section of outline) {
-    if (section.line > lineNumber) {
-      return highestLine
-    }
-    highestLine = section.line
-  }
-  return highestLine
-}
-
-type PartialFlatOutline = {
-  level: number
-  title: string
-  line: number
-}[]
-
-const outlineChanged = (
-  a: PartialFlatOutline | undefined,
-  b: PartialFlatOutline
-): boolean => {
-  if (!a) {
-    return true
-  }
-
-  if (a.length !== b.length) {
-    return true
-  }
-
-  for (let i = 0; i < a.length; i++) {
-    const aItem = a[i]
-    const bItem = b[i]
-    if (
-      aItem.level !== bItem.level ||
-      aItem.line !== bItem.line ||
-      aItem.title !== bItem.title
-    ) {
-      return true
-    }
-  }
-
-  return false
-}
+import { useOutlineContext } from '@/features/ide-react/context/outline-context'
 
 export const CodemirrorOutline = React.memo(function CodemirrorOutline() {
+  const { setFlatOutline } = useOutlineContext()
+
   const state = useCodeMirrorStateContext()
   const debouncedState = useDebounce(state, 100)
-  const [docName] = useScopeValue<string>('editor.open_doc_name')
-  const goToLineEmitter = useScopeEventEmitter('editor:gotoLine', true)
-  const outlineToggledEmitter = useScopeEventEmitter('outline-toggled')
-  const [currentlyHighlightedLine, setCurrentlyHighlightedLine] =
-    useState<number>(-1)
-  const isTexFile = useMemo(() => isValidTeXFile(docName), [docName])
-  const [ignoreNextCursorUpdate, setIgnoreNextCursorUpdate] =
-    useState<boolean>(false)
-  const [ignoreNextScroll, setIgnoreNextScroll] = useState<boolean>(false)
-  const [binaryFileOpened, setBinaryFileOpened] = useState<boolean>(false)
-
-  useEventListener(
-    'file-view:file-opened',
-    useCallback(_ => {
-      setBinaryFileOpened(true)
-    }, [])
-  )
-
-  useEventListener(
-    'scroll:editor:update',
-    useCallback(
-      evt => {
-        if (ignoreNextScroll) {
-          setIgnoreNextScroll(false)
-          return
-        }
-        setCurrentlyHighlightedLine(evt.detail + 1)
-      },
-      [ignoreNextScroll]
-    )
-  )
-
-  useEventListener(
-    'cursor:editor:update',
-    useCallback(
-      evt => {
-        if (ignoreNextCursorUpdate) {
-          setIgnoreNextCursorUpdate(false)
-          return
-        }
-        setCurrentlyHighlightedLine(evt.detail.row + 1)
-      },
-      [ignoreNextCursorUpdate]
-    )
-  )
-
-  useEventListener(
-    'doc:after-opened',
-    useCallback(evt => {
-      if (evt.detail) {
-        setIgnoreNextCursorUpdate(true)
-      }
-      setBinaryFileOpened(false)
-      setIgnoreNextScroll(true)
-    }, [])
-  )
-
   const outlineResult = debouncedState.field(documentOutline, false)
 
   // when the outline projection changes, calculate the flat outline
-  const flatOutline = useMemo<PartialFlatOutline | undefined>(() => {
-    if (!outlineResult || outlineResult.status === ProjectionStatus.Pending) {
-      return undefined
-    }
-
-    // We have a (potentially partial) outline.
-    return outlineResult.items.map(element => {
-      const { level, title, line } = element
-      return { level, title, line }
-    })
-  }, [outlineResult])
-
-  const [outline, setOutline] = useState<Outline[]>([])
-
-  const prevFlatOutlineRef = useRef<PartialFlatOutline | undefined>(undefined)
-
-  // when the flat outline changes, calculate the nested outline
   useEffect(() => {
-    const prevFlatOutline = prevFlatOutlineRef.current
-    prevFlatOutlineRef.current = flatOutline
-
-    if (flatOutline) {
-      if (outlineChanged(prevFlatOutline, flatOutline)) {
-        setOutline(nestOutline(flatOutline))
-      }
+    if (outlineResult && outlineResult.status !== ProjectionStatus.Pending) {
+      // We have a (potentially partial) outline.
+      setFlatOutline({
+        items: outlineResult.items.map(element => ({
+          level: element.level,
+          title: element.title,
+          line: element.line,
+        })),
+        partial: outlineResult?.status === ProjectionStatus.Partial,
+      })
     } else {
-      setOutline([])
+      setFlatOutline(undefined)
     }
-  }, [flatOutline])
-
-  const jumpToLine = useCallback(
-    (lineNumber, syncToPdf) => {
-      setIgnoreNextScroll(true)
-      goToLineEmitter(lineNumber, 0, syncToPdf)
-      eventTracking.sendMB('outline-jump-to-line')
-    },
-    [goToLineEmitter]
-  )
-
-  const highlightedLine = useMemo(
-    () => closestSectionLineNumber(flatOutline, currentlyHighlightedLine),
-    [flatOutline, currentlyHighlightedLine]
-  )
-
-  const outlineDomElement = document.querySelector('.outline-container')
-  if (!outlineDomElement) {
-    return null
-  }
+  }, [outlineResult, setFlatOutline])
 
-  return createPortal(
-    <OutlinePane
-      outline={outline}
-      onToggle={outlineToggledEmitter}
-      eventTracking={eventTracking}
-      isTexFile={isTexFile && !binaryFileOpened}
-      jumpToLine={jumpToLine}
-      highlightedLine={highlightedLine}
-      show
-      isPartial={outlineResult?.status === ProjectionStatus.Partial}
-    />,
-    outlineDomElement
-  )
+  return null
 })

+ 8 - 8
services/web/frontend/js/features/source-editor/utils/tree-operations/outline.ts

@@ -3,6 +3,7 @@ import { SyntaxNode, SyntaxNodeRef } from '@lezer/common'
 import { NodeIntersectsChangeFn, ProjectionItem } from './projection'
 import * as tokens from '../../lezer-latex/latex.terms.mjs'
 import { getEnvironmentArguments, getEnvironmentName } from './environments'
+import { PartialFlatOutline } from '@/features/ide-react/context/outline-context'
 
 export type Outline = {
   line: number
@@ -116,7 +117,7 @@ export const enterNode = (
     }
     const name = command.getChild('SectioningArgument')?.getChild('LongArg')
 
-    if (name == null || command == null) {
+    if (!name) {
       return
     }
 
@@ -185,14 +186,13 @@ const flatItemToOutline = (item: {
   title: string
   line: number
   level: number
-}): Outline => {
-  const { title, line, level } = item
-  return { title, line, level }
-}
+}): Outline => ({
+  title: item.title,
+  line: item.line,
+  level: item.level,
+})
 
-export const nestOutline = (
-  flatOutline: { title: string; line: number; level: number }[]
-): Outline[] => {
+export const nestOutline = (flatOutline: PartialFlatOutline): Outline[] => {
   const parentStack: Outline[] = []
   const outline = []
 

+ 1 - 0
services/web/frontend/js/ide.js

@@ -46,6 +46,7 @@ import './features/share-project-modal/controllers/react-share-project-modal-con
 import './features/source-editor/controllers/grammarly-advert-controller'
 import './features/history/controllers/history-controller'
 import './features/editor-left-menu/controllers/editor-left-menu-controller'
+import './features/outline/controllers/outline-controller'
 import { cleanupServiceWorker } from './utils/service-worker-cleanup'
 import { reportCM6Perf } from './infrastructure/cm6-performance'
 import { debugConsole } from '@/utils/debugging'

+ 4 - 1
services/web/frontend/js/shared/context/root-context.jsx

@@ -15,6 +15,7 @@ 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'
 import { UserSettingsProvider } from '@/shared/context/user-settings-context'
+import { OutlineProvider } from '@/features/ide-react/context/outline-context'
 
 export function ContextRoot({ children, ide }) {
   return (
@@ -31,7 +32,9 @@ export function ContextRoot({ children, ide }) {
                         <LayoutProvider>
                           <LocalCompileProvider>
                             <DetachCompileProvider>
-                              <ChatProvider>{children}</ChatProvider>
+                              <ChatProvider>
+                                <OutlineProvider>{children}</OutlineProvider>
+                              </ChatProvider>
                             </DetachCompileProvider>
                           </LocalCompileProvider>
                         </LayoutProvider>

+ 5 - 1
services/web/frontend/stories/decorators/scope.tsx

@@ -22,6 +22,7 @@ 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'
 import { UserSettingsProvider } from '@/shared/context/user-settings-context'
+import { OutlineProvider } from '@/features/ide-react/context/outline-context'
 
 const scopeWatchers: [string, (value: any) => void][] = []
 
@@ -245,6 +246,7 @@ export const ScopeDecorator = (
     IdeAngularProvider,
     LayoutProvider,
     LocalCompileProvider,
+    OutlineProvider,
     ProjectProvider,
     ProjectSettingsProvider,
     SplitTestProvider,
@@ -267,7 +269,9 @@ export const ScopeDecorator = (
                         <Providers.LayoutProvider>
                           <Providers.LocalCompileProvider>
                             <Providers.DetachCompileProvider>
-                              <Story />
+                              <Providers.OutlineProvider>
+                                <Story />
+                              </Providers.OutlineProvider>
                             </Providers.DetachCompileProvider>
                           </Providers.LocalCompileProvider>
                         </Providers.LayoutProvider>

+ 3 - 4
services/web/frontend/stories/outline.stories.jsx

@@ -49,14 +49,13 @@ export default {
   component: OutlinePane,
   argTypes: {
     jumpToLine: { action: 'jumpToLine' },
+    onToggle: { action: 'onToggle' },
+    toggleExpanded: { action: 'toggleExpanded' },
   },
   args: {
-    eventTracking: { sendMB: () => {} },
     isTexFile: true,
     outline: [],
-    jumpToLine: () => {},
-    onToggle: () => {},
-    show: true,
+    expanded: true,
   },
   decorators: [ScopeDecorator],
 }

+ 10 - 2
services/web/frontend/stylesheets/app/editor/ide-react.less

@@ -92,8 +92,15 @@
   height: 6px;
   background-color: @vertical-resizable-resizer-bg;
 
-  &:hover {
-    background-color: @vertical-resizable-resizer-hover-bg;
+  &.vertical-resize-handle-enabled {
+    &:hover {
+      background-color: @vertical-resizable-resizer-hover-bg;
+    }
+  }
+
+  &:not(.vertical-resize-handle-enabled) {
+    opacity: 0.5;
+    cursor: default;
   }
 
   &::after {
@@ -120,6 +127,7 @@
 
 .ide-react-file-tree-panel {
   display: flex;
+  flex-direction: column;
 
   // Prevent the file tree expanding beyond the boundary of the panel
   .file-tree {

+ 1 - 0
services/web/frontend/stylesheets/app/editor/outline.less

@@ -1,5 +1,6 @@
 .outline-container {
   width: 100%;
+  height: 100%;
   background-color: @file-tree-bg;
 }
 

+ 1 - 1
services/web/package.json

@@ -332,7 +332,7 @@
     "react-i18next": "^13.3.1",
     "react-linkify": "^1.0.0-alpha",
     "react-refresh": "^0.14.0",
-    "react-resizable-panels": "^0.0.63",
+    "react-resizable-panels": "^1.0.1",
     "react2angular": "^4.0.6",
     "react2angular-shared-context": "^1.1.0",
     "requirejs": "^2.3.6",

+ 97 - 0
services/web/test/frontend/features/outline/components/outline-item.spec.tsx

@@ -0,0 +1,97 @@
+import OutlineItem from '../../../../../frontend/js/features/outline/components/outline-item'
+
+describe('<OutlineItem />', function () {
+  it('renders basic item', function () {
+    cy.mount(
+      <OutlineItem
+        // @ts-ignore
+        outlineItem={{
+          title: 'Test Title',
+          line: 1,
+        }}
+        jumpToLine={cy.stub()}
+      />
+    )
+
+    cy.findByRole('treeitem', { current: false })
+    cy.findByRole('button', { name: 'Test Title' })
+    cy.findByRole('button', { name: 'Collapse' }).should('not.exist')
+  })
+
+  it('collapses and expands', function () {
+    cy.mount(
+      <OutlineItem
+        // @ts-ignore
+        outlineItem={{
+          title: 'Parent',
+          line: 1,
+          children: [{ title: 'Child', line: 2 }],
+        }}
+        jumpToLine={cy.stub()}
+      />
+    )
+
+    cy.findByRole('button', { name: 'Child' })
+    cy.findByRole('button', { name: 'Collapse' }).click()
+    cy.findByRole('button', { name: 'Expand' })
+    cy.findByRole('button', { name: 'Child' }).should('not.exist')
+  })
+
+  it('highlights', function () {
+    cy.mount(
+      <OutlineItem
+        // @ts-ignore
+        outlineItem={{
+          title: 'Parent',
+          line: 1,
+        }}
+        jumpToLine={cy.stub()}
+        highlightedLine={1}
+        matchesHighlightedLine
+      />
+    )
+
+    cy.findByRole('treeitem', { current: true })
+  })
+
+  it('highlights when has collapsed highlighted child', function () {
+    cy.mount(
+      <OutlineItem
+        // @ts-ignore
+        outlineItem={{
+          title: 'Parent',
+          line: 1,
+          children: [{ title: 'Child', line: 2 }],
+        }}
+        jumpToLine={cy.stub()}
+        highlightedLine={2}
+        containsHighlightedLine
+      />
+    )
+
+    cy.findByRole('treeitem', { name: 'Parent', current: false })
+    cy.findByRole('treeitem', { name: 'Child', current: true })
+    cy.findByRole('button', { name: 'Collapse' }).click()
+    cy.findByRole('treeitem', { name: 'Parent', current: true })
+  })
+
+  it('click and double-click jump to location', function () {
+    cy.mount(
+      <OutlineItem
+        // @ts-ignore
+        outlineItem={{
+          title: 'Parent',
+          line: 1,
+        }}
+        jumpToLine={cy.stub().as('jumpToLine')}
+      />
+    )
+
+    cy.findByRole('button', { name: 'Parent' }).click()
+    cy.get('@jumpToLine').should('have.been.calledOnceWith', 1, false)
+
+    cy.findByRole('button', { name: 'Parent' }).dblclick()
+    cy.get('@jumpToLine').should('have.been.calledThrice')
+    cy.get('@jumpToLine').should('have.been.calledWith', 1, true)
+  })
+})

+ 0 - 109
services/web/test/frontend/features/outline/components/outline-item.test.jsx

@@ -1,109 +0,0 @@
-import { expect } from 'chai'
-import sinon from 'sinon'
-import { screen, render, fireEvent, waitFor } from '@testing-library/react'
-
-import OutlineItem from '../../../../../frontend/js/features/outline/components/outline-item'
-
-describe('<OutlineItem />', function () {
-  const jumpToLine = sinon.stub()
-
-  afterEach(function () {
-    jumpToLine.reset()
-  })
-
-  it('renders basic item', function () {
-    const outlineItem = {
-      title: 'Test Title',
-      line: 1,
-    }
-    render(<OutlineItem outlineItem={outlineItem} jumpToLine={jumpToLine} />)
-
-    screen.getByRole('treeitem', { current: false })
-    screen.getByRole('button', { name: outlineItem.title })
-    expect(screen.queryByRole('button', { name: 'Collapse' })).to.not.exist
-  })
-
-  it('collapses and expands', function () {
-    const outlineItem = {
-      title: 'Parent',
-      line: 1,
-      children: [{ title: 'Child', line: 2 }],
-    }
-    render(<OutlineItem outlineItem={outlineItem} jumpToLine={jumpToLine} />)
-
-    const collapseButton = screen.getByRole('button', { name: 'Collapse' })
-
-    // test that children are rendered
-    screen.getByRole('button', { name: 'Child' })
-
-    fireEvent.click(collapseButton)
-
-    screen.getByRole('button', { name: 'Expand' })
-
-    expect(screen.queryByRole('button', { name: 'Child' })).to.not.exist
-  })
-
-  it('highlights', function () {
-    const outlineItem = {
-      title: 'Parent',
-      line: 1,
-    }
-
-    render(
-      <OutlineItem
-        outlineItem={outlineItem}
-        jumpToLine={jumpToLine}
-        highlightedLine={1}
-        matchesHighlightedLine
-      />
-    )
-
-    screen.getByRole('treeitem', { current: true })
-  })
-
-  it('highlights when has collapsed highlighted child', function () {
-    const outlineItem = {
-      title: 'Parent',
-      line: 1,
-      children: [{ title: 'Child', line: 2 }],
-    }
-    render(
-      <OutlineItem
-        outlineItem={outlineItem}
-        jumpToLine={jumpToLine}
-        highlightedLine={2}
-        containsHighlightedLine
-      />
-    )
-
-    screen.getByRole('treeitem', { name: 'Parent', current: false })
-    screen.getByRole('treeitem', { name: 'Child', current: true })
-
-    fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
-
-    screen.getByRole('treeitem', { name: 'Parent', current: true })
-  })
-
-  it('click and double-click jump to location', async function () {
-    const outlineItem = {
-      title: 'Parent',
-      line: 1,
-    }
-    render(<OutlineItem outlineItem={outlineItem} jumpToLine={jumpToLine} />)
-
-    const titleButton = screen.getByRole('button', { name: outlineItem.title })
-
-    fireEvent.click(titleButton, { detail: 1 })
-    await waitFor(() => {
-      expect(jumpToLine).to.be.calledOnce
-      expect(jumpToLine).to.be.calledWith(1, false)
-    })
-
-    jumpToLine.reset()
-    fireEvent.click(titleButton, { detail: 2 })
-    await waitFor(() => {
-      expect(jumpToLine).to.be.calledOnce
-      expect(jumpToLine).to.be.calledWith(1, true)
-    })
-  })
-})

+ 45 - 0
services/web/test/frontend/features/outline/components/outline-list.spec.tsx

@@ -0,0 +1,45 @@
+import OutlineList from '../../../../../frontend/js/features/outline/components/outline-list'
+
+describe('<OutlineList />', function () {
+  it('renders items', function () {
+    cy.mount(
+      <OutlineList
+        // @ts-ignore
+        outline={[
+          { title: 'Section 1', line: 1, level: 10 },
+          { title: 'Section 2', line: 2, level: 10 },
+        ]}
+        isRoot
+        jumpToLine={cy.stub()}
+      />
+    )
+
+    cy.findByRole('treeitem', { name: 'Section 1' })
+    cy.findByRole('treeitem', { name: 'Section 2' })
+  })
+
+  it('renders as root', function () {
+    cy.mount(
+      <OutlineList
+        // @ts-ignore
+        outline={[{ title: 'Section', line: 1, level: 10 }]}
+        isRoot
+        jumpToLine={cy.stub()}
+      />
+    )
+
+    cy.findByRole('tree')
+  })
+
+  it('renders as non-root', function () {
+    cy.mount(
+      <OutlineList
+        // @ts-ignore
+        outline={[{ title: 'Section', line: 1, level: 10 }]}
+        jumpToLine={cy.stub()}
+      />
+    )
+
+    cy.findByRole('group')
+  })
+})

+ 0 - 52
services/web/test/frontend/features/outline/components/outline-list.test.jsx

@@ -1,52 +0,0 @@
-import { screen, render } from '@testing-library/react'
-
-import OutlineList from '../../../../../frontend/js/features/outline/components/outline-list'
-
-describe('<OutlineList />', function () {
-  const jumpToLine = () => {}
-
-  it('renders items', function () {
-    const outline = [
-      {
-        title: 'Section 1',
-        line: 1,
-        level: 10,
-      },
-      {
-        title: 'Section 2',
-        line: 2,
-        level: 10,
-      },
-    ]
-    render(<OutlineList outline={outline} isRoot jumpToLine={jumpToLine} />)
-
-    screen.getByRole('treeitem', { name: 'Section 1' })
-    screen.getByRole('treeitem', { name: 'Section 2' })
-  })
-
-  it('renders as root', function () {
-    const outline = [
-      {
-        title: 'Section',
-        line: 1,
-        level: 10,
-      },
-    ]
-    render(<OutlineList outline={outline} isRoot jumpToLine={jumpToLine} />)
-
-    screen.getByRole('tree')
-  })
-
-  it('renders as non-root', function () {
-    const outline = [
-      {
-        title: 'Section',
-        line: 1,
-        level: 10,
-      },
-    ]
-    render(<OutlineList outline={outline} jumpToLine={jumpToLine} />)
-
-    screen.getByRole('group')
-  })
-})

+ 118 - 0
services/web/test/frontend/features/outline/components/outline-pane.spec.tsx

@@ -0,0 +1,118 @@
+import OutlinePane from '@/features/outline/components/outline-pane'
+import { EditorProviders, PROJECT_ID } from '../../../helpers/editor-providers'
+import { useState } from 'react'
+
+describe('<OutlinePane />', function () {
+  it('renders expanded outline', function () {
+    cy.mount(
+      <EditorProviders>
+        <OutlinePane
+          isTexFile
+          outline={[{ title: 'Section', line: 1, level: 10 }]}
+          jumpToLine={cy.stub()}
+          onToggle={cy.stub()}
+          expanded
+          toggleExpanded={cy.stub()}
+        />
+      </EditorProviders>
+    )
+
+    cy.findByRole('tree')
+  })
+
+  it('renders disabled outline', function () {
+    cy.mount(
+      <EditorProviders>
+        <OutlinePane
+          isTexFile
+          outline={[]}
+          jumpToLine={cy.stub()}
+          onToggle={cy.stub()}
+          expanded
+          toggleExpanded={cy.stub()}
+        />
+      </EditorProviders>
+    )
+
+    cy.findByRole('tree').should('not.exist')
+  })
+
+  it('expand outline and use local storage', function () {
+    window.localStorage.setItem(`file_outline.expanded.${PROJECT_ID}`, 'false')
+
+    const onToggle = cy.stub()
+
+    const Container = () => {
+      const [expanded, setExpanded] = useState(false)
+
+      return (
+        <OutlinePane
+          isTexFile
+          outline={[{ title: 'Section', line: 1, level: 10 }]}
+          jumpToLine={cy.stub()}
+          onToggle={onToggle}
+          expanded={expanded}
+          toggleExpanded={() => {
+            window.localStorage.setItem(
+              `file_outline.expanded.${PROJECT_ID}`,
+              expanded ? 'false' : 'true'
+            )
+            setExpanded(!expanded)
+          }}
+        />
+      )
+    }
+
+    cy.mount(
+      <EditorProviders>
+        <Container />
+      </EditorProviders>
+    )
+
+    cy.findByRole('tree').should('not.exist')
+
+    cy.findByRole('button', {
+      name: 'Show File outline',
+    }).click()
+
+    cy.findByRole('tree').then(() => {
+      expect(onToggle).to.be.calledTwice
+      expect(
+        window.localStorage.getItem(`file_outline.expanded.${PROJECT_ID}`)
+      ).to.equal('true')
+    })
+  })
+
+  it('shows warning on partial result', function () {
+    cy.mount(
+      <EditorProviders>
+        <OutlinePane
+          isTexFile
+          outline={[]}
+          jumpToLine={cy.stub()}
+          onToggle={cy.stub()}
+          toggleExpanded={cy.stub()}
+          isPartial
+        />
+      </EditorProviders>
+    )
+
+    cy.findByRole('status')
+  })
+
+  it('shows no warning on non-partial result', function () {
+    cy.mount(
+      <EditorProviders>
+        <OutlinePane
+          isTexFile
+          outline={[]}
+          jumpToLine={cy.stub()}
+          onToggle={cy.stub()}
+          toggleExpanded={cy.stub()}
+        />
+      </EditorProviders>
+    )
+
+    cy.findByRole('status').should('not.exist')
+  })
+})

+ 0 - 149
services/web/test/frontend/features/outline/components/outline-pane.test.jsx

@@ -1,149 +0,0 @@
-import { expect } from 'chai'
-import sinon from 'sinon'
-import { screen, fireEvent } from '@testing-library/react'
-
-import OutlinePane from '../../../../../frontend/js/features/outline/components/outline-pane'
-import { renderWithEditorContext } from '../../../helpers/render-with-context'
-
-describe('<OutlinePane />', function () {
-  const jumpToLine = () => {}
-  const onToggle = sinon.stub()
-  const eventTracking = { sendMB: sinon.stub() }
-
-  function render(children) {
-    renderWithEditorContext(children, { projectId: '123abc' })
-  }
-
-  let originalLocalStorage
-  before(function () {
-    originalLocalStorage = global.localStorage
-
-    Object.defineProperty(global, 'localStorage', {
-      value: {
-        getItem: sinon.stub().returns(null),
-        setItem: sinon.stub(),
-        removeItem: sinon.stub(),
-      },
-    })
-  })
-
-  afterEach(function () {
-    onToggle.reset()
-    eventTracking.sendMB.reset()
-    global.localStorage.getItem.resetHistory()
-    global.localStorage.setItem.resetHistory()
-  })
-
-  after(function () {
-    Object.defineProperty(global, 'localStorage', {
-      value: originalLocalStorage,
-    })
-  })
-
-  it('renders expanded outline', function () {
-    const outline = [
-      {
-        title: 'Section',
-        line: 1,
-        level: 10,
-      },
-    ]
-    render(
-      <OutlinePane
-        isTexFile
-        outline={outline}
-        jumpToLine={jumpToLine}
-        onToggle={onToggle}
-        eventTracking={eventTracking}
-        show
-      />
-    )
-
-    screen.getByRole('tree')
-  })
-
-  it('renders disabled outline', function () {
-    const outline = []
-    render(
-      <OutlinePane
-        isTexFile={false}
-        outline={outline}
-        jumpToLine={jumpToLine}
-        onToggle={onToggle}
-        eventTracking={eventTracking}
-        show
-      />
-    )
-
-    expect(screen.queryByRole('tree')).to.be.null
-  })
-
-  it('expand outline and use local storage', function () {
-    global.localStorage.getItem.callsFake(key => {
-      if (key.startsWith('file_outline.expanded.')) {
-        return false
-      }
-      return null
-    })
-
-    const outline = [
-      {
-        title: 'Section',
-        line: 1,
-        level: 10,
-      },
-    ]
-    render(
-      <OutlinePane
-        isTexFile
-        outline={outline}
-        jumpToLine={jumpToLine}
-        onToggle={onToggle}
-        eventTracking={eventTracking}
-        show
-      />
-    )
-
-    expect(screen.queryByRole('tree')).to.be.null
-    const collapseButton = screen.getByRole('button', {
-      name: 'Show File outline',
-    })
-    fireEvent.click(collapseButton)
-
-    screen.getByRole('tree')
-    expect(global.localStorage.setItem).to.be.calledOnce
-    expect(global.localStorage.setItem).to.be.calledWithMatch(/123abc/, 'true')
-    expect(onToggle).to.be.calledTwice
-  })
-
-  it('shows warning on partial result', function () {
-    render(
-      <OutlinePane
-        isTexFile
-        outline={[]}
-        jumpToLine={jumpToLine}
-        onToggle={onToggle}
-        eventTracking={eventTracking}
-        show
-        isPartial
-      />
-    )
-    expect(screen.queryByRole('status')).to.exist
-  })
-
-  it('shows no warning on non-partial result', function () {
-    render(
-      <OutlinePane
-        isTexFile
-        outline={[]}
-        jumpToLine={jumpToLine}
-        onToggle={onToggle}
-        eventTracking={eventTracking}
-        show
-        isPartial={false}
-      />
-    )
-
-    expect(screen.queryByRole('status')).to.not.exist
-  })
-})

+ 22 - 0
services/web/test/frontend/features/outline/components/outline-root.spec.tsx

@@ -0,0 +1,22 @@
+import OutlineRoot from '../../../../../frontend/js/features/outline/components/outline-root'
+
+describe('<OutlineRoot />', function () {
+  it('renders outline', function () {
+    cy.mount(
+      <OutlineRoot
+        outline={[{ title: 'Section', line: 1, level: 10 }]}
+        jumpToLine={cy.stub()}
+      />
+    )
+
+    cy.findByRole('tree')
+    cy.findByRole('link').should('not.exist')
+  })
+
+  it('renders placeholder', function () {
+    cy.mount(<OutlineRoot outline={[]} jumpToLine={cy.stub()} />)
+
+    cy.findByRole('tree').should('not.exist')
+    cy.findByRole('link')
+  })
+})

+ 0 - 30
services/web/test/frontend/features/outline/components/outline-root.test.jsx

@@ -1,30 +0,0 @@
-import { expect } from 'chai'
-import { screen, render } from '@testing-library/react'
-
-import OutlineRoot from '../../../../../frontend/js/features/outline/components/outline-root'
-
-describe('<OutlineRoot />', function () {
-  const jumpToLine = () => {}
-
-  it('renders outline', function () {
-    const outline = [
-      {
-        title: 'Section',
-        line: 1,
-        level: 10,
-      },
-    ]
-    render(<OutlineRoot outline={outline} jumpToLine={jumpToLine} />)
-
-    screen.getByRole('tree')
-    expect(screen.queryByRole('link')).to.be.null
-  })
-
-  it('renders placeholder', function () {
-    const outline = []
-    render(<OutlineRoot outline={outline} jumpToLine={jumpToLine} />)
-
-    expect(screen.queryByRole('tree')).to.be.null
-    screen.getByRole('link')
-  })
-})

+ 5 - 1
services/web/test/frontend/helpers/editor-providers.jsx

@@ -15,6 +15,7 @@ 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'
 import { UserSettingsProvider } from '@/shared/context/user-settings-context'
+import { OutlineProvider } from '@/features/ide-react/context/outline-context'
 
 // these constants can be imported in tests instead of
 // using magic strings
@@ -141,6 +142,7 @@ export function EditorProviders({
     IdeAngularProvider,
     LayoutProvider,
     LocalCompileProvider,
+    OutlineProvider,
     ProjectProvider,
     ProjectSettingsProvider,
     SplitTestProvider,
@@ -163,7 +165,9 @@ export function EditorProviders({
                         <Providers.LayoutProvider>
                           <Providers.LocalCompileProvider>
                             <Providers.DetachCompileProvider>
-                              {children}
+                              <Providers.OutlineProvider>
+                                {children}
+                              </Providers.OutlineProvider>
                             </Providers.DetachCompileProvider>
                           </Providers.LocalCompileProvider>
                         </Providers.LayoutProvider>