[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
This commit is contained in:
Alf Eaton 2023-12-15 09:19:42 +00:00 committed by Copybot
parent 5e7665e322
commit c2b553e915
37 changed files with 869 additions and 721 deletions

16
package-lock.json generated
View file

@ -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": {}
},

View file

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

View file

@ -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()

View file

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

View file

@ -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>

View file

@ -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 />

View file

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

View file

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

View file

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

View file

@ -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,
])
return (
<FileTreeOpenContext.Provider value={value}>
{children}
</FileTreeOpenContext.Provider>
)
}
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(),
}
}
export const useFileTreeOpenContext = () => {
const context = useContext(FileTreeOpenContext)
// `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,
if (!context) {
throw new Error(
'useFileTreeOpenContext is only available inside FileTreeOpenProvider'
)
}
return context
}

View file

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

View file

@ -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>

View file

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

View file

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

View file

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

View file

@ -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,
}
}

View file

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

View file

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

View file

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

View file

@ -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])
}, [outlineResult, setFlatOutline])
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
}
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
})

View file

@ -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 = []

View file

@ -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'

View file

@ -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>

View file

@ -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>

View file

@ -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],
}

View file

@ -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 {

View file

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

View file

@ -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",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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>