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

Merge pull request #20298 from overleaf/rd-ide-filetree

[web] Migrate the file tree on the editor page to Bootstrap 5

GitOrigin-RevId: e2efec26242c8cdab37a54bc182b83bfb0f1eb3c
Rebeka Dekany 1 год назад
Родитель
Сommit
abb59e4603
40 измененных файлов с 1297 добавлено и 326 удалено
  1. 1 0
      services/web/frontend/extracted-translations.json
  2. 53 21
      services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx
  3. 2 2
      services/web/frontend/js/features/file-tree/components/file-tree-create/danger-message.jsx
  4. 45 34
      services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-create-name-input.jsx
  5. 5 4
      services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-body.jsx
  6. 17 12
      services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-footer.jsx
  7. 10 6
      services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-mode.jsx
  8. 11 11
      services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-upload-conflicts.tsx
  9. 62 32
      services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx
  10. 7 6
      services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-url.jsx
  11. 32 10
      services/web/frontend/js/features/file-tree/components/file-tree-doc.jsx
  12. 3 3
      services/web/frontend/js/features/file-tree/components/file-tree-error.jsx
  13. 40 17
      services/web/frontend/js/features/file-tree/components/file-tree-folder.jsx
  14. 1 1
      services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.tsx
  15. 79 23
      services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.jsx
  16. 13 2
      services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu.jsx
  17. 15 11
      services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-file.jsx
  18. 27 28
      services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-folder.jsx
  19. 31 26
      services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-delete.jsx
  20. 18 12
      services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-error.jsx
  21. 6 4
      services/web/frontend/js/features/file-tree/util/icon-type-from-name.js
  22. 1 1
      services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx
  23. 4 1
      services/web/frontend/js/features/ui/components/bootstrap-5/language-picker.tsx
  24. 3 0
      services/web/frontend/js/features/ui/components/types/dropdown-menu-props.ts
  25. 23 5
      services/web/frontend/js/shared/components/loading-spinner.tsx
  26. 3 1
      services/web/frontend/js/shared/components/material-icon.tsx
  27. 8 8
      services/web/frontend/stylesheets/app/editor/file-tree.less
  28. 1 4
      services/web/frontend/stylesheets/app/editor/ide-react.less
  29. 0 13
      services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss
  30. 4 0
      services/web/frontend/stylesheets/bootstrap-5/base/layout.scss
  31. 1 0
      services/web/frontend/stylesheets/bootstrap-5/components/button.scss
  32. 1 1
      services/web/frontend/stylesheets/bootstrap-5/components/dropdown-menu.scss
  33. 603 0
      services/web/frontend/stylesheets/bootstrap-5/pages/editor/file-tree.scss
  34. 139 2
      services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide.scss
  35. 10 17
      services/web/frontend/stylesheets/bootstrap-5/pages/editor/outline.scss
  36. 1 0
      services/web/locales/en.json
  37. 1 1
      services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-inner.spec.tsx
  38. 13 4
      services/web/test/frontend/features/file-tree/components/file-tree-root.spec.tsx
  39. 2 2
      services/web/test/frontend/features/file-tree/flows/delete-entity.spec.tsx
  40. 1 1
      services/web/test/frontend/features/file-tree/flows/rename-entity.spec.tsx

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

@@ -968,6 +968,7 @@
   "only_group_admin_or_managers_can_delete_your_account_5": "",
   "only_importer_can_refresh": "",
   "open_a_file_on_the_left": "",
+  "open_action_menu": "",
   "open_advanced_reference_search": "",
   "open_file": "",
   "open_link": "",

+ 53 - 21
services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx

@@ -1,10 +1,15 @@
 import React, { useEffect, useRef } from 'react'
 import ReactDOM from 'react-dom'
-import { Dropdown } from 'react-bootstrap'
+import { Dropdown as BS3Dropdown } from 'react-bootstrap'
+import {
+  Dropdown,
+  DropdownMenu,
+} from '@/features/ui/components/bootstrap-5/dropdown-menu'
 import { useFileTreeData } from '@/shared/context/file-tree-data-context'
 import { useFileTreeMainContext } from '../contexts/file-tree-main'
 
 import FileTreeItemMenuItems from './file-tree-item/file-tree-item-menu-items'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 function FileTreeContextMenu() {
   const { fileTreeReadOnly } = useFileTreeData()
@@ -24,10 +29,10 @@ function FileTreeContextMenu() {
 
   // A11y - Move the focus to the context menu when it opens
   function focusContextMenu() {
-    const contextMenu = document.querySelector(
+    const BS3contextMenu = document.querySelector(
       '[aria-labelledby="dropdown-file-tree-context-menu"]'
     ) as HTMLElement | null
-    contextMenu?.focus()
+    BS3contextMenu?.focus()
   }
 
   function close() {
@@ -48,31 +53,58 @@ function FileTreeContextMenu() {
 
   // A11y - Close the context menu when the user presses the Tab key
   // Focus should move to the next element in the filetree
-  function handleKeyDown(event: React.KeyboardEvent<Dropdown>) {
+  function handleKeyDown(event: React.KeyboardEvent<BS3Dropdown | Element>) {
     if (event.key === 'Tab') {
       close()
     }
   }
 
   return ReactDOM.createPortal(
-    <Dropdown
-      onClick={handleClick}
-      open
-      id="dropdown-file-tree-context-menu"
-      onToggle={handleToggle}
-      dropup={
-        document.body.offsetHeight / contextMenuCoords.top < 2 &&
-        document.body.offsetHeight - contextMenuCoords.top < 250
+    <BootstrapVersionSwitcher
+      bs3={
+        <BS3Dropdown
+          onClick={handleClick}
+          open
+          id="dropdown-file-tree-context-menu"
+          onToggle={handleToggle}
+          dropup={
+            document.body.offsetHeight / contextMenuCoords.top < 2 &&
+            document.body.offsetHeight - contextMenuCoords.top < 250
+          }
+          className="context-menu"
+          style={contextMenuCoords}
+          onKeyDown={handleKeyDown}
+        >
+          <FakeDropDownToggle bsRole="toggle" />
+          <BS3Dropdown.Menu tabIndex={-1}>
+            <FileTreeItemMenuItems />
+          </BS3Dropdown.Menu>
+        </BS3Dropdown>
       }
-      className="context-menu"
-      style={contextMenuCoords}
-      onKeyDown={handleKeyDown}
-    >
-      <FakeDropDownToggle bsRole="toggle" />
-      <Dropdown.Menu tabIndex={-1}>
-        <FileTreeItemMenuItems />
-      </Dropdown.Menu>
-    </Dropdown>,
+      bs5={
+        <div style={contextMenuCoords} className="context-menu">
+          <Dropdown
+            show
+            drop={
+              document.body.offsetHeight / contextMenuCoords.top < 2 &&
+              document.body.offsetHeight - contextMenuCoords.top < 250
+                ? 'up'
+                : 'down'
+            }
+            focusFirstItemOnShow // A11y - Focus the first item in the context menu when it opens since the menu is rendered at the root level
+            onKeyDown={handleKeyDown}
+            onToggle={handleToggle}
+          >
+            <DropdownMenu
+              className="dropdown-menu-sm-width"
+              id="dropdown-file-tree-context-menu"
+            >
+              <FileTreeItemMenuItems />
+            </DropdownMenu>
+          </Dropdown>
+        </div>
+      }
+    />,
     document.body
   )
 }

+ 2 - 2
services/web/frontend/js/features/file-tree/components/file-tree-create/danger-message.jsx

@@ -1,8 +1,8 @@
+import OLNotification from '@/features/ui/components/ol/ol-notification'
 import PropTypes from 'prop-types'
-import { Alert } from 'react-bootstrap'
 
 export default function DangerMessage({ children }) {
-  return <Alert bsStyle="danger">{children}</Alert>
+  return <OLNotification type="error" content={children} />
 }
 DangerMessage.propTypes = {
   children: PropTypes.any.isRequired,

+ 45 - 34
services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-create-name-input.jsx

@@ -1,7 +1,4 @@
-import ControlLabel from 'react-bootstrap/lib/ControlLabel'
-import { Alert, FormControl } from 'react-bootstrap'
-import FormGroup from 'react-bootstrap/lib/FormGroup'
-import { useCallback } from 'react'
+import { useRef, useEffect } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useFileTreeCreateName } from '../../contexts/file-tree-create-name'
 import PropTypes from 'prop-types'
@@ -10,6 +7,10 @@ import {
   DuplicateFilenameError,
   InvalidFilenameError,
 } from '../../errors'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
+import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
+import OLFormControl from '@/features/ui/components/ol/ol-form-control'
+import OLNotification from '@/features/ui/components/ol/ol-notification'
 
 /**
  * A form component that renders a text input with label,
@@ -29,42 +30,46 @@ export default function FileTreeCreateNameInput({
   const { name, setName, touchedName, validName } = useFileTreeCreateName()
 
   // focus the first part of the filename if needed
-  const inputRef = useCallback(
-    element => {
-      if (element && focusName) {
-        window.requestAnimationFrame(() => {
-          element.focus()
-          element.setSelectionRange(0, element.value.lastIndexOf('.'))
-        })
-      }
-    },
-    [focusName]
-  )
+  const inputRef = useRef(null)
+
+  useEffect(() => {
+    if (inputRef.current && focusName) {
+      window.requestAnimationFrame(() => {
+        if (inputRef.current) {
+          inputRef.current.focus()
+          inputRef.current.setSelectionRange(
+            0,
+            inputRef.current.value.lastIndexOf('.')
+          )
+        }
+      })
+    }
+  }, [focusName])
 
   return (
-    <FormGroup controlId="new-doc-name" className={classes.formGroup}>
-      <ControlLabel>{label || t('file_name')}</ControlLabel>
+    <OLFormGroup controlId="new-doc-name" className={classes.formGroup}>
+      <OLFormLabel>{label || t('file_name')}</OLFormLabel>
 
-      <FormControl
+      <OLFormControl
         type="text"
         placeholder={placeholder || t('file_name')}
         required
         value={name}
         onChange={event => setName(event.target.value)}
-        inputRef={inputRef}
+        ref={inputRef}
         disabled={inFlight}
       />
 
-      <FormControl.Feedback />
-
       {touchedName && !validName && (
-        <Alert bsStyle="danger" className="row-spaced-small">
-          {t('files_cannot_include_invalid_characters')}
-        </Alert>
+        <OLNotification
+          type="error"
+          className="row-spaced-small"
+          content={t('files_cannot_include_invalid_characters')}
+        />
       )}
 
       {error && <ErrorMessage error={error} />}
-    </FormGroup>
+    </OLFormGroup>
   )
 }
 
@@ -89,23 +94,29 @@ function ErrorMessage({ error }) {
   switch (error.constructor) {
     case DuplicateFilenameError:
       return (
-        <Alert bsStyle="danger" className="row-spaced-small">
-          {t('file_already_exists')}
-        </Alert>
+        <OLNotification
+          type="error"
+          className="row-spaced-small"
+          content={t('file_already_exists')}
+        />
       )
 
     case InvalidFilenameError:
       return (
-        <Alert bsStyle="danger" className="row-spaced-small">
-          {t('files_cannot_include_invalid_characters')}
-        </Alert>
+        <OLNotification
+          type="error"
+          className="row-spaced-small"
+          content={t('files_cannot_include_invalid_characters')}
+        />
       )
 
     case BlockedFilenameError:
       return (
-        <Alert bsStyle="danger" className="row-spaced-small">
-          {t('blocked_filename')}
-        </Alert>
+        <OLNotification
+          type="error"
+          className="row-spaced-small"
+          content={t('blocked_filename')}
+        />
       )
 
     default:

+ 5 - 4
services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-body.jsx

@@ -11,6 +11,7 @@ import importOverleafModules from '../../../../../macros/import-overleaf-module.
 import { lazy, Suspense } from 'react'
 import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
 import getMeta from '@/utils/meta'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 const createFileModeModules = importOverleafModules('createFileModes')
 
@@ -35,11 +36,11 @@ export default function FileTreeModalCreateFileBody() {
     <table>
       <tbody>
         <tr>
-          <td className="modal-new-file--list">
+          <td className="modal-new-file-list">
             <ul className="list-unstyled">
               <FileTreeModalCreateFileMode
                 mode="doc"
-                icon="file"
+                icon={bsVersion({ bs5: 'description', bs3: 'file' })}
                 label={t('new_file')}
               />
 
@@ -53,7 +54,7 @@ export default function FileTreeModalCreateFileBody() {
                 hasLinkedProjectOutputFileFeature) && (
                 <FileTreeModalCreateFileMode
                   mode="project"
-                  icon="folder-open"
+                  icon={bsVersion({ bs5: 'folder_open', bs3: 'folder-open' })}
                   label={t('from_another_project')}
                 />
               )}
@@ -75,7 +76,7 @@ export default function FileTreeModalCreateFileBody() {
           </td>
 
           <td
-            className={`modal-new-file--body modal-new-file--body-${newFileCreateMode}`}
+            className={`modal-new-file-body modal-new-file-body-${newFileCreateMode}`}
           >
             {newFileCreateMode === 'doc' && (
               <FileTreeCreateNameProvider initialName="name.tex">

+ 17 - 12
services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-footer.jsx

@@ -1,9 +1,10 @@
 import { useTranslation } from 'react-i18next'
-import { Alert, Button } from 'react-bootstrap'
 import { useFileTreeCreateForm } from '../../contexts/file-tree-create-form'
 import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
 import { useFileTreeData } from '../../../../shared/context/file-tree-data-context'
 import PropTypes from 'prop-types'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLNotification from '@/features/ui/components/ol/ol-notification'
 
 export default function FileTreeModalCreateFileFooter() {
   const { valid } = useFileTreeCreateForm()
@@ -40,31 +41,35 @@ export function FileTreeModalCreateFileFooterContent({
       )}
 
       {fileCount.status === 'error' && (
-        <Alert bsStyle="warning" className="at-file-limit">
+        <OLNotification
+          type="error"
+          className="at-file-limit"
+          content={t('project_has_too_many_files')}
+        >
           {/* TODO: add parameter for fileCount.limit */}
-          {t('project_has_too_many_files')}
-        </Alert>
+        </OLNotification>
       )}
 
-      <Button
-        bsStyle={null}
-        className="btn-secondary"
+      <OLButton
+        variant="secondary"
         type="button"
         disabled={inFlight}
         onClick={cancel}
       >
         {t('cancel')}
-      </Button>
+      </OLButton>
 
       {newFileCreateMode !== 'upload' && (
-        <Button
-          bsStyle="primary"
+        <OLButton
+          variant="primary"
           type="submit"
           form="create-file"
           disabled={inFlight || !valid}
+          isLoading={inFlight}
+          bs3Props={{ loading: inFlight ? `${t('creating')}…` : t('create') }}
         >
-          <span>{inFlight ? `${t('creating')}…` : t('create')}</span>
-        </Button>
+          {t('create')}
+        </OLButton>
       )}
     </>
   )

+ 10 - 6
services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-mode.jsx

@@ -1,9 +1,11 @@
 import classnames from 'classnames'
-import { Button } from 'react-bootstrap'
 import PropTypes from 'prop-types'
 import Icon from '../../../../shared/components/icon'
 import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
 import * as eventTracking from '../../../../infrastructure/event-tracking'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
 
 export default function FileTreeModalCreateFileMode({ mode, icon, label }) {
   const { newFileCreateMode, startCreatingFile } = useFileTreeActionable()
@@ -15,16 +17,18 @@ export default function FileTreeModalCreateFileMode({ mode, icon, label }) {
 
   return (
     <li className={classnames({ active: newFileCreateMode === mode })}>
-      <Button
-        bsStyle="link"
-        block
+      <OLButton
+        variant="link"
         onClick={handleClick}
         className="modal-new-file-mode"
       >
-        <Icon type={icon} fw />
+        <BootstrapVersionSwitcher
+          bs3={<Icon type={icon} fw />}
+          bs5={<MaterialIcon type={icon} />}
+        />
         &nbsp;
         {label}
-      </Button>
+      </OLButton>
     </li>
   )
 }

+ 11 - 11
services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-upload-conflicts.tsx

@@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next'
 import { useProjectContext } from '@/shared/context/project-context'
 import { useCallback } from 'react'
 import { syncDelete } from '@/features/file-tree/util/sync-mutation'
-import { Button } from 'react-bootstrap'
 import { TFunction } from 'i18next'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 export type Conflict = {
   entity: FileTreeEntity
@@ -48,7 +48,7 @@ export function FileUploadConflicts({
   )
 
   return (
-    <div className="small modal-new-file--body-conflict">
+    <div className="small modal-new-file-body-conflict">
       {conflicts.length > 0 && (
         <>
           <p className="text-center mb-0">{getConflictText(conflicts, t)}</p>
@@ -70,14 +70,14 @@ export function FileUploadConflicts({
       )}
 
       <p className="text-center">
-        <Button bsStyle={null} className="btn-secondary" onClick={cancel}>
+        <OLButton variant="secondary" onClick={cancel}>
           {t('cancel')}
-        </Button>
+        </OLButton>
         &nbsp;
         {!hasFolderConflict && (
-          <Button bsStyle="danger" onClick={handleOverwrite}>
+          <OLButton variant="danger" onClick={handleOverwrite}>
             {t('overwrite')}
-          </Button>
+          </OLButton>
         )}
       </p>
     </div>
@@ -118,7 +118,7 @@ export function FolderUploadConflicts({
   }, [setError, conflicts, handleOverwrite, projectId])
 
   return (
-    <div className="small modal-new-file--body-conflict">
+    <div className="small modal-new-file-body-conflict">
       <p className="text-center mb-0">{getConflictText(conflicts, t)}</p>
 
       <ul className="text-center list-unstyled row-spaced-small mt-1">
@@ -140,14 +140,14 @@ export function FolderUploadConflicts({
       )}
 
       <p className="text-center">
-        <Button bsStyle={null} className="btn-secondary" onClick={cancel}>
+        <OLButton variant="secondary" onClick={cancel}>
           {t('cancel')}
-        </Button>
+        </OLButton>
         &nbsp;
         {!hasFileConflict && (
-          <Button bsStyle="danger" onClick={deleteAndRetry}>
+          <OLButton variant="danger" onClick={deleteAndRetry}>
             {t('overwrite')}
-          </Button>
+          </OLButton>
         )}
       </p>
     </div>

+ 62 - 32
services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx

@@ -5,7 +5,6 @@ import {
   useMemo,
   FormEventHandler,
 } from 'react'
-import { Button, ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
 import Icon from '../../../../../shared/components/icon'
 import FileTreeCreateNameInput from '../file-tree-create-name-input'
 import { useTranslation } from 'react-i18next'
@@ -21,6 +20,13 @@ import * as eventTracking from '../../../../../infrastructure/event-tracking'
 import { File } from '@/features/source-editor/utils/file'
 import { Project } from '../../../../../../../types/project'
 import getMeta from '@/utils/meta'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
+import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
+import OLForm from '@/features/ui/components/ol/ol-form'
+import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { Spinner } from 'react-bootstrap-5'
 
 export default function FileTreeImportFromProject() {
   const { t } = useTranslation()
@@ -125,7 +131,7 @@ export default function FileTreeImportFromProject() {
   }
 
   return (
-    <form className="form-controls" id="create-file" onSubmit={handleSubmit}>
+    <OLForm id="create-file" onSubmit={handleSubmit}>
       <SelectProject
         selectedProject={selectedProject}
         setSelectedProject={setSelectedProject}
@@ -148,8 +154,8 @@ export default function FileTreeImportFromProject() {
       {canSwitchOutputFilesMode && (
         <div className="toggle-file-type-button">
           or&nbsp;
-          <Button
-            bsStyle="link"
+          <OLButton
+            variant="link"
             type="button"
             onClick={() => setOutputFilesMode(value => !value)}
           >
@@ -158,7 +164,7 @@ export default function FileTreeImportFromProject() {
                 ? t('select_from_source_files')
                 : t('select_from_output_files')}
             </span>
-          </Button>
+          </OLButton>
         </div>
       )}
 
@@ -173,7 +179,7 @@ export default function FileTreeImportFromProject() {
       />
 
       {error && <ErrorMessage error={error} />}
-    </form>
+    </OLForm>
   )
 }
 
@@ -204,18 +210,27 @@ function SelectProject({
   }
 
   return (
-    <FormGroup className="form-controls" controlId="project-select">
-      <ControlLabel>{t('select_a_project')}</ControlLabel>
+    <OLFormGroup controlId="project-select">
+      <OLFormLabel>{t('select_a_project')}</OLFormLabel>
 
       {loading && (
         <span>
           &nbsp;
-          <Icon type="spinner" spin />
+          <BootstrapVersionSwitcher
+            bs3={<Icon type="spinner" spin />}
+            bs5={
+              <Spinner
+                animation="border"
+                aria-hidden="true"
+                size="sm"
+                role="status"
+              />
+            }
+          />
         </span>
       )}
 
-      <FormControl
-        componentClass="select"
+      <OLFormSelect
         disabled={!data}
         value={selectedProject ? selectedProject._id : ''}
         onChange={event => {
@@ -234,12 +249,12 @@ function SelectProject({
               {project.name}
             </option>
           ))}
-      </FormControl>
+      </OLFormSelect>
 
       {filteredData && !filteredData.length && (
         <small>{t('no_other_projects_found')}</small>
       )}
-    </FormGroup>
+    </OLFormGroup>
   )
 }
 
@@ -263,25 +278,34 @@ function SelectProjectOutputFile({
   }
 
   return (
-    <FormGroup
-      className="form-controls row-spaced-small"
+    <OLFormGroup
+      className="row-spaced-small"
       controlId="project-output-file-select"
     >
-      <ControlLabel>{t('select_an_output_file')}</ControlLabel>
+      <OLFormLabel>{t('select_an_output_file')}</OLFormLabel>
 
       {loading && (
         <span>
           &nbsp;
-          <Icon type="spinner" spin />
+          <BootstrapVersionSwitcher
+            bs3={<Icon type="spinner" spin />}
+            bs5={
+              <Spinner
+                animation="border"
+                aria-hidden="true"
+                size="sm"
+                role="status"
+              />
+            }
+          />
         </span>
       )}
 
-      <FormControl
-        componentClass="select"
+      <OLFormSelect
         disabled={!data}
         value={selectedProjectOutputFile?.path || ''}
         onChange={event => {
-          const path = (event.target as HTMLSelectElement).value
+          const path = (event.target as unknown as HTMLSelectElement).value
           const file = data?.find(item => item.path === path)
           setSelectedProjectOutputFile(file)
         }}
@@ -296,8 +320,8 @@ function SelectProjectOutputFile({
               {file.path}
             </option>
           ))}
-      </FormControl>
-    </FormGroup>
+      </OLFormSelect>
+    </OLFormGroup>
   )
 }
 
@@ -321,21 +345,27 @@ function SelectProjectEntity({
   }
 
   return (
-    <FormGroup
-      className="form-controls row-spaced-small"
-      controlId="project-entity-select"
-    >
-      <ControlLabel>{t('select_a_file')}</ControlLabel>
+    <OLFormGroup className="row-spaced-small" controlId="project-entity-select">
+      <OLFormLabel>{t('select_a_file')}</OLFormLabel>
 
       {loading && (
         <span>
           &nbsp;
-          <Icon type="spinner" spin />
+          <BootstrapVersionSwitcher
+            bs3={<Icon type="spinner" spin />}
+            bs5={
+              <Spinner
+                animation="border"
+                aria-hidden="true"
+                size="sm"
+                role="status"
+              />
+            }
+          />
         </span>
       )}
 
-      <FormControl
-        componentClass="select"
+      <OLFormSelect
         disabled={!data}
         value={selectedProjectEntity?.path || ''}
         onChange={event => {
@@ -354,7 +384,7 @@ function SelectProjectEntity({
               {entity.path.slice(1)}
             </option>
           ))}
-      </FormControl>
-    </FormGroup>
+      </OLFormSelect>
+    </OLFormGroup>
   )
 }

+ 7 - 6
services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-url.jsx

@@ -1,4 +1,3 @@
-import { ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
 import { useCallback, useEffect, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import FileTreeCreateNameInput from '../file-tree-create-name-input'
@@ -7,6 +6,9 @@ import { useFileTreeCreateName } from '../../../contexts/file-tree-create-name'
 import { useFileTreeCreateForm } from '../../../contexts/file-tree-create-form'
 import ErrorMessage from '../error-message'
 import * as eventTracking from '../../../../../infrastructure/event-tracking'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
+import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
+import OLFormControl from '@/features/ui/components/ol/ol-form-control'
 
 export default function FileTreeImportFromUrl() {
   const { t } = useTranslation()
@@ -54,17 +56,16 @@ export default function FileTreeImportFromUrl() {
       noValidate
       onSubmit={handleSubmit}
     >
-      <FormGroup controlId="import-from-url">
-        <ControlLabel>{t('url_to_fetch_the_file_from')}</ControlLabel>
-
-        <FormControl
+      <OLFormGroup controlId="import-from-url">
+        <OLFormLabel>{t('url_to_fetch_the_file_from')}</OLFormLabel>
+        <OLFormControl
           type="url"
           placeholder="https://example.com/my-file.png"
           required
           value={url}
           onChange={handleChange}
         />
-      </FormGroup>
+      </OLFormGroup>
 
       <FileTreeCreateNameInput
         label={t('file_name_in_this_project')}

+ 32 - 10
services/web/frontend/js/features/file-tree/components/file-tree-doc.jsx

@@ -7,6 +7,8 @@ import { useTranslation } from 'react-i18next'
 import Icon from '../../../shared/components/icon'
 import iconTypeFromName from '../util/icon-type-from-name'
 import classnames from 'classnames'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
 
 function FileTreeDoc({ name, id, isFile, isLinkedFile }) {
   const type = isFile ? 'file' : 'doc'
@@ -46,25 +48,45 @@ FileTreeDoc.propTypes = {
 export const FileTreeIcon = ({ isLinkedFile, name }) => {
   const { t } = useTranslation()
 
-  const className = classnames('spaced', 'file-tree-icon', {
+  const className = classnames('file-tree-icon', {
     'linked-file-icon': isLinkedFile,
   })
 
   return (
     <>
       &nbsp;
-      <Icon type={iconTypeFromName(name)} fw className={className} />
-      {isLinkedFile && (
-        <Icon
-          type="external-link-square"
-          modifier="rotate-180"
-          className="linked-file-highlight"
-          accessibilityLabel={t('linked_file')}
-        />
-      )}
+      <BootstrapVersionSwitcher
+        bs3={
+          <>
+            <Icon type={iconTypeFromName(name)} fw className={className} />
+            {isLinkedFile && (
+              <Icon
+                type="external-link-square"
+                modifier="rotate-180"
+                className="linked-file-highlight"
+                accessibilityLabel={t('linked_file')}
+              />
+            )}
+          </>
+        }
+        bs5={
+          <>
+            <MaterialIcon type={iconTypeFromName(name)} className={className} />
+            {isLinkedFile && (
+              <MaterialIcon
+                type="open_in_new"
+                modifier="rotate-180"
+                className="linked-file-highlight"
+                accessibilityLabel={t('linked_file')}
+              />
+            )}
+          </>
+        }
+      />
     </>
   )
 }
+
 FileTreeIcon.propTypes = {
   name: PropTypes.string.isRequired,
   isLinkedFile: PropTypes.bool,

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

@@ -1,6 +1,6 @@
-import { Button } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { useLocation } from '../../../shared/hooks/use-location'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 function FileTreeError() {
   const { t } = useTranslation()
@@ -10,9 +10,9 @@ function FileTreeError() {
     <div className="file-tree-error">
       <p>{t('generic_something_went_wrong')}</p>
       <p>{t('please_refresh')}</p>
-      <Button bsStyle="primary" onClick={handleClick}>
+      <OLButton variant="primary" onClick={handleClick}>
         {t('refresh')}
-      </Button>
+      </OLButton>
     </div>
   )
 }

+ 40 - 17
services/web/frontend/js/features/file-tree/components/file-tree-folder.jsx

@@ -13,6 +13,8 @@ import { useDroppable } from '../contexts/file-tree-draggable'
 import FileTreeItemInner from './file-tree-item/file-tree-item-inner'
 import FileTreeFolderList from './file-tree-folder-list'
 import usePersistedState from '../../../shared/hooks/use-persisted-state'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
 
 function FileTreeFolder({ name, id, folders, docs, files }) {
   const { t } = useTranslation()
@@ -43,23 +45,44 @@ function FileTreeFolder({ name, id, folders, docs, files }) {
   const { isOver: isOverList, dropRef: dropRefList } = useDroppable(id)
 
   const icons = (
-    <>
-      <button
-        onClick={handleExpandCollapseClick}
-        aria-label={expanded ? t('collapse') : t('expand')}
-      >
-        <Icon
-          type={expanded ? 'angle-down' : 'angle-right'}
-          fw
-          className="file-tree-expand-icon"
-        />
-      </button>
-      <Icon
-        type={expanded ? 'folder-open' : 'folder'}
-        fw
-        className="file-tree-folder-icon"
-      />
-    </>
+    <BootstrapVersionSwitcher
+      bs3={
+        <>
+          <button
+            onClick={handleExpandCollapseClick}
+            aria-label={expanded ? t('collapse') : t('expand')}
+          >
+            <Icon
+              type={expanded ? 'angle-down' : 'angle-right'}
+              fw
+              className="file-tree-expand-icon"
+            />
+          </button>
+          <Icon
+            type={expanded ? 'folder-open' : 'folder'}
+            fw
+            className="file-tree-folder-icon"
+          />
+        </>
+      }
+      bs5={
+        <>
+          <button
+            onClick={handleExpandCollapseClick}
+            aria-label={expanded ? t('collapse') : t('expand')}
+          >
+            <MaterialIcon
+              type={expanded ? 'expand_more' : 'chevron_right'}
+              className="file-tree-expand-icon"
+            />
+          </button>
+          <MaterialIcon
+            type={expanded ? 'folder_open' : 'folder'}
+            className="file-tree-folder-icon"
+          />
+        </>
+      }
+    />
   )
 
   return (

+ 1 - 1
services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.tsx

@@ -90,7 +90,7 @@ function FileTreeItemInner({
           isSelected={isSelected}
           setIsDraggable={setIsDraggable}
         />
-        {hasMenu ? <FileTreeItemMenu id={id} /> : null}
+        {hasMenu ? <FileTreeItemMenu id={id} name={name} /> : null}
       </div>
     </div>
   )

+ 79 - 23
services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.jsx

@@ -4,7 +4,12 @@ import * as eventTracking from '../../../../infrastructure/event-tracking'
 import { useProjectContext } from '@/shared/context/project-context'
 
 import { MenuItem } from 'react-bootstrap'
+import {
+  DropdownDivider,
+  DropdownItem,
+} from '@/features/ui/components/bootstrap-5/dropdown-menu'
 import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 function FileTreeItemMenuItems() {
   const { t } = useTranslation()
@@ -42,31 +47,82 @@ function FileTreeItemMenuItems() {
   }, [startUploadingDocOrFile])
 
   return (
-    <>
-      {canRename ? (
-        <MenuItem onClick={startRenaming}>{t('rename')}</MenuItem>
-      ) : null}
-      {downloadPath ? (
-        <MenuItem
-          href={downloadPath}
-          onClick={downloadWithAnalytics}
-          download={selectedFileName}
-        >
-          {t('download')}
-        </MenuItem>
-      ) : null}
-      {canDelete ? (
-        <MenuItem onClick={startDeleting}>{t('delete')}</MenuItem>
-      ) : null}
-      {canCreate ? (
+    <BootstrapVersionSwitcher
+      bs3={
         <>
-          <li role="none" className="divider" />
-          <MenuItem onClick={createWithAnalytics}>{t('new_file')}</MenuItem>
-          <MenuItem onClick={startCreatingFolder}>{t('new_folder')}</MenuItem>
-          <MenuItem onClick={uploadWithAnalytics}>{t('upload')}</MenuItem>
+          {canRename ? (
+            <MenuItem onClick={startRenaming}>{t('rename')}</MenuItem>
+          ) : null}
+          {downloadPath ? (
+            <MenuItem
+              href={downloadPath}
+              onClick={downloadWithAnalytics}
+              download={selectedFileName}
+            >
+              {t('download')}
+            </MenuItem>
+          ) : null}
+          {canDelete ? (
+            <MenuItem onClick={startDeleting}>{t('delete')}</MenuItem>
+          ) : null}
+          {canCreate ? (
+            <>
+              <li role="none" className="divider" />
+              <MenuItem onClick={createWithAnalytics}>{t('new_file')}</MenuItem>
+              <MenuItem onClick={startCreatingFolder}>
+                {t('new_folder')}
+              </MenuItem>
+              <MenuItem onClick={uploadWithAnalytics}>{t('upload')}</MenuItem>
+            </>
+          ) : null}
         </>
-      ) : null}
-    </>
+      }
+      bs5={
+        <>
+          {canRename ? (
+            <li role="none">
+              <DropdownItem onClick={startRenaming}>{t('rename')}</DropdownItem>
+            </li>
+          ) : null}
+          {downloadPath ? (
+            <li role="none">
+              <DropdownItem
+                href={downloadPath}
+                onClick={downloadWithAnalytics}
+                download={selectedFileName}
+              >
+                {t('download')}
+              </DropdownItem>
+            </li>
+          ) : null}
+          {canDelete ? (
+            <li role="none">
+              <DropdownItem onClick={startDeleting}>{t('delete')}</DropdownItem>
+            </li>
+          ) : null}
+          {canCreate ? (
+            <>
+              <DropdownDivider role="none" />
+              <li role="none">
+                <DropdownItem onClick={createWithAnalytics}>
+                  {t('new_file')}
+                </DropdownItem>
+              </li>
+              <li role="none">
+                <DropdownItem onClick={startCreatingFolder}>
+                  {t('new_folder')}
+                </DropdownItem>
+              </li>
+              <li role="none">
+                <DropdownItem onClick={uploadWithAnalytics}>
+                  {t('upload')}
+                </DropdownItem>
+              </li>
+            </>
+          ) : null}
+        </>
+      }
+    />
   )
 }
 

+ 13 - 2
services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu.jsx

@@ -5,12 +5,16 @@ import { useTranslation } from 'react-i18next'
 import Icon from '../../../../shared/components/icon'
 
 import { useFileTreeMainContext } from '../../contexts/file-tree-main'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
 
-function FileTreeItemMenu({ id }) {
+function FileTreeItemMenu({ id, name }) {
   const { t } = useTranslation()
   const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
   const menuButtonRef = useRef()
 
+  const isMenuOpen = Boolean(contextMenuCoords)
+
   function handleClick(event) {
     event.stopPropagation()
     if (!contextMenuCoords) {
@@ -31,8 +35,14 @@ function FileTreeItemMenu({ id }) {
         id={`menu-button-${id}`}
         onClick={handleClick}
         ref={menuButtonRef}
+        aria-haspopup="true"
+        aria-expanded={isMenuOpen}
+        aria-label={t('open_action_menu', { name })}
       >
-        <Icon type="ellipsis-v" accessibilityLabel={t('menu')} />
+        <BootstrapVersionSwitcher
+          bs3={<Icon type="ellipsis-v" accessibilityLabel={t('menu')} />}
+          bs5={<MaterialIcon type="more_vert" accessibilityLabel={t('menu')} />}
+        />
       </button>
     </div>
   )
@@ -40,6 +50,7 @@ function FileTreeItemMenu({ id }) {
 
 FileTreeItemMenu.propTypes = {
   id: PropTypes.string.isRequired,
+  name: PropTypes.string.isRequired,
 }
 
 export default FileTreeItemMenu

+ 15 - 11
services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-file.jsx

@@ -1,10 +1,14 @@
-import { Modal } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
 import FileTreeCreateFormProvider from '../../contexts/file-tree-create-form'
 import FileTreeModalCreateFileBody from '../file-tree-create/file-tree-modal-create-file-body'
 import FileTreeModalCreateFileFooter from '../file-tree-create/file-tree-modal-create-file-footer'
-import AccessibleModal from '../../../../shared/components/accessible-modal'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
 
 export default function FileTreeModalCreateFile() {
   const { t } = useTranslation()
@@ -17,19 +21,19 @@ export default function FileTreeModalCreateFile() {
 
   return (
     <FileTreeCreateFormProvider>
-      <AccessibleModal bsSize="large" onHide={cancel} show>
-        <Modal.Header closeButton>
-          <Modal.Title>{t('add_files')}</Modal.Title>
-        </Modal.Header>
+      <OLModal size="lg" onHide={cancel} show>
+        <OLModalHeader closeButton>
+          <OLModalTitle>{t('add_files')}</OLModalTitle>
+        </OLModalHeader>
 
-        <Modal.Body className="modal-new-file">
+        <OLModalBody className="modal-new-file">
           <FileTreeModalCreateFileBody />
-        </Modal.Body>
+        </OLModalBody>
 
-        <Modal.Footer>
+        <OLModalFooter>
           <FileTreeModalCreateFileFooter />
-        </Modal.Footer>
-      </AccessibleModal>
+        </OLModalFooter>
+      </OLModal>
     </FileTreeCreateFormProvider>
   )
 }

+ 27 - 28
services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-folder.jsx

@@ -1,17 +1,17 @@
 import { useEffect, useState } from 'react'
 import PropTypes from 'prop-types'
-
-import { Button, Modal } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
-
-import AccessibleModal from '../../../../shared/components/accessible-modal'
-
 import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
-
 import { DuplicateFilenameError } from '../../errors'
-
 import { isCleanFilename } from '../../util/safe-path'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 function FileTreeModalCreateFolder() {
   const { t } = useTranslation()
@@ -48,12 +48,12 @@ function FileTreeModalCreateFolder() {
   }
 
   return (
-    <AccessibleModal show onHide={handleHide}>
-      <Modal.Header>
-        <Modal.Title>{t('new_folder')}</Modal.Title>
-      </Modal.Header>
+    <OLModal show onHide={handleHide}>
+      <OLModalHeader>
+        <OLModalTitle>{t('new_folder')}</OLModalTitle>
+      </OLModalHeader>
 
-      <Modal.Body>
+      <OLModalBody>
         <InputName
           name={name}
           setName={setName}
@@ -79,33 +79,32 @@ function FileTreeModalCreateFolder() {
             {errorMessage()}
           </div>
         ) : null}
-      </Modal.Body>
+      </OLModalBody>
 
-      <Modal.Footer>
+      <OLModalFooter>
         {inFlight ? (
-          <Button bsStyle="primary" disabled>
-            {t('creating')}…
-          </Button>
+          <OLButton
+            variant="primary"
+            disabled
+            isLoading={inFlight}
+            bs3Props={{ loading: `${t('creating')}…` }}
+          />
         ) : (
           <>
-            <Button
-              bsStyle={null}
-              className="btn-secondary"
-              onClick={handleHide}
-            >
+            <OLButton variant="secondary" onClick={handleHide}>
               {t('cancel')}
-            </Button>
-            <Button
-              bsStyle="primary"
+            </OLButton>
+            <OLButton
+              variant="primary"
               onClick={handleCreateFolder}
               disabled={!validName}
             >
               {t('create')}
-            </Button>
+            </OLButton>
           </>
         )}
-      </Modal.Footer>
-    </AccessibleModal>
+      </OLModalFooter>
+    </OLModal>
   )
 }
 

+ 31 - 26
services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-delete.jsx

@@ -1,9 +1,14 @@
-import { Button, Modal } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 
-import AccessibleModal from '../../../../shared/components/accessible-modal'
-
 import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLNotification from '@/features/ui/components/ol/ol-notification'
 
 function FileTreeModalDelete() {
   const { t } = useTranslation()
@@ -28,12 +33,12 @@ function FileTreeModalDelete() {
   }
 
   return (
-    <AccessibleModal show onHide={handleHide}>
-      <Modal.Header>
-        <Modal.Title>{t('delete')}</Modal.Title>
-      </Modal.Header>
+    <OLModal show onHide={handleHide}>
+      <OLModalHeader>
+        <OLModalTitle>{t('delete')}</OLModalTitle>
+      </OLModalHeader>
 
-      <Modal.Body>
+      <OLModalBody>
         <p>{t('sure_you_want_to_delete')}</p>
         <ul>
           {actionedEntities.map(entity => (
@@ -41,33 +46,33 @@ function FileTreeModalDelete() {
           ))}
         </ul>
         {error && (
-          <div className="alert alert-danger file-tree-modal-alert">
-            {t('generic_something_went_wrong')}
-          </div>
+          <OLNotification
+            type="error"
+            content={t('generic_something_went_wrong')}
+          />
         )}
-      </Modal.Body>
+      </OLModalBody>
 
-      <Modal.Footer>
+      <OLModalFooter>
         {inFlight ? (
-          <Button bsStyle="danger" disabled>
-            {t('deleting')}…
-          </Button>
+          <OLButton
+            variant="danger"
+            disabled
+            isLoading
+            bs3Props={{ loading: `${t('deleting')}…` }}
+          />
         ) : (
           <>
-            <Button
-              bsStyle={null}
-              className="btn-secondary"
-              onClick={handleHide}
-            >
+            <OLButton className="secondary" onClick={handleHide}>
               {t('cancel')}
-            </Button>
-            <Button bsStyle="danger" onClick={handleDelete}>
+            </OLButton>
+            <OLButton variant="danger" onClick={handleDelete}>
               {t('delete')}
-            </Button>
+            </OLButton>
           </>
         )}
-      </Modal.Footer>
-    </AccessibleModal>
+      </OLModalFooter>
+    </OLModal>
   )
 }
 

+ 18 - 12
services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-error.jsx

@@ -1,4 +1,3 @@
-import { Button, Modal } from 'react-bootstrap'
 import { Trans, useTranslation } from 'react-i18next'
 
 import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
@@ -9,6 +8,13 @@ import {
   DuplicateFilenameError,
   DuplicateFilenameMoveError,
 } from '../../errors'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 function FileTreeModalError() {
   const { t } = useTranslation()
@@ -60,23 +66,23 @@ function FileTreeModalError() {
   }
 
   return (
-    <Modal show onHide={handleHide}>
-      <Modal.Header>
-        <Modal.Title>{errorTitle()}</Modal.Title>
-      </Modal.Header>
+    <OLModal show onHide={handleHide}>
+      <OLModalHeader>
+        <OLModalTitle>{errorTitle()}</OLModalTitle>
+      </OLModalHeader>
 
-      <Modal.Body>
+      <OLModalBody>
         <div role="alert" aria-label={errorMessage()}>
           {errorMessage()}
         </div>
-      </Modal.Body>
+      </OLModalBody>
 
-      <Modal.Footer>
-        <Button onClick={handleHide} bsStyle="primary">
+      <OLModalFooter>
+        <OLButton onClick={handleHide} variant="primary">
           {t('ok')}
-        </Button>
-      </Modal.Footer>
-    </Modal>
+        </OLButton>
+      </OLModalFooter>
+    </OLModal>
   )
 }
 

+ 6 - 4
services/web/frontend/js/features/file-tree/util/icon-type-from-name.js

@@ -1,3 +1,5 @@
+import { isBootstrap5 } from '@/features/utils/bootstrap-5'
+
 export default function iconTypeFromName(name) {
   let ext = name.split('.').pop()
   ext = ext ? ext.toLowerCase() : ext
@@ -5,12 +7,12 @@ export default function iconTypeFromName(name) {
   if (['png', 'pdf', 'jpg', 'jpeg', 'gif'].includes(ext)) {
     return 'image'
   } else if (['csv', 'xls', 'xlsx'].includes(ext)) {
-    return 'table'
+    return isBootstrap5() ? 'table_chart' : 'table'
   } else if (['py', 'r'].includes(ext)) {
-    return 'file-text'
+    return isBootstrap5() ? 'code' : 'file-text'
   } else if (['bib'].includes(ext)) {
-    return 'book'
+    return isBootstrap5() ? 'menu_book' : 'book'
   } else {
-    return 'file'
+    return isBootstrap5() ? 'description' : 'file'
   }
 }

+ 1 - 1
services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx

@@ -82,7 +82,7 @@ export default function TagsList() {
                   <DropdownToggle id={`${tag._id}-dropdown-toggle`}>
                     <span className="caret" />
                   </DropdownToggle>
-                  <DropdownMenu className="sm">
+                  <DropdownMenu className="dropdown-menu-sm-width">
                     <DropdownItem
                       as="li"
                       className="tag-action"

+ 4 - 1
services/web/frontend/js/features/ui/components/bootstrap-5/language-picker.tsx

@@ -34,7 +34,10 @@ function LanguagePicker() {
         {translatedLanguages?.[currentLangCode]}
       </DropdownToggle>
 
-      <DropdownMenu className="sm" aria-labelledby="language-picker-toggle">
+      <DropdownMenu
+        className="dropdown-menu-sm-width"
+        aria-labelledby="language-picker-toggle"
+      >
         {subdomainLang &&
           Object.entries(subdomainLang).map(([subdomain, subdomainDetails]) => {
             if (

+ 3 - 0
services/web/frontend/js/features/ui/components/types/dropdown-menu-props.ts

@@ -18,6 +18,8 @@ export type DropdownProps = {
   show?: boolean
   autoClose?: boolean | 'inside' | 'outside'
   drop?: 'up' | 'up-centered' | 'start' | 'end' | 'down' | 'down-centered'
+  focusFirstItemOnShow?: false | true | 'keyboard'
+  onKeyDown?: (event: React.KeyboardEvent) => void
 }
 
 export type DropdownItemProps = PropsWithChildren<{
@@ -54,6 +56,7 @@ export type DropdownMenuProps = PropsWithChildren<{
   show?: boolean
   className?: string
   flip?: boolean
+  id?: string
 }>
 
 export type DropdownDividerProps = PropsWithChildren<{

+ 23 - 5
services/web/frontend/js/shared/components/loading-spinner.tsx

@@ -1,6 +1,8 @@
 import { useTranslation } from 'react-i18next'
 import Icon from './icon'
 import { useEffect, useState } from 'react'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { Spinner } from 'react-bootstrap-5'
 
 function LoadingSpinner({
   delay = 0,
@@ -28,11 +30,27 @@ function LoadingSpinner({
   }
 
   return (
-    <div className="loading">
-      <Icon type="refresh" fw spin />
-      &nbsp;
-      {loadingText || t('loading')}…
-    </div>
+    <BootstrapVersionSwitcher
+      bs3={
+        <div className="loading">
+          <Icon type="refresh" fw spin />
+          &nbsp;
+          {loadingText || t('loading')}…
+        </div>
+      }
+      bs5={
+        <div className="text-center mt-4">
+          <Spinner
+            animation="border"
+            aria-hidden="true"
+            role="status"
+            className="align-bottom"
+          />
+          &nbsp;
+          {loadingText || t('loading')}…
+        </div>
+      }
+    />
   )
 }
 

+ 3 - 1
services/web/frontend/js/shared/components/material-icon.tsx

@@ -5,15 +5,17 @@ import { bsVersion } from '@/features/utils/bootstrap-5'
 type IconProps = React.ComponentProps<'i'> & {
   type: string
   accessibilityLabel?: string
+  modifier?: string
 }
 
 function MaterialIcon({
   type,
   className,
   accessibilityLabel,
+  modifier,
   ...rest
 }: IconProps) {
-  const iconClassName = classNames('material-symbols', className)
+  const iconClassName = classNames('material-symbols', className, modifier)
 
   return (
     <>

+ 8 - 8
services/web/frontend/stylesheets/app/editor/file-tree.less

@@ -422,7 +422,7 @@
     }
   }
 }
-.modal-new-file--list {
+.modal-new-file-list {
   background-color: @modal-footer-background-color;
   width: 220px;
   ul {
@@ -473,24 +473,24 @@
 }
 
 .file-tree-modal-alert {
-  margin-top: 10px;
-  margin-bottom: 0px;
+  margin-top: 12.5px;
 }
 
 .btn.modal-new-file-mode {
   text-align: left;
+  width: 100%;
 }
 
-.modal-new-file--body {
+.modal-new-file-body {
   padding: 20px;
   padding-top: (@line-height-computed / 4);
 }
 
-.modal-new-file--body-upload {
+.modal-new-file-body-upload {
   padding-top: 20px;
 }
 
-.modal-new-file--body-conflict {
+.modal-new-file-body-conflict {
   background-color: @red-10;
   border: 1px dashed @red-50;
   min-height: 400px;
@@ -512,11 +512,11 @@
   }
 }
 
-.modal-new-file--body-upload .uppy-Root {
+.modal-new-file-body-upload .uppy-Root {
   font-family: inherit;
 }
 
-.modal-new-file--body-upload .uppy-Dashboard {
+.modal-new-file-body-upload .uppy-Dashboard {
   .uppy-Dashboard-inner {
     border: none;
   }

+ 1 - 4
services/web/frontend/stylesheets/app/editor/ide-react.less

@@ -27,7 +27,6 @@
 }
 
 .ide-react-main {
-  //migrated to SCSS
   height: 100%;
   display: flex;
   flex-direction: column;
@@ -40,7 +39,6 @@
 }
 
 .ide-react-body {
-  //migrated to SCSS
   flex-grow: 1;
   background-color: @pdf-bg;
   overflow-y: hidden;
@@ -127,7 +125,6 @@
 }
 
 .ide-react-editor-sidebar {
-  //migrated to SCSS
   height: 100%;
   background-color: @file-tree-bg;
   color: var(--neutral-20);
@@ -140,7 +137,6 @@
 }
 
 .ide-react-file-tree-panel {
-  //migrated to SCSS
   display: flex;
   flex-direction: column;
 
@@ -162,6 +158,7 @@
 }
 
 // Styles for placeholder elements that will eventually be replaced
+// Unused, not migrated to SCSS
 .ide-react-placeholder-chat {
   background-color: var(--editor-toolbar-bg);
   color: var(--neutral-20);

+ 0 - 13
services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss

@@ -74,19 +74,6 @@
   background-color: var(--bg-dark-secondary);
 }
 
-// Filetree
-@mixin file-tree-item-color {
-  color: var(--content-primary-dark);
-}
-
-@mixin file-tree-item-hover-bg {
-  background-color: var(--bg-dark-secondary);
-}
-
-@mixin file-tree-bg {
-  background-color: var(--bg-dark-tertiary);
-}
-
 @mixin theme($name) {
   @if index($themes, $name) {
     [data-theme='#{$name}'] {

+ 4 - 0
services/web/frontend/stylesheets/bootstrap-5/base/layout.scss

@@ -54,3 +54,7 @@ hr {
 .row-spaced-extra-large {
   margin-top: calc(var(--line-height-03) * 4);
 }
+
+.rotate-180 {
+  transform: rotate(180deg);
+}

+ 1 - 0
services/web/frontend/stylesheets/bootstrap-5/components/button.scss

@@ -104,6 +104,7 @@
   text-decoration: underline;
   font-size: inherit;
   vertical-align: inherit;
+  border-radius: 0;
 
   &,
   &:active,

+ 1 - 1
services/web/frontend/stylesheets/bootstrap-5/components/dropdown-menu.scss

@@ -14,7 +14,7 @@
 
   min-width: 240px;
 
-  &.sm {
+  &.dropdown-menu-sm-width {
     min-width: 160px;
   }
 

+ 603 - 0
services/web/frontend/stylesheets/bootstrap-5/pages/editor/file-tree.scss

@@ -1,3 +1,19 @@
+:root {
+  --file-tree-item-hover-bg: var(--bg-dark-secondary);
+  --file-tree-item-selected-bg: var(--bg-accent-01);
+  --file-tree-item-color: var(--content-primary-dark);
+  --file-tree-bg: var(--bg-dark-tertiary);
+  --file-tree-item-selected-color: var(--content-primary-dark);
+  --file-tree-line-height: 2.05;
+}
+
+@include theme('light') {
+  --file-tree-item-hover-bg: var(--bg-light-tertiary);
+  --file-tree-item-color: var(--content-secondary);
+  --file-tree-bg: var(--bg-light-primary);
+  --file-tree-item-selected-color: var(--bg-light-primary);
+}
+
 .ide-react-file-tree-panel {
   display: flex;
   flex-direction: column;
@@ -7,3 +23,590 @@
     width: 100%;
   }
 }
+
+.context-menu {
+  position: fixed;
+  z-index: 100;
+}
+
+.editor-sidebar {
+  background-color: var(--file-tree-bg);
+  display: flex;
+  flex-direction: column;
+}
+
+@mixin fake-full-width-bg($bg-color) {
+  &::before {
+    content: '\00a0';
+    position: absolute;
+    width: 9999px;
+    left: -9999px;
+    background-color: $bg-color;
+  }
+}
+
+.file-tree {
+  display: flex !important; // To work around jQuery layout's inline styles
+  flex-direction: column;
+  height: 100%;
+
+  .toolbar.toolbar-filetree {
+    @include toolbar-sm-height;
+    @include toolbar-alt-bg;
+
+    padding: 0 var(--spacing-03);
+    flex-shrink: 0;
+  }
+
+  > file-tree-root,
+  .file-tree-inner {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    flex-grow: 1;
+    overflow-y: auto;
+    width: inherit;
+    height: inherit;
+
+    &.no-toolbar {
+      top: 0;
+    }
+  }
+
+  // TODO; Consolidate with "Project files" in Overleaf
+  h3 {
+    font-size: 1rem;
+    border-bottom: 1px solid var(--border-primary);
+    padding-bottom: var(--spacing-02);
+    margin: var(--spacing-05);
+  }
+
+  &-history {
+    .entity-name {
+      padding-left: var(--spacing-03);
+
+      &.deleted {
+        text-decoration: line-through;
+      }
+    }
+
+    .loading {
+      padding-left: var(--spacing-03);
+      color: var(--content-primary-dark);
+
+      .material-symbols {
+        color: var(--content-primary-dark);
+      }
+    }
+  }
+
+  ul.file-tree-list {
+    margin: 0;
+    overflow: hidden auto;
+    height: 100%;
+    flex-grow: 1;
+    position: relative;
+
+    .entity > ul,
+    ul[role='tree'] {
+      margin-left: var(--spacing-08);
+    }
+
+    &::after {
+      content: '';
+      display: block;
+      min-height: var(--spacing-08);
+    }
+
+    li {
+      line-height: var(--file-tree-line-height);
+      position: relative;
+
+      &:focus {
+        outline: none;
+      }
+
+      .entity {
+        user-select: none;
+
+        &:focus {
+          outline: none;
+        }
+      }
+
+      .entity > .entity-name > button {
+        background-color: transparent;
+        border: 0;
+        padding: 0;
+
+        &:focus {
+          outline: none;
+        }
+
+        &.item-name-button {
+          color: inherit;
+          width: 100%;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          text-align: left;
+          padding-right: var(--spacing-09);
+          white-space: pre;
+        }
+      }
+
+      .entity-name {
+        color: var(--file-tree-item-color);
+        cursor: pointer;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+
+        &.entity-name-react {
+          text-overflow: clip;
+        }
+
+        &:focus {
+          outline: none;
+        }
+
+        background-color: transparent;
+
+        @include fake-full-width-bg(transparent);
+
+        &:hover {
+          background-color: var(--file-tree-item-hover-bg);
+
+          // When the entity is a subfolder, the DOM element is "indented" via margin-left. This makes the
+          // element not fill the entire file-tree width (as it's spaced from the left-hand side via margin)
+          // and, in consequence, the background gets clipped. The ::before pseudo-selector is used to fill
+          // the empty space.
+          @include fake-full-width-bg(var(--file-tree-item-hover-bg));
+        }
+
+        input {
+          line-height: 1.6;
+        }
+
+        .entity-menu-toggle > .material-symbols {
+          color: var(--content-primary-dark);
+          vertical-align: middle;
+        }
+      }
+
+      .material-symbols {
+        color: var(--content-disabled);
+
+        &.file-tree-icon {
+          margin-right: var(--spacing-02);
+          margin-left: var(--spacing-04);
+          vertical-align: sub;
+
+          &.linked-file-icon {
+            position: relative;
+            left: -2px;
+
+            + .linked-file-highlight {
+              color: inherit;
+              position: relative;
+              top: 4px;
+              width: 0;
+              left: -5px;
+              font-size: 12px;
+            }
+          }
+        }
+
+        &.file-tree-folder-icon {
+          margin-right: var(--spacing-02);
+          vertical-align: sub;
+        }
+
+        &.file-tree-expand-icon {
+          margin-left: var(--spacing-04);
+          vertical-align: sub;
+        }
+      }
+
+      .material-symbols.folder-open,
+      .material-symbols.fa-folder {
+        color: var(--content-disabled);
+      }
+
+      .material-symbols.toggle {
+        width: 24px;
+        padding: var(--spacing-03);
+        font-size: var(--font-size-03);
+        color: var(--content-disabled);
+      }
+
+      .file-tree-dropdown-toggle {
+        display: flex;
+        align-items: center;
+        color: var(--content-primary-dark);
+        line-height: 1.6;
+        font-size: var(--font-size-05);
+        padding: 0 var(--font-size-02) 0 var(--font-size-04);
+
+        &:hover,
+        &:focus {
+          text-decoration: none;
+        }
+
+        &::before {
+          content: '\00B7\00B7\00B7';
+          transform: rotate(90deg);
+          letter-spacing: 0.5px;
+        }
+      }
+
+      &.multi-selected {
+        > .entity {
+          > .entity-name {
+            > div > .material-symbols,
+            > button > .material-symbols,
+            > .material-symbols,
+            .entity-menu-toggle .material-symbols {
+              color: var(--content-primary-dark);
+            }
+
+            > .material-symbols.linked-file-highlight {
+              color: var(--bg-info-01);
+            }
+
+            @include fake-full-width-bg(var(--bg-info-01));
+
+            color: var(--content-primary-dark);
+            font-weight: bold;
+            background-color: var(--bg-info-01);
+
+            &:hover {
+              background-color: var(--bg-info-02);
+
+              @include fake-full-width-bg(var(--bg-info-02));
+            }
+          }
+        }
+      }
+
+      .menu-button {
+        position: absolute;
+        right: 0;
+        top: 3px;
+      }
+
+      .rename-input {
+        display: block;
+        position: absolute;
+        top: 1px;
+        left: 32px;
+        right: 32px;
+        color: var(--content-primary);
+
+        input {
+          width: 100%;
+        }
+      }
+
+      > .entity > .entity-name {
+        .entity-menu-toggle {
+          display: none;
+        }
+      }
+
+      .entity-limit-hit {
+        line-height: var(--file-tree-line-height);
+        color: var(--file-tree-item-color);
+        margin-left: var(--spacing-05);
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+
+      .entity-limit-hit-message {
+        font-style: italic;
+      }
+
+      .material-symbols .entity-limit-hit-tooltip-trigger {
+        margin-left: var(spacing-03);
+        cursor: pointer;
+      }
+    }
+  }
+
+  &:not(.multi-selected) {
+    ul.file-tree-list li.selected {
+      > .entity {
+        > .entity-name {
+          background-color: var(--file-tree-item-selected-bg);
+          color: var(--file-tree-item-selected-color);
+
+          > div > .material-symbols,
+          > button > .material-symbols,
+          > .material-symbols,
+          .entity-menu-toggle .material-symbols {
+            color: var(--file-tree-item-selected-color);
+          }
+
+          > .material-symbols.linked-file-highlight {
+            color: var(--bg-info-01);
+          }
+
+          @include fake-full-width-bg(var(--file-tree-item-selected-bg));
+
+          font-weight: bold;
+          padding-right: var(--spacing-09);
+
+          .entity-menu-toggle {
+            display: inline-block;
+            background-color: transparent;
+            box-shadow: none;
+            border: 0;
+            padding-right: var(--spacing-02);
+            padding-left: var(--spacing-02);
+          }
+        }
+      }
+    }
+  }
+
+  // while dragging, the previously selected item gets no highlight
+  ul.file-tree-list.file-tree-dragging li.selected .entity .entity-name {
+    @include fake-full-width-bg(transparent);
+
+    font-weight: normal;
+    background-color: transparent;
+    color: var(--file-tree-item-color);
+
+    .material-symbols {
+      color: var(--content-disabled) !important;
+    }
+  }
+
+  // the items being dragged get the full "hover" colour
+  ul.file-tree-list.file-tree-dragging
+    li
+    .entity.file-tree-entity-dragging
+    .entity-name {
+    background-color: fade(var(--file-tree-item-hover-bg), 90%);
+
+    @include fake-full-width-bg(fade(var(--file-tree-item-hover-bg), 90%));
+
+    color: var(--file-tree-item-color);
+
+    .material-symbols {
+      color: var(--content-disabled) !important;
+    }
+  }
+
+  // the drop target gets the "selected" colour
+  ul.file-tree-list.file-tree-dragging
+    li.dnd-droppable-hover
+    .entity
+    .entity-name {
+    background-color: var(--file-tree-item-selected-bg);
+
+    @include fake-full-width-bg(var(--file-tree-item-selected-bg));
+
+    color: var(--file-tree-item-selected-color);
+
+    .material-symbols {
+      color: var(--file-tree-item-selected-color) !important;
+    }
+  }
+
+  .dnd-draggable-preview-layer {
+    position: absolute;
+    pointer-events: none;
+    z-index: 100;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+
+    &.dnd-droppable-hover {
+      border: 3px solid var(--file-tree-item-selected-bg);
+    }
+  }
+
+  .dnd-draggable-preview-item {
+    color: var(--file-tree-item-selected-color);
+    background-color: fade(var(--file-tree-item-selected-bg), 60%);
+    width: 75%;
+    padding-left: var(--spacing-08);
+    line-height: 2.05;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .disconnected-overlay {
+    background-color: var(--file-tree-bg);
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    z-index: 10;
+    opacity: 0.5;
+    cursor: wait;
+  }
+}
+
+.modal-new-file {
+  padding: 0;
+
+  table {
+    width: 100%;
+    table-layout: fixed;
+
+    td {
+      vertical-align: top;
+    }
+  }
+
+  .toggle-file-type-button {
+    font-size: 80%;
+    margin-top: calc(var(--spacing-05) * -1);
+
+    .btn {
+      display: inline-block;
+      padding: 0;
+      vertical-align: baseline;
+      font-size: inherit;
+    }
+
+    .btn:focus-within {
+      outline: none;
+      text-decoration: none;
+    }
+  }
+}
+
+.modal-new-file-list {
+  background-color: var(--bg-light-secondary);
+  width: 220px;
+
+  ul {
+    li {
+      /* old modal (a) */
+      a {
+        color: var(--content-secondary);
+        padding: var(--spacing-03);
+        display: block;
+        text-decoration: none;
+      }
+
+      /* new modal (button) */
+      .btn {
+        color: var(--content-secondary);
+        padding: var(--spacing-03);
+      }
+
+      .btn:hover {
+        text-decoration: none;
+      }
+
+      .btn:focus {
+        outline: none;
+        text-decoration: none;
+        background-color: white;
+      }
+    }
+
+    li.active {
+      background-color: white;
+
+      /* old modal (a) */
+      a {
+        color: var(--link-ui);
+      }
+
+      /* new modal (button) */
+      .btn {
+        color: var(--link-ui);
+        text-decoration: none;
+      }
+    }
+
+    li:hover {
+      background-color: white;
+    }
+  }
+}
+
+.file-tree-error {
+  text-align: center;
+  color: var(--content-secondary-dark);
+  padding: 20px;
+}
+
+.file-tree-modal-alert {
+  margin-top: var(--spacing-06);
+}
+
+.btn.modal-new-file-mode {
+  justify-content: left;
+  text-align: left;
+  text-decoration: none;
+  width: 100%;
+}
+
+.modal-new-file-body {
+  padding: 20px;
+  padding-top: var(--spacing-03);
+}
+
+.modal-new-file-body-upload {
+  padding-top: 20px;
+}
+
+.modal-new-file-body-conflict {
+  background-color: var(--bg-danger-03);
+  border: 1px dashed var(--border-danger);
+  min-height: 400px;
+  border-radius: 3px;
+  color: var(--content-primary);
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  padding: var(--spacing-05);
+}
+
+.modal-footer {
+  .approaching-file-limit {
+    font-weight: bold;
+  }
+
+  .at-file-limit {
+    text-align: left;
+  }
+}
+
+/* stylelint-disable selector-class-pattern */
+.modal-new-file-body-upload .uppy-Root {
+  font-family: inherit;
+}
+
+.modal-new-file-body-upload .uppy-Dashboard {
+  .uppy-Dashboard-inner {
+    border: none;
+  }
+
+  .uppy-Dashboard-dropFilesHereHint {
+    inset: 0;
+  }
+
+  .uppy-Dashboard-AddFiles {
+    margin: 0;
+    border: 1px dashed var(--border-primary);
+    height: 100%;
+
+    .uppy-Dashboard-AddFiles-title {
+      font-size: inherit;
+    }
+  }
+
+  .uppy-Dashboard-AddFiles-title {
+    width: 26em; // sized to create a wrap between the sentences
+    max-width: 100%;
+  }
+}

+ 139 - 2
services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide.scss

@@ -27,8 +27,7 @@
 }
 
 .ide-react-editor-sidebar {
-  @include file-tree-bg;
-
+  background-color: var(--file-tree-bg);
   height: 100%;
   color: var(--content-secondary-dark);
 }
@@ -51,3 +50,141 @@
     color: var(--neutral-20);
   }
 }
+
+.ide-react-symbol-palette {
+  height: 100%;
+  background-color: var(--bg-dark-tertiary);
+  color: var(--neutral-20);
+}
+
+.ide-react-file-tree-panel {
+  display: flex;
+  flex-direction: column;
+
+  // Prevent the file tree expanding beyond the boundary of the panel
+  .file-tree {
+    width: 100%;
+  }
+}
+
+.ide-react-editor-panel {
+  display: flex;
+  flex-direction: column;
+}
+
+// Ensure an element with class "full-size", such as the binary file view, stays within the bounds of the panel
+.ide-react-panel {
+  position: relative;
+  container-type: size;
+}
+
+.ide-panel-group-resizing {
+  background-color: white;
+
+  // Hide panel contents while resizing
+  .ide-react-editor-content,
+  .pdf {
+    display: none !important;
+  }
+}
+
+.horizontal-resize-handle {
+  width: 7px !important;
+  height: 100%;
+
+  // Enable ::before and ::after pseudo-elements to position themselves correctly
+  position: relative;
+  background-color: var(--bg-dark-secondary);
+
+  .custom-toggler {
+    padding: 0;
+    border-width: 0;
+  }
+
+  &.horizontal-resize-handle-enabled {
+    &::before,
+    &::after {
+      // This SVG has the colour hard-coded to the current value of @ol-blue-gray-2, so if we changed @ol-blue-gray-2,
+      // we'd have to change this SVG too
+      content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='7' height='18' viewBox='0 0 7 18'%3E%3Cpath d='M2 0h3v3H2zM2 5h3v3H2zM2 10h3v3H2zM2 15h3v3H2z' style='fill:%239da7b7'/%3E%3C/svg%3E");
+      display: block;
+      position: absolute;
+      text-align: center;
+      left: 0;
+      width: 7px;
+      height: 18px;
+    }
+
+    &::before {
+      top: 25%;
+    }
+
+    &::after {
+      top: 75%;
+    }
+  }
+
+  &:not(.horizontal-resize-handle-enabled) {
+    cursor: default;
+  }
+
+  .synctex-controls {
+    left: -8px;
+    margin: 0;
+
+    // Ensure that SyncTex controls appear in front of PDF viewer controls and logs pane
+    z-index: 12;
+  }
+}
+
+.vertical-resize-handle {
+  height: 6px;
+  background-color: var(--bg-dark-secondary);
+
+  &.vertical-resize-handle-enabled {
+    &:hover {
+      background-color: var(--bg-dark-primary);
+    }
+  }
+
+  &:not(.vertical-resize-handle-enabled) {
+    opacity: 0.5;
+    cursor: default;
+  }
+
+  &::after {
+    // This SVG has the colour hard-coded to the current value of @ol-blue-gray-2, so if we changed @ol-blue-gray-2,
+    // we'd have to change this SVG too
+    content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='6' viewBox='0 0 18 6'%3E%3Cpath d='M0 1.5h3v3H0zM5 1.5h3v3H5zM10 1.5h3v3h-3zM15 1.5h3v3h-3z' style='fill:%239da7b7'/%3E%3C/svg%3E");
+    display: block;
+    text-align: center;
+    line-height: 0;
+  }
+}
+
+.vertical-resizable-resizer {
+  background-color: var(--bg-dark-secondary);
+
+  &:hover {
+    background-color: var(--bg-dark-primary);
+  }
+
+  &::after {
+    @include heading-sm;
+
+    content: '\00b7\00b7\00b7\00b7';
+    display: block;
+    color: var(--content-disabled);
+    text-align: center;
+    pointer-events: none;
+  }
+}
+
+.vertical-resizable-resizer-disabled {
+  pointer-events: none;
+  opacity: 0.5;
+
+  &::after {
+    opacity: 0.5;
+  }
+}

+ 10 - 17
services/web/frontend/stylesheets/bootstrap-5/pages/editor/outline.scss

@@ -51,8 +51,7 @@
 }
 
 .outline-header-expand-collapse-btn {
-  @include file-tree-item-color;
-
+  color: var(--file-tree-item-color);
   display: flex;
   align-items: center;
   background-color: transparent;
@@ -81,8 +80,8 @@
 
 .outline-header-name {
   @include body-sm;
-  @include file-tree-item-color;
 
+  color: var(--file-tree-item-color);
   display: inline-block;
   font-weight: 700;
   margin: 0;
@@ -93,15 +92,13 @@
 }
 
 .outline-body {
-  @include file-tree-bg;
-
+  background-color: var(--file-tree-bg);
   overflow-y: auto;
   padding-right: var(--spacing-03);
 }
 
 .outline-body-no-elements {
-  @include file-tree-item-color;
-
+  color: var(--file-tree-item-color);
   text-align: center;
   padding: var(--spacing-08) var(--spacing-08) var(--spacing-11)
     var(--spacing-08);
@@ -109,15 +106,13 @@
 }
 
 .outline-body-link {
-  @include file-tree-item-color;
-
+  color: var(--file-tree-item-color);
   display: block;
   text-decoration: underline;
 
   &:hover,
   &:focus {
-    @include file-tree-item-color;
-
+    color: var(--file-tree-item-color);
     text-decoration: underline;
   }
 }
@@ -157,8 +152,7 @@
 }
 
 .outline-item-expand-collapse-btn {
-  @include file-tree-bg;
-
+  background-color: var(--file-tree-bg);
   display: inline;
   border: 0;
   padding: 0;
@@ -176,14 +170,14 @@
   }
 
   &:hover {
-    @include file-tree-item-hover-bg;
+    background-color: var(--file-tree-item-hover-bg);
   }
 }
 
 .outline-item-link {
-  @include file-tree-item-color;
   @include text-truncate;
 
+  color: var(--file-tree-item-color);
   display: inline;
   background-color: transparent;
   border: 0;
@@ -196,8 +190,7 @@
 
   &:hover,
   &:focus {
-    @include file-tree-item-hover-bg;
-
+    background-color: var(--file-tree-item-hover-bg);
     outline: 0;
   }
 }

+ 1 - 0
services/web/locales/en.json

@@ -1375,6 +1375,7 @@
   "only_group_admin_or_managers_can_delete_your_account_5": "For more information, see the \"Managed Accounts\" section in our terms of use, which you agree to by clicking Accept invitation",
   "only_importer_can_refresh": "Only the person who originally imported this __provider__ file can refresh it.",
   "open_a_file_on_the_left": "Open a file on the left",
+  "open_action_menu": "Open __name__ action menu",
   "open_advanced_reference_search": "Open advanced reference search",
   "open_as_template": "Open as Template",
   "open_file": "Edit file",

+ 1 - 1
services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-inner.spec.tsx

@@ -116,7 +116,7 @@ describe('<FileTreeitemInner />', function () {
         </EditorProviders>
       )
 
-      cy.findByRole('button', { name: 'Menu' }).click()
+      cy.findByRole('button', { name: 'Open bar.tex action menu' }).click()
       cy.findByRole('menuitem', { name: 'Rename' }).click()
       cy.findByRole('button', { name: 'bar.tex' }).should('not.exist')
       cy.findByRole('textbox')

+ 13 - 4
services/web/test/frontend/features/file-tree/components/file-tree-root.spec.tsx

@@ -90,7 +90,7 @@ describe('<FileTreeRoot/>', function () {
       ctrlKey: true,
       cmdKey: true,
     })
-    cy.findByRole('button', { name: 'Menu' }).click()
+    cy.findByRole('button', { name: 'Open main.tex action menu' }).click()
     cy.findByRole('menuitem', { name: 'Delete' }).click()
     cy.findByRole('button', { name: 'Cancel' })
   })
@@ -256,7 +256,10 @@ describe('<FileTreeRoot/>', function () {
     cy.findByRole('treeitem', { name: 'other.tex', selected: false })
 
     // single item selected: menu button is visible
-    cy.findAllByRole('button', { name: 'Menu' }).should('have.length', 1)
+    cy.findAllByRole('button', { name: 'Open main.tex action menu' }).should(
+      'have.length',
+      1
+    )
 
     // select the other item
     cy.findByRole('treeitem', { name: 'other.tex' }).click()
@@ -265,7 +268,10 @@ describe('<FileTreeRoot/>', function () {
     cy.findByRole('treeitem', { name: 'other.tex', selected: true })
 
     // single item selected: menu button is visible
-    cy.findAllByRole('button', { name: 'Menu' }).should('have.length', 1)
+    cy.findAllByRole('button', { name: 'Open other.tex action menu' }).should(
+      'have.length',
+      1
+    )
 
     // multi-select the main item
     cy.findByRole('treeitem', { name: 'main.tex' }).click({
@@ -277,7 +283,10 @@ describe('<FileTreeRoot/>', function () {
     cy.findByRole('treeitem', { name: 'other.tex', selected: true })
 
     // multiple items selected: no menu button is visible
-    cy.findAllByRole('button', { name: 'Menu' }).should('have.length', 0)
+    cy.findAllByRole('button', { name: 'Open main.tex action menu' }).should(
+      'have.length',
+      0
+    )
   })
 
   describe('when deselecting files', function () {

+ 2 - 2
services/web/test/frontend/features/file-tree/flows/delete-entity.spec.tsx

@@ -47,7 +47,7 @@ describe('FileTree Delete Entity Flow', function () {
       )
 
       cy.findByRole('treeitem', { name: 'main.tex' }).click()
-      cy.findByRole('button', { name: 'Menu' }).click()
+      cy.findByRole('button', { name: 'Open main.tex action menu' }).click()
       cy.findByRole('menuitem', { name: 'Delete' }).click()
     })
 
@@ -195,7 +195,7 @@ describe('FileTree Delete Entity Flow', function () {
       // as a proxy to check that the child entity has been unselect we start
       // a delete and ensure the modal is displayed (the cancel button can be
       // selected) This is needed to make sure the test fail.
-      cy.findByRole('button', { name: 'Menu' }).click()
+      cy.findByRole('button', { name: 'Open main.tex action menu' }).click()
       cy.findByRole('menuitem', { name: 'Delete' }).click()
       cy.findByRole('button', { name: 'Cancel' })
     })

+ 1 - 1
services/web/test/frontend/features/file-tree/flows/rename-entity.spec.tsx

@@ -157,7 +157,7 @@ describe('FileTree Rename Entity Flow', function () {
 
   function renameItem(from: string, to: string) {
     cy.findByRole('treeitem', { name: from }).click()
-    cy.findByRole('button', { name: 'Menu' }).click()
+    cy.findByRole('button', { name: `Open ${from} action menu` }).click()
     cy.findByRole('menuitem', { name: 'Rename' }).click()
     cy.findByRole('textbox').clear()
     cy.findByRole('textbox').type(to + '{enter}')