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

Merge pull request #7034 from overleaf/ta-pdf-detach-full

PDF Detach v2

GitOrigin-RevId: 3deb76474185f9176cde23ab32ef51b90df6e8e9
Timothée Alby 4 лет назад
Родитель
Сommit
3c01402bbd
63 измененных файлов с 1593 добавлено и 848 удалено
  1. 6 1
      services/web/app/src/Features/Project/ProjectController.js
  2. 1 5
      services/web/app/views/project/editor.pug
  3. 1 0
      services/web/app/views/project/editor/meta.pug
  4. 16 0
      services/web/app/views/project/editor_detached.pug
  5. 1 0
      services/web/frontend/extracted-translations.json
  6. 1 5
      services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.js
  7. 3 16
      services/web/frontend/js/features/pdf-preview/components/detach-compile-button.js
  8. 2 2
      services/web/frontend/js/features/pdf-preview/components/pdf-clear-cache-button.js
  9. 3 3
      services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.js
  10. 1 1
      services/web/frontend/js/features/pdf-preview/components/pdf-download-files-button.js
  11. 4 11
      services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-code-check-button.js
  12. 1 1
      services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.js
  13. 4 11
      services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-logs-button.js
  14. 1 1
      services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.js
  15. 9 0
      services/web/frontend/js/features/pdf-preview/components/pdf-log-entry.js
  16. 1 2
      services/web/frontend/js/features/pdf-preview/components/pdf-logs-entries.js
  17. 1 1
      services/web/frontend/js/features/pdf-preview/components/pdf-logs-viewer.js
  18. 18 0
      services/web/frontend/js/features/pdf-preview/components/pdf-preview-detached-root.js
  19. 45 6
      services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.js
  20. 1 1
      services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.js
  21. 33 43
      services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.js
  22. 1 1
      services/web/frontend/js/features/pdf-preview/components/pdf-viewer.js
  23. 3 27
      services/web/frontend/js/features/pdf-preview/hooks/use-compile-triggers.js
  24. 5 0
      services/web/frontend/js/ide-detached.js
  25. 0 18
      services/web/frontend/js/ide/human-readable-logs/HumanReadableLogs.js
  26. 449 0
      services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsHints.js
  27. 29 451
      services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsRules.js
  28. 386 0
      services/web/frontend/js/shared/context/detach-compile-context.js
  29. 20 1
      services/web/frontend/js/shared/context/detach-context.js
  30. 1 1
      services/web/frontend/js/shared/context/editor-context.js
  31. 6 3
      services/web/frontend/js/shared/context/ide-context.js
  32. 14 1
      services/web/frontend/js/shared/context/layout-context.js
  33. 35 9
      services/web/frontend/js/shared/context/local-compile-context.js
  34. 65 0
      services/web/frontend/js/shared/context/mock/mock-ide.js
  35. 9 6
      services/web/frontend/js/shared/context/root-context.js
  36. 2 2
      services/web/frontend/js/shared/context/user-context.js
  37. 13 25
      services/web/frontend/js/shared/hooks/use-callback-handlers.js
  38. 22 4
      services/web/frontend/js/shared/hooks/use-detach-layout.js
  39. 22 0
      services/web/frontend/js/shared/hooks/use-detach-state-watcher.js
  40. 17 3
      services/web/frontend/js/shared/hooks/use-detach-state.js
  41. 2 2
      services/web/frontend/js/shared/hooks/use-persisted-state.js
  42. 1 1
      services/web/frontend/stories/pdf-preview.stories.js
  43. 1 0
      services/web/locales/en.json
  44. 9 6
      services/web/test/frontend/features/chat/components/chat-pane.test.js
  45. 20 19
      services/web/test/frontend/features/chat/context/chat-context.test.js
  46. 0 6
      services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js
  47. 3 0
      services/web/test/frontend/features/file-tree/components/file-tree-root.test.js
  48. 6 0
      services/web/test/frontend/features/file-tree/flows/context-menu.test.js
  49. 3 0
      services/web/test/frontend/features/file-tree/flows/create-folder.test.js
  50. 6 0
      services/web/test/frontend/features/file-tree/flows/delete-entity.test.js
  51. 3 0
      services/web/test/frontend/features/file-tree/flows/rename-entity.test.js
  52. 1 20
      services/web/test/frontend/features/pdf-preview/components/detach-compile-button.test.js
  53. 9 3
      services/web/test/frontend/features/pdf-preview/components/pdf-logs-entries.test.js
  54. 70 0
      services/web/test/frontend/features/pdf-preview/components/pdf-preview-detached-root.test.js
  55. 53 5
      services/web/test/frontend/features/pdf-preview/components/pdf-preview-hybrid-toolbar.test.js
  56. 10 3
      services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js
  57. 82 95
      services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js
  58. 9 2
      services/web/test/frontend/features/pdf-preview/utils/mock-compile.js
  59. 6 7
      services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.js
  60. 5 2
      services/web/test/frontend/helpers/editor-providers.js
  61. 5 0
      services/web/test/frontend/helpers/sysend.js
  62. 36 15
      services/web/test/frontend/shared/hooks/use-detach-layout.test.js
  63. 1 0
      services/web/webpack.config.js

+ 6 - 1
services/web/app/src/Features/Project/ProjectController.js

@@ -948,11 +948,16 @@ const ProjectController = {
               !Features.hasFeature('saas') ||
               (user.features && user.features.symbolPalette)
 
-            res.render('project/editor', {
+            const template =
+              detachRole === 'detached'
+                ? 'project/editor_detached'
+                : 'project/editor'
+            res.render(template, {
               title: project.name,
               priority_title: true,
               bodyClasses: ['editor'],
               project_id: project._id,
+              projectName: project.name,
               user: {
                 id: userId,
                 email: user.email,

+ 1 - 5
services/web/app/views/project/editor.pug

@@ -65,11 +65,7 @@ block content
 						span.sr-only #{translate("close")}
 					.system-message-content(ng-bind-html="htmlContent")
 
-		if detachRole === 'detached'
-			div.full-size
-				pdf-preview()
-		else
-			include ./editor/main
+		include ./editor/main
 
 	script(type="text/ng-template", id="genericMessageModalTemplate")
 		.modal-header

+ 1 - 0
services/web/app/views/project/editor/meta.pug

@@ -1,5 +1,6 @@
 meta(name="ol-useV2History" data-type="boolean" content=useV2History)
 meta(name="ol-project_id" content=project_id)
+meta(name="ol-projectName" content=projectName)
 meta(name="ol-userSettings" data-type="json" content=userSettings)
 meta(name="ol-user" data-type="json" content=user)
 meta(name="ol-learnedWords" data-type="json" content=learnedWords)

+ 16 - 0
services/web/app/views/project/editor_detached.pug

@@ -0,0 +1,16 @@
+extends ../layout
+
+block entrypointVar
+	- entrypoint = 'ide-detached'
+
+block vars
+	- var suppressNavbar = true
+	- var suppressFooter = true
+	- var suppressSkipToContent = true
+	- metadata.robotsNoindexNofollow = true
+
+block content
+	#pdf-preview-detached-root()
+
+block append meta
+	include ./editor/meta

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

@@ -342,6 +342,7 @@
   "sync_project_to_github_explanation": "",
   "sync_to_dropbox": "",
   "sync_to_github": "",
+  "tab_connecting": "",
   "tab_no_longer_connected": "",
   "tags": "",
   "template_approved_by_publisher": "",

+ 1 - 5
services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.js

@@ -7,7 +7,6 @@ import IconChecked from '../../../shared/components/icon-checked'
 import ControlledDropdown from '../../../shared/components/controlled-dropdown'
 import IconEditorOnly from './icon-editor-only'
 import IconPdfOnly from './icon-pdf-only'
-import { useCompileContext } from '../../../shared/context/compile-context'
 import { useLayoutContext } from '../../../shared/context/layout-context'
 import * as eventTracking from '../../../infrastructure/event-tracking'
 
@@ -59,13 +58,10 @@ function LayoutDropdownButton() {
     pdfLayout,
   } = useLayoutContext(layoutContextPropTypes)
 
-  const { stopCompile } = useCompileContext()
-
   const handleDetach = useCallback(() => {
     detach()
-    stopCompile()
     eventTracking.sendMB('project-layout-detach')
-  }, [detach, stopCompile])
+  }, [detach])
 
   const handleReattach = useCallback(() => {
     if (detachRole !== 'detacher') {

+ 3 - 16
services/web/frontend/js/features/pdf-preview/components/detach-compile-button.js

@@ -1,26 +1,13 @@
-import { memo, useCallback } from 'react'
+import { memo } from 'react'
 import PropTypes from 'prop-types'
 import classnames from 'classnames'
 import { useLayoutContext } from '../../../shared/context/layout-context'
-import { useCompileContext } from '../../../shared/context/compile-context'
-import useDetachAction from '../../../shared/hooks/use-detach-action'
+import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
 import PdfCompileButtonInner from './pdf-compile-button-inner'
 
 export function DetachCompileButton() {
   const { compiling, hasChanges, startCompile } = useCompileContext()
 
-  const startOrTriggerCompile = useDetachAction(
-    'start-compile',
-    startCompile,
-    'detacher',
-    'detached'
-  )
-
-  const handleStartCompile = useCallback(
-    () => startOrTriggerCompile(),
-    [startOrTriggerCompile]
-  )
-
   return (
     <div
       className={classnames({
@@ -29,7 +16,7 @@ export function DetachCompileButton() {
       })}
     >
       <PdfCompileButtonInner
-        startCompile={handleStartCompile}
+        startCompile={startCompile}
         compiling={compiling}
       />
     </div>

+ 2 - 2
services/web/frontend/js/features/pdf-preview/components/pdf-clear-cache-button.js

@@ -2,7 +2,7 @@ import Icon from '../../../shared/components/icon'
 import { Button } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { memo } from 'react'
-import { useCompileContext } from '../../../shared/context/compile-context'
+import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
 
 function PdfClearCacheButton() {
   const { compiling, clearCache, clearingCache } = useCompileContext()
@@ -14,7 +14,7 @@ function PdfClearCacheButton() {
       bsSize="small"
       bsStyle="danger"
       className="logs-pane-actions-clear-cache"
-      onClick={clearCache}
+      onClick={() => clearCache()}
       disabled={clearingCache || compiling}
     >
       {clearingCache ? <Icon type="refresh" spin /> : <Icon type="trash-o" />}

+ 3 - 3
services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.js

@@ -4,7 +4,7 @@ import ControlledDropdown from '../../../shared/components/controlled-dropdown'
 import { useTranslation } from 'react-i18next'
 import { memo } from 'react'
 import classnames from 'classnames'
-import { useCompileContext } from '../../../shared/context/compile-context'
+import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
 import PdfCompileButtonInner from './pdf-compile-button-inner'
 
 function PdfCompileButton() {
@@ -84,7 +84,7 @@ function PdfCompileButton() {
         <MenuItem divider />
 
         <MenuItem
-          onSelect={stopCompile}
+          onSelect={() => stopCompile()}
           disabled={!compiling}
           aria-disabled={!compiling}
         >
@@ -92,7 +92,7 @@ function PdfCompileButton() {
         </MenuItem>
 
         <MenuItem
-          onSelect={recompileFromScratch}
+          onSelect={() => recompileFromScratch()}
           disabled={compiling}
           aria-disabled={compiling}
         >

+ 1 - 1
services/web/frontend/js/features/pdf-preview/components/pdf-download-files-button.js

@@ -3,7 +3,7 @@ import PdfFileList from './pdf-file-list'
 import ControlledDropdown from '../../../shared/components/controlled-dropdown'
 import { memo } from 'react'
 import { useTranslation } from 'react-i18next'
-import { useCompileContext } from '../../../shared/context/compile-context'
+import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
 
 function PdfDownloadFilesButton() {
   const { compiling, fileList } = useCompileContext()

+ 4 - 11
services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-code-check-button.js

@@ -1,24 +1,17 @@
 import { memo, useCallback } from 'react'
-import { sendMBOnce } from '../../../infrastructure/event-tracking'
 import { Button } from 'react-bootstrap'
 import Icon from '../../../shared/components/icon'
 import { useTranslation } from 'react-i18next'
-import { useCompileContext } from '../../../shared/context/compile-context'
+import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
 
 function PdfHybridCodeCheckButton() {
-  const { codeCheckFailed, error, setShowLogs } = useCompileContext()
+  const { codeCheckFailed, error, toggleLogs } = useCompileContext()
 
   const { t } = useTranslation()
 
   const handleClick = useCallback(() => {
-    setShowLogs(value => {
-      if (!value) {
-        sendMBOnce('ide-open-logs-once')
-      }
-
-      return !value
-    })
-  }, [setShowLogs])
+    toggleLogs()
+  }, [toggleLogs])
 
   if (!codeCheckFailed) {
     return null

+ 1 - 1
services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.js

@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
 import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap'
 import Icon from '../../../shared/components/icon'
 import { memo } from 'react'
-import { useCompileContext } from '../../../shared/context/compile-context'
+import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
 
 function PdfHybridDownloadButton() {
   const { pdfDownloadUrl } = useCompileContext()

+ 4 - 11
services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-logs-button.js

@@ -1,24 +1,17 @@
 import { memo, useCallback } from 'react'
 import { useTranslation } from 'react-i18next'
 import { Button, Label, OverlayTrigger, Tooltip } from 'react-bootstrap'
-import { sendMBOnce } from '../../../infrastructure/event-tracking'
 import Icon from '../../../shared/components/icon'
-import { useCompileContext } from '../../../shared/context/compile-context'
+import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
 
 function PdfHybridLogsButton() {
-  const { error, logEntries, setShowLogs, showLogs } = useCompileContext()
+  const { error, logEntries, toggleLogs, showLogs } = useCompileContext()
 
   const { t } = useTranslation()
 
   const handleClick = useCallback(() => {
-    setShowLogs(value => {
-      if (!value) {
-        sendMBOnce('ide-open-logs-once')
-      }
-
-      return !value
-    })
-  }, [setShowLogs])
+    toggleLogs()
+  }, [toggleLogs])
 
   const errorCount = Number(logEntries?.errors?.length)
   const warningCount = Number(logEntries?.warnings?.length)

+ 1 - 1
services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.js

@@ -8,7 +8,7 @@ import { buildHighlightElement } from '../util/highlights'
 import PDFJSWrapper from '../util/pdf-js-wrapper'
 import withErrorBoundary from '../../../infrastructure/error-boundary'
 import ErrorBoundaryFallback from './error-boundary-fallback'
-import { useCompileContext } from '../../../shared/context/compile-context'
+import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
 import getMeta from '../../../utils/meta'
 
 function PdfJsViewer({ url }) {

+ 9 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-log-entry.js

@@ -3,8 +3,10 @@ import classNames from 'classnames'
 import { memo, useCallback } from 'react'
 import PreviewLogEntryHeader from '../../preview/components/preview-log-entry-header'
 import PdfLogEntryContent from './pdf-log-entry-content'
+import HumanReadableLogsHints from '../../../ide/human-readable-logs/HumanReadableLogsHints'
 
 function PdfLogEntry({
+  ruleId,
   headerTitle,
   headerIcon,
   rawContent,
@@ -20,6 +22,12 @@ function PdfLogEntry({
   onSourceLocationClick,
   onClose,
 }) {
+  if (ruleId && HumanReadableLogsHints[ruleId]) {
+    const hint = HumanReadableLogsHints[ruleId]
+    formattedContent = hint.formattedContent
+    extraInfoURL = hint.extraInfoURL
+  }
+
   const handleLogEntryLinkClick = useCallback(
     event => {
       event.preventDefault()
@@ -56,6 +64,7 @@ function PdfLogEntry({
 }
 
 PdfLogEntry.propTypes = {
+  ruleId: PropTypes.string,
   sourceLocation: PreviewLogEntryHeader.propTypes.sourceLocation,
   headerTitle: PropTypes.string,
   headerIcon: PropTypes.element,

+ 1 - 2
services/web/frontend/js/features/pdf-preview/components/pdf-logs-entries.js

@@ -48,11 +48,10 @@ function PdfLogsEntries({ entries, hasErrors }) {
       {logEntries.map(logEntry => (
         <PdfLogEntry
           key={logEntry.key}
+          ruleId={logEntry.ruleId}
           headerTitle={logEntry.message}
           rawContent={logEntry.content}
           logType={logEntry.type}
-          formattedContent={logEntry.humanReadableHintComponent}
-          extraInfoURL={logEntry.extraInfoURL}
           level={logEntry.level}
           entryAriaLabel={t('log_entry_description', {
             level: logEntry.level,

+ 1 - 1
services/web/frontend/js/features/pdf-preview/components/pdf-logs-viewer.js

@@ -11,7 +11,7 @@ import withErrorBoundary from '../../../infrastructure/error-boundary'
 import ErrorBoundaryFallback from './error-boundary-fallback'
 import PdfCodeCheckFailedNotice from './pdf-code-check-failed-notice'
 import PdfLogsPaneInfoNotice from './pdf-logs-pane-info-notice'
-import { useCompileContext } from '../../../shared/context/compile-context'
+import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
 import PdfLogEntry from './pdf-log-entry'
 
 function PdfLogsViewer() {

+ 18 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-preview-detached-root.js

@@ -0,0 +1,18 @@
+import ReactDOM from 'react-dom'
+import PdfPreview from './pdf-preview'
+import { ContextRoot } from '../../../shared/context/root-context'
+
+function PdfPreviewDetachedRoot() {
+  return (
+    <ContextRoot>
+      <PdfPreview />
+    </ContextRoot>
+  )
+}
+
+export default PdfPreviewDetachedRoot // for testing
+
+const element = document.getElementById('pdf-preview-detached-root')
+if (element) {
+  ReactDOM.render(<PdfPreviewDetachedRoot />, element)
+}

+ 45 - 6
services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.js

@@ -1,4 +1,4 @@
-import { memo } from 'react'
+import { memo, useState, useEffect, useRef } from 'react'
 import { ButtonToolbar } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { useLayoutContext } from '../../../shared/context/layout-context'
@@ -9,19 +9,46 @@ import PdfHybridDownloadButton from './pdf-hybrid-download-button'
 import PdfHybridCodeCheckButton from './pdf-hybrid-code-check-button'
 import PdfOrphanRefreshButton from './pdf-orphan-refresh-button'
 import { DetachedSynctexControl } from './detach-synctex-control'
+import Icon from '../../../shared/components/icon'
+
+const ORPHAN_UI_TIMEOUT_MS = 5000
 
 function PdfPreviewHybridToolbar() {
   const { detachRole, detachIsLinked } = useLayoutContext()
 
+  const uiTimeoutRef = useRef()
+  const [orphanPdfTabAfterDelay, setOrphanPdfTabAfterDelay] = useState(false)
+
   const orphanPdfTab = !detachIsLinked && detachRole === 'detached'
 
+  useEffect(() => {
+    if (uiTimeoutRef.current) {
+      clearTimeout(uiTimeoutRef.current)
+    }
+
+    if (orphanPdfTab) {
+      uiTimeoutRef.current = setTimeout(() => {
+        setOrphanPdfTabAfterDelay(true)
+      }, ORPHAN_UI_TIMEOUT_MS)
+    } else {
+      setOrphanPdfTabAfterDelay(false)
+    }
+  }, [orphanPdfTab])
+
+  let ToolbarInner = null
+  if (orphanPdfTabAfterDelay) {
+    // when the detached tab has been orphan for a while
+    ToolbarInner = <PdfPreviewHybridToolbarOrphanInner />
+  } else if (orphanPdfTab) {
+    ToolbarInner = <PdfPreviewHybridToolbarConnectingInner />
+  } else {
+    // tab is not detached or not orphan
+    ToolbarInner = <PdfPreviewHybridToolbarInner />
+  }
+
   return (
     <ButtonToolbar className="toolbar toolbar-pdf toolbar-pdf-hybrid">
-      {orphanPdfTab ? (
-        <PdfPreviewHybridToolbarOrphanInner />
-      ) : (
-        <PdfPreviewHybridToolbarInner />
-      )}
+      {ToolbarInner}
     </ButtonToolbar>
   )
 }
@@ -55,4 +82,16 @@ function PdfPreviewHybridToolbarOrphanInner() {
   )
 }
 
+function PdfPreviewHybridToolbarConnectingInner() {
+  const { t } = useTranslation()
+  return (
+    <>
+      <div className="toolbar-pdf-orphan">
+        <Icon type="refresh" fw spin />
+        {t('tab_connecting')}…
+      </div>
+    </>
+  )
+}
+
 export default memo(PdfPreviewHybridToolbar)

+ 1 - 1
services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.js

@@ -4,7 +4,7 @@ import PdfLogsViewer from './pdf-logs-viewer'
 import PdfViewer from './pdf-viewer'
 import LoadingSpinner from '../../../shared/components/loading-spinner'
 import PdfHybridPreviewToolbar from './pdf-preview-hybrid-toolbar'
-import { useCompileContext } from '../../../shared/context/compile-context'
+import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
 
 function PdfPreviewPane() {
   const { pdfUrl } = useCompileContext()

+ 33 - 43
services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.js

@@ -1,10 +1,10 @@
 import classNames from 'classnames'
-import { memo, useCallback, useEffect, useState, useMemo } from 'react'
+import { memo, useCallback, useEffect, useState } from 'react'
 import PropTypes from 'prop-types'
 import { useIdeContext } from '../../../shared/context/ide-context'
 import { useProjectContext } from '../../../shared/context/project-context'
 import { getJSON } from '../../../infrastructure/fetch-json'
-import { useCompileContext } from '../../../shared/context/compile-context'
+import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
 import { useLayoutContext } from '../../../shared/context/layout-context'
 import useScopeValue from '../../../shared/hooks/use-scope-value'
 import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap'
@@ -134,32 +134,19 @@ function PdfSynctexControls() {
 
   const { signal } = useAbortController()
 
-  // for detacher editor tab, which cannot access pdfUrl in a scope value in
-  // detached state
-  const [pdfExists, setPdfExists] = useDetachState(
-    'pdf-exists',
-    !!pdfUrl,
-    'detached',
-    'detacher'
-  )
-
-  useEffect(() => {
-    setPdfExists(!!pdfUrl)
-  }, [pdfUrl, setPdfExists])
-
   useEffect(() => {
     const listener = event => setCursorPosition(event.detail)
     window.addEventListener('cursor:editor:update', listener)
     return () => window.removeEventListener('cursor:editor:update', listener)
   }, [ide])
 
-  const [syncToPdfInFlight, setSyncToPdfInFlight] = useDetachState(
-    'sync-to-pdf-inflight',
+  const [syncToPdfInFlight, setSyncToPdfInFlight] = useState(false)
+  const [syncToCodeInFlight, setSyncToCodeInFlight] = useDetachState(
+    'sync-to-code-inflight',
     false,
-    'detached',
-    'detacher'
+    'detacher',
+    'detached'
   )
-  const [syncToCodeInFlight, setSyncToCodeInFlight] = useState(false)
 
   const [, setSynctexError] = useScopeValue('sync_tex_error')
 
@@ -179,7 +166,7 @@ function PdfSynctexControls() {
     return path
   }, [ide])
 
-  const _goToCodeLine = useCallback(
+  const goToCodeLine = useCallback(
     (file, line) => {
       if (file) {
         const doc = ide.fileTreeManager.findEntityByPath(file)
@@ -200,14 +187,7 @@ function PdfSynctexControls() {
     [ide, isMounted, setSynctexError]
   )
 
-  const goToCodeLine = useDetachAction(
-    'go-to-code-line',
-    _goToCodeLine,
-    'detached',
-    'detacher'
-  )
-
-  const _goToPdfLocation = useCallback(
+  const goToPdfLocation = useCallback(
     params => {
       setSyncToPdfInFlight(true)
 
@@ -240,13 +220,6 @@ function PdfSynctexControls() {
     ]
   )
 
-  const goToPdfLocation = useDetachAction(
-    'go-to-pdf-location',
-    _goToPdfLocation,
-    'detacher',
-    'detached'
-  )
-
   const syncToPdf = useCallback(
     cursorPosition => {
       const params = new URLSearchParams({
@@ -260,7 +233,7 @@ function PdfSynctexControls() {
     [getCurrentFilePath, goToPdfLocation]
   )
 
-  const syncToCode = useCallback(
+  const _syncToCode = useCallback(
     (position, visualOffset = 0) => {
       setSyncToCodeInFlight(true)
       // FIXME: this actually works better if it's halfway across the
@@ -317,6 +290,13 @@ function PdfSynctexControls() {
     ]
   )
 
+  const syncToCode = useDetachAction(
+    'sync-to-code',
+    _syncToCode,
+    'detached',
+    'detacher'
+  )
+
   useEffect(() => {
     const listener = event => syncToCode(event.detail)
     window.addEventListener('synctex:sync-to-position', listener)
@@ -325,22 +305,32 @@ function PdfSynctexControls() {
     }
   }, [syncToCode])
 
-  const hasSingleSelectedDoc = useMemo(() => {
+  const [hasSingleSelectedDoc, setHasSingleSelectedDoc] = useDetachState(
+    'has-single-selected-doc',
+    false,
+    'detacher',
+    'detached'
+  )
+
+  useEffect(() => {
     if (selectedEntities.length !== 1) {
-      return false
+      setHasSingleSelectedDoc(false)
+      return
     }
 
     if (selectedEntities[0].type !== 'doc') {
-      return false
+      setHasSingleSelectedDoc(false)
+      return
     }
-    return true
-  }, [selectedEntities])
+
+    setHasSingleSelectedDoc(true)
+  }, [selectedEntities, setHasSingleSelectedDoc])
 
   if (!position) {
     return null
   }
 
-  if (!pdfExists || pdfViewer === 'native') {
+  if (!pdfUrl || pdfViewer === 'native') {
     return null
   }
 

+ 1 - 1
services/web/frontend/js/features/pdf-preview/components/pdf-viewer.js

@@ -1,5 +1,5 @@
 import { lazy, memo } from 'react'
-import { useCompileContext } from '../../../shared/context/compile-context'
+import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
 
 const PdfJsViewer = lazy(() =>
   import(/* webpackChunkName: "pdf-js-viewer" */ './pdf-js-viewer')

+ 3 - 27
services/web/frontend/js/features/pdf-preview/hooks/use-compile-triggers.js

@@ -1,18 +1,13 @@
-import { useCallback, useEffect } from 'react'
+import { useCallback } from 'react'
 import getMeta from '../../../utils/meta'
-import { useCompileContext } from '../../../shared/context/compile-context'
-import { useDetachContext } from '../../../shared/context/detach-context'
+import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
 import useEventListener from '../../../shared/hooks/use-event-listener'
 import useDetachAction from '../../../shared/hooks/use-detach-action'
-import usePreviousValue from '../../../shared/hooks/use-previous-value'
 
 const showPdfDetach = getMeta('ol-showPdfDetach')
-const debugPdfDetach = getMeta('ol-debugPdfDetach')
 
 export default function useCompileTriggers() {
-  const { startCompile, setChangedAt, cleanupCompileResult, setError } =
-    useCompileContext()
-  const { role: detachRole } = useDetachContext()
+  const { startCompile, setChangedAt } = useCompileContext()
 
   // recompile on key press
   const startOrTriggerCompile = useDetachAction(
@@ -43,23 +38,4 @@ export default function useCompileTriggers() {
   }, [setOrTriggerChangedAt, setChangedAt])
   useEventListener('doc:changed', setChangedAtHandler)
   useEventListener('doc:saved', setChangedAtHandler)
-
-  // clear preview and recompile when the detach role is reset
-  const previousDetachRole = usePreviousValue(detachRole)
-  useEffect(() => {
-    if (previousDetachRole && !detachRole) {
-      if (debugPdfDetach) {
-        console.log('Recompile on reattach', { previousDetachRole, detachRole })
-      }
-      cleanupCompileResult()
-      setError()
-      startCompile()
-    }
-  }, [
-    cleanupCompileResult,
-    setError,
-    startCompile,
-    previousDetachRole,
-    detachRole,
-  ])
 }

+ 5 - 0
services/web/frontend/js/ide-detached.js

@@ -0,0 +1,5 @@
+import './utils/meta'
+import './utils/webpack-public-path'
+import './infrastructure/error-reporter'
+import './i18n'
+import './features/pdf-preview/components/pdf-preview-detached-root'

+ 0 - 18
services/web/frontend/js/ide/human-readable-logs/HumanReadableLogs.js

@@ -28,11 +28,6 @@ export default {
         let type
         if (ruleDetails.ruleId != null) {
           entry.ruleId = ruleDetails.ruleId
-        } else if (ruleDetails.regexToMatch != null) {
-          entry.ruleId = `hint_${ruleDetails.regexToMatch
-            .toString()
-            .replace(/\s/g, '_')
-            .slice(1, -1)}`
         }
         if (ruleDetails.newMessage != null) {
           entry.message = entry.message.replace(
@@ -54,19 +49,6 @@ export default {
             seenErrorTypes[type] = true
           }
         }
-
-        if (ruleDetails.humanReadableHint != null) {
-          entry.humanReadableHint = ruleDetails.humanReadableHint
-        }
-
-        if (ruleDetails.humanReadableHintComponent != null) {
-          entry.humanReadableHintComponent =
-            ruleDetails.humanReadableHintComponent
-        }
-
-        if (ruleDetails.extraInfoURL != null) {
-          entry.extraInfoURL = ruleDetails.extraInfoURL
-        }
       }
     }
 

+ 449 - 0
services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsHints.js

@@ -0,0 +1,449 @@
+import PropTypes from 'prop-types'
+
+function WikiLink({ url, children }) {
+  if (window.wikiEnabled) {
+    return (
+      <a href={url} target="_blank" rel="noopener">
+        {children}
+      </a>
+    )
+  } else {
+    return <>{children}</>
+  }
+}
+
+WikiLink.propTypes = {
+  url: PropTypes.string.isRequired,
+  children: PropTypes.node.isRequired,
+}
+
+const hints = {
+  hint_misplaced_alignment_tab_character: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/Misplaced_alignment_tab_character_%26',
+    formattedContent: (
+      <>
+        You have placed an alignment tab character '&' in the wrong place. If
+        you want to align something, you must write it inside an align
+        environment such as \begin
+        {'{align}'} … \end
+        {'{align}'}, \begin
+        {'{tabular}'} … \end
+        {'{tabular}'}, etc. If you want to write an ampersand '&' in text, you
+        must write \& instead.
+      </>
+    ),
+  },
+  hint_extra_alignment_tab_has_been_changed: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/Extra_alignment_tab_has_been_changed_to_%5Ccr',
+    formattedContent: (
+      <>
+        You have written too many alignment tabs in a table, causing one of them
+        to be turned into a line break. Make sure you have specified the correct
+        number of columns in your{' '}
+        <WikiLink url="https://www.overleaf.com/learn/Tables">table</WikiLink>.
+      </>
+    ),
+  },
+  hint_display_math_should_end_with: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/Display_math_should_end_with_$$',
+    formattedContent: (
+      <>
+        You have forgotten a $ sign at the end of 'display math' mode. When
+        writing in display math mode, you must always math write inside $$ … $$.
+        Check that the number of $s match around each math expression.
+      </>
+    ),
+  },
+  hint_missing_inserted: {
+    extraInfoURL: 'https://www.overleaf.com/learn/Errors/Missing_$_inserted',
+    formattedContent: (
+      <>
+        <p>
+          You need to enclose all mathematical expressions and symbols with
+          special markers. These special markers create a ‘math mode’.
+        </p>
+        <p>
+          Use <code>$...$</code> for inline math mode, and <code>\[...\]</code>
+          or one of the mathematical environments (e.g. equation) for display
+          math mode.
+        </p>
+        <p>
+          This applies to symbols such as subscripts ( <code>_</code> ),
+          integrals ( <code>\int</code> ), Greek letters ( <code>\alpha</code>,{' '}
+          <code>\beta</code>, <code>\delta</code> ) and modifiers{' '}
+          <code>{'(\\vec{x}'}</code>, <code>{'\\tilde{x}'})</code>.
+        </p>
+      </>
+    ),
+  },
+  hint_reference_undefined: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/There_were_undefined_references',
+    formattedContent: (
+      <>
+        You have referenced something which has not yet been labelled. If you
+        have labelled it already, make sure that what is written inside \ref
+        {'{...}'} is the same as what is written inside \label
+        {'{...}'}.
+      </>
+    ),
+  },
+  hint_there_were_undefined_references: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/There_were_undefined_references',
+    formattedContent: (
+      <>
+        You have referenced something which has not yet been labelled. If you
+        have labelled it already, make sure that what is written inside \ref
+        {'{...}'} is the same as what is written inside \label
+        {'{...}'}.
+      </>
+    ),
+  },
+  hint_citation_on_page_undefined_on_input_line: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/Citation_XXX_on_page_XXX_undefined_on_input_line_XXX',
+    formattedContent: (
+      <>
+        You have cited something which is not included in your bibliography.
+        Make sure that the citation (\cite
+        {'{...}'}) has a corresponding key in your bibliography, and that both
+        are spelled the same way.
+      </>
+    ),
+  },
+  hint_label_multiply_defined_labels: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/There_were_multiply-defined_labels',
+    formattedContent: (
+      <>
+        You have used the same label more than once. Check that each \label
+        {'{...}'} labels only one item.
+      </>
+    ),
+  },
+  hint_float_specifier_changed: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/%60!h%27_float_specifier_changed_to_%60!ht%27',
+    formattedContent: (
+      <>
+        The float specifier 'h' is too strict of a demand for LaTeX to place
+        your float in a nice way here. Try relaxing it by using 'ht', or even
+        'htbp' if necessary. If you want to try keep the float here anyway,
+        check out the{' '}
+        <WikiLink url="https://www.overleaf.com/learn/Positioning_of_Figures">
+          float package
+        </WikiLink>
+        .
+      </>
+    ),
+  },
+  hint_no_positions_in_optional_float_specifier: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/No_positions_in_optional_float_specifier',
+    formattedContent: (
+      <>
+        You have forgotten to include a float specifier, which tells LaTeX where
+        to position your figure. To fix this, either insert a float specifier
+        inside the square brackets (e.g. \begin
+        {'{figure}'}
+        [h]), or remove the square brackets (e.g. \begin
+        {'{figure}'}
+        ). Find out more about float specifiers{' '}
+        <WikiLink url="https://www.overleaf.com/learn/Positioning_of_Figures">
+          here
+        </WikiLink>
+        .
+      </>
+    ),
+  },
+  hint_undefined_control_sequence: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/Undefined_control_sequence',
+    formattedContent: (
+      <>
+        The compiler is having trouble understanding a command you have used.
+        Check that the command is spelled correctly. If the command is part of a
+        package, make sure you have included the package in your preamble using
+        \usepackage
+        {'{...}'}.
+      </>
+    ),
+  },
+  hint_file_not_found: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/File_XXX_not_found_on_input_line_XXX',
+    formattedContent: (
+      <>
+        The compiler cannot find the file you want to include. Make sure that
+        you have{' '}
+        <WikiLink url="https://www.overleaf.com/learn/Including_images_in_ShareLaTeX">
+          uploaded the file
+        </WikiLink>{' '}
+        and{' '}
+        <WikiLink url="https://www.overleaf.com/learn/Errors/File_XXX_not_found_on_input_line_XXX.">
+          specified the file location correctly
+        </WikiLink>
+        .
+      </>
+    ),
+  },
+  hint_unknown_graphics_extension: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.XXX',
+    formattedContent: (
+      <>
+        The compiler does not recognise the file type of one of your images.
+        Make sure you are using a{' '}
+        <WikiLink url="https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.gif.">
+          supported image format
+        </WikiLink>{' '}
+        for your choice of compiler, and check that there are no periods (.) in
+        the name of your image.
+      </>
+    ),
+  },
+  hint_unknown_float_option_h: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60H%27',
+    formattedContent: (
+      <>
+        The compiler isn't recognizing the float option 'H'. Include \usepackage
+        {'{float}'} in your preamble to fix this.
+      </>
+    ),
+  },
+  hint_unknown_float_option_q: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60q%27',
+    formattedContent: (
+      <>
+        You have used a float specifier which the compiler does not understand.
+        You can learn more about the different float options available for
+        placing figures{' '}
+        <WikiLink url="https://www.overleaf.com/learn/Positioning_of_Figures">
+          here
+        </WikiLink>{' '}
+        .
+      </>
+    ),
+  },
+  hint_math_allowed_only_in_math_mode: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/LaTeX_Error:_%5Cmathrm_allowed_only_in_math_mode',
+    formattedContent: (
+      <>
+        You have used a font command which is only available in math mode. To
+        use this command, you must be in maths mode (E.g. $ … $ or \begin
+        {'{math}'} … \end
+        {'{math}'}
+        ). If you want to use it outside of math mode, use the text version
+        instead: \textrm, \textit, etc.
+      </>
+    ),
+  },
+  hint_mismatched_environment: {
+    formattedContent: (
+      <>
+        You have used \begin
+        {'{...}'} without a corresponding \end
+        {'{...}'}.
+      </>
+    ),
+  },
+  hint_mismatched_brackets: {
+    formattedContent: (
+      <>You have used an open bracket without a corresponding close bracket.</>
+    ),
+  },
+  hint_can_be_used_only_in_preamble: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Can_be_used_only_in_preamble',
+    formattedContent: (
+      <>
+        You have used a command in the main body of your document which should
+        be used in the preamble. Make sure that \documentclass[…]
+        {'{…}'} and all \usepackage
+        {'{…}'} commands are written before \begin
+        {'{document}'}.
+      </>
+    ),
+  },
+  hint_missing_right_inserted: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/Missing_%5Cright_insertede',
+    formattedContent: (
+      <>
+        You have started an expression with a \left command, but have not
+        included a corresponding \right command. Make sure that your \left and
+        \right commands balance everywhere, or else try using \Biggl and \Biggr
+        commands instead as shown{' '}
+        <WikiLink url="https://www.overleaf.com/learn/Errors/Missing_%5Cright_inserted">
+          here
+        </WikiLink>
+        .
+      </>
+    ),
+  },
+  hint_double_superscript: {
+    extraInfoURL: 'https://www.overleaf.com/learn/Errors/Double_superscript',
+    formattedContent: (
+      <>
+        You have written a double superscript incorrectly as a^b^c, or else you
+        have written a prime with a superscript. Remember to include {'{and}'}{' '}
+        when using multiple superscripts. Try a^
+        {'{b ^ c}'} instead.
+      </>
+    ),
+  },
+  hint_double_subscript: {
+    extraInfoURL: 'https://www.overleaf.com/learn/Errors/Double_subscript',
+    formattedContent: (
+      <>
+        You have written a double subscript incorrectly as a_b_c. Remember to
+        include {'{and}'} when using multiple subscripts. Try a_
+        {'{b_c}'} instead.
+      </>
+    ),
+  },
+  hint_no_author_given: {
+    extraInfoURL: 'https://www.overleaf.com/learn/Errors/No_%5Cauthor_given',
+    formattedContent: (
+      <>
+        You have used the \maketitle command, but have not specified any
+        \author. To fix this, include an author in your preamble using the
+        \author
+        {'{…}'} command.
+      </>
+    ),
+  },
+  hint_environment_undefined: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors%2FLaTeX%20Error%3A%20Environment%20XXX%20undefined',
+    formattedContent: (
+      <>
+        You have created an environment (using \begin
+        {'{…}'} and \end
+        {'{…}'} commands) which is not recognized. Make sure you have included
+        the required package for that environment in your preamble, and that the
+        environment is spelled correctly.
+      </>
+    ),
+  },
+  hint_somethings_wrong_perhaps_a_missing_item: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Something%27s_wrong--perhaps_a_missing_%5Citem',
+    formattedContent: (
+      <>
+        There are no entries found in a list you have created. Make sure you
+        label list entries using the \item command, and that you have not used a
+        list inside a table.
+      </>
+    ),
+  },
+  hint_misplaced_noalign: {
+    extraInfoURL: 'https://www.overleaf.com/learn/Errors/Misplaced_%5Cnoalign',
+    formattedContent: (
+      <>
+        You have used a \hline command in the wrong place, probably outside a
+        table. If the \hline command is written inside a table, try including \\
+        before it.
+      </>
+    ),
+  },
+  hint_no_line_here_to_end: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/LaTeX_Error:_There%27s_no_line_here_to_end',
+    formattedContent: (
+      <>
+        You have used a \\ or \newline command where LaTeX was not expecting
+        one. Make sure that you only use line breaks after blocks of text, and
+        be careful using linebreaks inside lists and other environments.\
+      </>
+    ),
+  },
+  hint_verb_ended_by_end_of_line: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors/LaTeX_Error:_%5Cverb_ended_by_end_of_line',
+    formattedContent: (
+      <>
+        You have used a \verb command incorrectly. Try replacling the \verb
+        command with \begin
+        {'{verbatim}'}
+        …\end
+        {'{verbatim}'}
+        .\
+      </>
+    ),
+  },
+  hint_illegal_unit_of_measure_pt_inserted: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors%2FIllegal%20unit%20of%20measure%20(pt%20inserted)',
+    formattedContent: (
+      <>
+        You have written a length, but have not specified the appropriate units
+        (pt, mm, cm etc.). If you have not written a length, check that you have
+        not witten a linebreak \\ followed by square brackets […] anywhere.
+      </>
+    ),
+  },
+  hint_extra_right: {
+    extraInfoURL: 'https://www.overleaf.com/learn/Errors/Extra_%5Cright',
+    formattedContent: (
+      <>
+        You have written a \right command without a corresponding \left command.
+        Check that all \left and \right commands balance everywhere.
+      </>
+    ),
+  },
+  hint_missing_begin_document_: {
+    extraInfoURL:
+      'https://www.overleaf.com/learn/Errors%2FLaTeX%20Error%3A%20Missing%20%5Cbegin%20document',
+    formattedContent: (
+      <>
+        No \begin
+        {'{document}'} command was found. Make sure you have included \begin
+        {'{document}'} in your preamble, and that your main document is set
+        correctly.
+      </>
+    ),
+  },
+  hint_mismatched_environment2: {
+    formattedContent: (
+      <>
+        You have used \begin
+        {'{}'} without a corresponding \end
+        {'{}'}.
+      </>
+    ),
+  },
+  hint_mismatched_environment3: {
+    formattedContent: (
+      <>
+        You have used \begin
+        {'{}'} without a corresponding \end
+        {'{}'}.
+      </>
+    ),
+  },
+  hint_mismatched_environment4: {
+    formattedContent: (
+      <>
+        You have used \begin
+        {'{}'} without a corresponding \end
+        {'{}'}.
+      </>
+    ),
+  },
+}
+
+if (!window.wikiEnabled) {
+  Object.keys(hints).forEach(ruleId => {
+    hints[ruleId].extraInfoURL = null
+  })
+}
+
+export default hints

+ 29 - 451
services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsRules.js

@@ -1,515 +1,133 @@
 /* eslint-disable no-useless-escape */
-import PropTypes from 'prop-types'
-
-function WikiLink({ url, children }) {
-  if (window.wikiEnabled) {
-    return (
-      <a href={url} target="_blank" rel="noopener">
-        {children}
-      </a>
-    )
-  } else {
-    return <>{children}</>
-  }
-}
-
-WikiLink.propTypes = {
-  url: PropTypes.string.isRequired,
-  children: PropTypes.node.isRequired,
-}
 
 const rules = [
   {
+    ruleId: 'hint_misplaced_alignment_tab_character',
     regexToMatch: /Misplaced alignment tab character \&/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/Misplaced_alignment_tab_character_%26',
-    humanReadableHintComponent: (
-      <>
-        You have placed an alignment tab character '&' in the wrong place. If
-        you want to align something, you must write it inside an align
-        environment such as \begin
-        {'{align}'} … \end
-        {'{align}'}, \begin
-        {'{tabular}'} … \end
-        {'{tabular}'}, etc. If you want to write an ampersand '&' in text, you
-        must write \& instead.
-      </>
-    ),
-    humanReadableHint:
-      'You have placed an alignment tab character &#x27;&amp;&#x27; in the wrong place. If you want to align something, you must write it inside an align environment such as \\begin{align} … \\end{align}, \\begin{tabular} … \\end{tabular}, etc. If you want to write an ampersand &#x27;&amp;&#x27; in text, you must write \\&amp; instead.',
   },
   {
+    ruleId: 'hint_extra_alignment_tab_has_been_changed',
     regexToMatch: /Extra alignment tab has been changed to \\cr/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/Extra_alignment_tab_has_been_changed_to_%5Ccr',
-    humanReadableHintComponent: (
-      <>
-        You have written too many alignment tabs in a table, causing one of them
-        to be turned into a line break. Make sure you have specified the correct
-        number of columns in your{' '}
-        <WikiLink url="https://www.overleaf.com/learn/Tables">table</WikiLink>.
-      </>
-    ),
-    humanReadableHint:
-      'You have written too many alignment tabs in a table, causing one of them to be turned into a line break. Make sure you have specified the correct number of columns in your <a href="https://www.overleaf.com/learn/Tables" target="_blank">table</a>.',
   },
   {
+    ruleId: 'hint_display_math_should_end_with',
     regexToMatch: /Display math should end with \$\$/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/Display_math_should_end_with_$$',
-    humanReadableHintComponent: (
-      <>
-        You have forgotten a $ sign at the end of 'display math' mode. When
-        writing in display math mode, you must always math write inside $$ … $$.
-        Check that the number of $s match around each math expression.
-      </>
-    ),
-    humanReadableHint:
-      'You have forgotten a $ sign at the end of &#x27;display math&#x27; mode. When writing in display math mode, you must always math write inside $$ … $$. Check that the number of $s match around each math expression.',
   },
   {
+    ruleId: 'hint_missing_inserted',
     regexToMatch: /Missing [{$] inserted./,
-    extraInfoURL: 'https://www.overleaf.com/learn/Errors/Missing_$_inserted',
-    humanReadableHintComponent: (
-      <>
-        <p>
-          You need to enclose all mathematical expressions and symbols with
-          special markers. These special markers create a ‘math mode’.
-        </p>
-        <p>
-          Use <code>$...$</code> for inline math mode, and <code>\[...\]</code>
-          or one of the mathematical environments (e.g. equation) for display
-          math mode.
-        </p>
-        <p>
-          This applies to symbols such as subscripts ( <code>_</code> ),
-          integrals ( <code>\int</code> ), Greek letters ( <code>\alpha</code>,{' '}
-          <code>\beta</code>, <code>\delta</code> ) and modifiers{' '}
-          <code>{'(\\vec{x}'}</code>, <code>{'\\tilde{x}'})</code>.
-        </p>
-      </>
-    ),
-    humanReadableHint:
-      'You need to enclose all mathematical expressions and symbols with special markers. These special markers create a ‘math mode’. Use $...$ for inline math mode, and \\[...\\] or one of the mathematical environments (e.g. equation) for display math mode.  This applies to symbols such as subscripts ( _ ), integrals ( \\int ), Greek letters ( \\alpha, \\beta, \\delta ) and modifiers (\\vec{x}, \\tilde{x} ).',
   },
   {
+    ruleId: 'hint_reference_undefined',
     regexToMatch: /Reference.+undefined/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/There_were_undefined_references',
-    humanReadableHintComponent: (
-      <>
-        You have referenced something which has not yet been labelled. If you
-        have labelled it already, make sure that what is written inside \ref
-        {'{...}'} is the same as what is written inside \label
-        {'{...}'}.
-      </>
-    ),
-    humanReadableHint:
-      'You have referenced something which has not yet been labelled. If you have labelled it already, make sure that what is written inside \\ref{...} is the same as what is written inside \\label{...}.',
   },
   {
+    ruleId: 'hint_there_were_undefined_references',
     regexToMatch: /There were undefined references/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/There_were_undefined_references',
-    humanReadableHintComponent: (
-      <>
-        You have referenced something which has not yet been labelled. If you
-        have labelled it already, make sure that what is written inside \ref
-        {'{...}'} is the same as what is written inside \label
-        {'{...}'}.
-      </>
-    ),
-    humanReadableHint:
-      'You have referenced something which has not yet been labelled. If you have labelled it already, make sure that what is written inside \\ref{...} is the same as what is written inside \\label{...}.',
   },
   {
+    ruleId: 'hint_citation_on_page_undefined_on_input_line',
     regexToMatch: /Citation .+ on page .+ undefined on input line .+/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/Citation_XXX_on_page_XXX_undefined_on_input_line_XXX',
-    humanReadableHintComponent: (
-      <>
-        You have cited something which is not included in your bibliography.
-        Make sure that the citation (\cite
-        {'{...}'}) has a corresponding key in your bibliography, and that both
-        are spelled the same way.
-      </>
-    ),
-    humanReadableHint:
-      'You have cited something which is not included in your bibliography. Make sure that the citation (\\cite{...}) has a corresponding key in your bibliography, and that both are spelled the same way.',
   },
   {
+    ruleId: 'hint_label_multiply_defined_labels',
     regexToMatch: /(Label .+)? multiply[ -]defined( labels)?/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/There_were_multiply-defined_labels',
-    humanReadableHintComponent: (
-      <>
-        You have used the same label more than once. Check that each \label
-        {'{...}'} labels only one item.
-      </>
-    ),
-    humanReadableHint:
-      'You have used the same label more than once. Check that each \\label{...} labels only one item.',
   },
   {
+    ruleId: 'hint_float_specifier_changed',
     regexToMatch: /`!?h' float specifier changed to `!?ht'/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/%60!h%27_float_specifier_changed_to_%60!ht%27',
-    humanReadableHintComponent: (
-      <>
-        The float specifier 'h' is too strict of a demand for LaTeX to place
-        your float in a nice way here. Try relaxing it by using 'ht', or even
-        'htbp' if necessary. If you want to try keep the float here anyway,
-        check out the{' '}
-        <WikiLink url="https://www.overleaf.com/learn/Positioning_of_Figures">
-          float package
-        </WikiLink>
-        .
-      </>
-    ),
-    humanReadableHint:
-      'The float specifier &#x27;h&#x27; is too strict of a demand for LaTeX to place your float in a nice way here. Try relaxing it by using &#x27;ht&#x27;, or even &#x27;htbp&#x27; if necessary. If you want to try keep the float here anyway, check out the <a href="https://www.overleaf.com/learn/Positioning_of_Figures" target="_blank">float package</a>.',
   },
   {
+    ruleId: 'hint_no_positions_in_optional_float_specifier',
     regexToMatch: /No positions in optional float specifier/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/No_positions_in_optional_float_specifier',
-    humanReadableHintComponent: (
-      <>
-        You have forgotten to include a float specifier, which tells LaTeX where
-        to position your figure. To fix this, either insert a float specifier
-        inside the square brackets (e.g. \begin
-        {'{figure}'}
-        [h]), or remove the square brackets (e.g. \begin
-        {'{figure}'}
-        ). Find out more about float specifiers{' '}
-        <WikiLink url="https://www.overleaf.com/learn/Positioning_of_Figures">
-          here
-        </WikiLink>
-        .
-      </>
-    ),
-    humanReadableHint:
-      'You have forgotten to include a float specifier, which tells LaTeX where to position your figure. To fix this, either insert a float specifier inside the square brackets (e.g. \\begin{figure}[h]), or remove the square brackets (e.g. \\begin{figure}). Find out more about float specifiers <a href="https://www.overleaf.com/learn/Positioning_of_Figures" target="_blank">here</a>.',
   },
   {
+    ruleId: 'hint_undefined_control_sequence',
     regexToMatch: /Undefined control sequence/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/Undefined_control_sequence',
-    humanReadableHintComponent: (
-      <>
-        The compiler is having trouble understanding a command you have used.
-        Check that the command is spelled correctly. If the command is part of a
-        package, make sure you have included the package in your preamble using
-        \usepackage
-        {'{...}'}.
-      </>
-    ),
-    humanReadableHint:
-      'The compiler is having trouble understanding a command you have used. Check that the command is spelled correctly. If the command is part of a package, make sure you have included the package in your preamble using \\usepackage{...}.',
   },
   {
+    ruleId: 'hint_file_not_found',
     regexToMatch: /File .+ not found/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/File_XXX_not_found_on_input_line_XXX',
-    humanReadableHintComponent: (
-      <>
-        The compiler cannot find the file you want to include. Make sure that
-        you have{' '}
-        <WikiLink url="https://www.overleaf.com/learn/Including_images_in_ShareLaTeX">
-          uploaded the file
-        </WikiLink>{' '}
-        and{' '}
-        <WikiLink url="https://www.overleaf.com/learn/Errors/File_XXX_not_found_on_input_line_XXX.">
-          specified the file location correctly
-        </WikiLink>
-        .
-      </>
-    ),
-    humanReadableHint:
-      'The compiler cannot find the file you want to include. Make sure that you have <a href="https://www.overleaf.com/learn/Including_images_in_ShareLaTeX" target="_blank">uploaded the file</a> and <a href="https://www.overleaf.com/learn/Errors/File_XXX_not_found_on_input_line_XXX." target="_blank">specified the file location correctly</a>.',
   },
   {
+    ruleId: 'hint_unknown_graphics_extension',
     regexToMatch: /LaTeX Error: Unknown graphics extension: \..+/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.XXX',
-    humanReadableHintComponent: (
-      <>
-        The compiler does not recognise the file type of one of your images.
-        Make sure you are using a{' '}
-        <WikiLink url="https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.gif.">
-          supported image format
-        </WikiLink>{' '}
-        for your choice of compiler, and check that there are no periods (.) in
-        the name of your image.
-      </>
-    ),
-    humanReadableHint:
-      'The compiler does not recognise the file type of one of your images. Make sure you are using a <a href="https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.gif." target="_blank">supported image format</a> for your choice of compiler, and check that there are no periods (.) in the name of your image.',
   },
   {
+    ruleId: 'hint_unknown_float_option_h',
     regexToMatch: /LaTeX Error: Unknown float option `H'/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60H%27',
-    humanReadableHintComponent: (
-      <>
-        The compiler isn't recognizing the float option 'H'. Include \usepackage
-        {'{float}'} in your preamble to fix this.
-      </>
-    ),
-    humanReadableHint:
-      'The compiler isn&#x27;t recognizing the float option &#x27;H&#x27;. Include \\usepackage{float} in your preamble to fix this.',
   },
   {
+    ruleId: 'hint_unknown_float_option_q',
     regexToMatch: /LaTeX Error: Unknown float option `q'/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60q%27',
-    humanReadableHintComponent: (
-      <>
-        You have used a float specifier which the compiler does not understand.
-        You can learn more about the different float options available for
-        placing figures{' '}
-        <WikiLink url="https://www.overleaf.com/learn/Positioning_of_Figures">
-          here
-        </WikiLink>{' '}
-        .
-      </>
-    ),
-    humanReadableHint:
-      'You have used a float specifier which the compiler does not understand. You can learn more about the different float options available for placing figures <a href="https://www.overleaf.com/learn/Positioning_of_Figures" target="_blank">here</a> .',
   },
   {
+    ruleId: 'hint_math_allowed_only_in_math_mode',
     regexToMatch: /LaTeX Error: \\math.+ allowed only in math mode/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/LaTeX_Error:_%5Cmathrm_allowed_only_in_math_mode',
-    humanReadableHintComponent: (
-      <>
-        You have used a font command which is only available in math mode. To
-        use this command, you must be in maths mode (E.g. $ … $ or \begin
-        {'{math}'} … \end
-        {'{math}'}
-        ). If you want to use it outside of math mode, use the text version
-        instead: \textrm, \textit, etc.
-      </>
-    ),
-    humanReadableHint:
-      'You have used a font command which is only available in math mode. To use this command, you must be in maths mode (E.g. $ … $ or \\begin{math} … \\end{math}). If you want to use it outside of math mode, use the text version instead: \\textrm, \\textit, etc.',
   },
   {
     ruleId: 'hint_mismatched_environment',
     types: ['environment'],
     regexToMatch: /Error: `([^']{2,})' expected, found `([^']{2,})'.*/,
     newMessage: 'Error: environment does not match \\begin{$1} ... \\end{$2}',
-    humanReadableHintComponent: (
-      <>
-        You have used \begin
-        {'{...}'} without a corresponding \end
-        {'{...}'}.
-      </>
-    ),
-    humanReadableHint:
-      'You have used \\begin{...} without a corresponding \\end{...}.',
   },
   {
     ruleId: 'hint_mismatched_brackets',
     types: ['environment'],
     regexToMatch: /Error: `([^a-zA-Z0-9])' expected, found `([^a-zA-Z0-9])'.*/,
     newMessage: "Error: brackets do not match, found '$2' instead of '$1'",
-    humanReadableHintComponent: (
-      <>You have used an open bracket without a corresponding close bracket.</>
-    ),
-    humanReadableHint:
-      'You have used an open bracket without a corresponding close bracket.',
   },
   {
+    ruleId: 'hint_can_be_used_only_in_preamble',
     regexToMatch: /LaTeX Error: Can be used only in preamble/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Can_be_used_only_in_preamble',
-    humanReadableHintComponent: (
-      <>
-        You have used a command in the main body of your document which should
-        be used in the preamble. Make sure that \documentclass[…]
-        {'{…}'} and all \usepackage
-        {'{…}'} commands are written before \begin
-        {'{document}'}.
-      </>
-    ),
-    humanReadableHint:
-      'You have used a command in the main body of your document which should be used in the preamble. Make sure that \\documentclass[…]{…} and all \\usepackage{…} commands are written before \\begin{document}.',
   },
   {
+    ruleId: 'hint_missing_right_inserted',
     regexToMatch: /Missing \\right inserted/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/Missing_%5Cright_insertede',
-    humanReadableHintComponent: (
-      <>
-        You have started an expression with a \left command, but have not
-        included a corresponding \right command. Make sure that your \left and
-        \right commands balance everywhere, or else try using \Biggl and \Biggr
-        commands instead as shown{' '}
-        <WikiLink url="https://www.overleaf.com/learn/Errors/Missing_%5Cright_inserted">
-          here
-        </WikiLink>
-        .
-      </>
-    ),
-    humanReadableHint:
-      'You have started an expression with a \\left command, but have not included a corresponding \\right command. Make sure that your \\left and \\right commands balance everywhere, or else try using \\Biggl and \\Biggr commands instead as shown <a href="https://www.overleaf.com/learn/Errors/Missing_%5Cright_inserted" target="_blank">here</a>.',
   },
   {
+    ruleId: 'hint_double_superscript',
     regexToMatch: /Double superscript/,
-    extraInfoURL: 'https://www.overleaf.com/learn/Errors/Double_superscript',
-    humanReadableHintComponent: (
-      <>
-        You have written a double superscript incorrectly as a^b^c, or else you
-        have written a prime with a superscript. Remember to include {'{and}'}{' '}
-        when using multiple superscripts. Try a^
-        {'{b ^ c}'} instead.
-      </>
-    ),
-    humanReadableHint:
-      'You have written a double superscript incorrectly as a^b^c, or else you have written a prime with a superscript. Remember to include {and} when using multiple superscripts. Try a^{b ^ c} instead.',
   },
   {
+    ruleId: 'hint_double_subscript',
     regexToMatch: /Double subscript/,
-    extraInfoURL: 'https://www.overleaf.com/learn/Errors/Double_subscript',
-    humanReadableHintComponent: (
-      <>
-        You have written a double subscript incorrectly as a_b_c. Remember to
-        include {'{and}'} when using multiple subscripts. Try a_
-        {'{b_c}'} instead.
-      </>
-    ),
-    humanReadableHint:
-      'You have written a double subscript incorrectly as a_b_c. Remember to include {and} when using multiple subscripts. Try a_{b_c} instead.',
   },
   {
+    ruleId: 'hint_no_author_given',
     regexToMatch: /No \\author given/,
-    extraInfoURL: 'https://www.overleaf.com/learn/Errors/No_%5Cauthor_given',
-    humanReadableHintComponent: (
-      <>
-        You have used the \maketitle command, but have not specified any
-        \author. To fix this, include an author in your preamble using the
-        \author
-        {'{…}'} command.
-      </>
-    ),
-    humanReadableHint:
-      'You have used the \\maketitle command, but have not specified any \\author. To fix this, include an author in your preamble using the \\author{…} command.',
   },
   {
+    ruleId: 'hint_environment_undefined',
     regexToMatch: /LaTeX Error: Environment .+ undefined/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors%2FLaTeX%20Error%3A%20Environment%20XXX%20undefined',
-    humanReadableHintComponent: (
-      <>
-        You have created an environment (using \begin
-        {'{…}'} and \end
-        {'{…}'} commands) which is not recognized. Make sure you have included
-        the required package for that environment in your preamble, and that the
-        environment is spelled correctly.
-      </>
-    ),
-    humanReadableHint:
-      'You have created an environment (using \\begin{…} and \\end{…} commands) which is not recognized. Make sure you have included the required package for that environment in your preamble, and that the environment is spelled correctly.',
   },
   {
+    ruleId: 'hint_somethings_wrong_perhaps_a_missing_item',
     regexToMatch: /LaTeX Error: Something's wrong--perhaps a missing \\item/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Something%27s_wrong--perhaps_a_missing_%5Citem',
-    humanReadableHintComponent: (
-      <>
-        There are no entries found in a list you have created. Make sure you
-        label list entries using the \item command, and that you have not used a
-        list inside a table.
-      </>
-    ),
-    humanReadableHint:
-      'There are no entries found in a list you have created. Make sure you label list entries using the \\item command, and that you have not used a list inside a table.',
   },
   {
+    ruleId: 'hint_misplaced_noalign',
     regexToMatch: /Misplaced \\noalign/,
-    extraInfoURL: 'https://www.overleaf.com/learn/Errors/Misplaced_%5Cnoalign',
-    humanReadableHintComponent: (
-      <>
-        You have used a \hline command in the wrong place, probably outside a
-        table. If the \hline command is written inside a table, try including \\
-        before it.
-      </>
-    ),
-    humanReadableHint:
-      'You have used a \\hline command in the wrong place, probably outside a table. If the \\hline command is written inside a table, try including \\\\ before it.',
   },
   {
+    ruleId: 'hint_no_line_here_to_end',
     regexToMatch: /LaTeX Error: There's no line here to end/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/LaTeX_Error:_There%27s_no_line_here_to_end',
-    humanReadableHintComponent: (
-      <>
-        You have used a \\ or \newline command where LaTeX was not expecting
-        one. Make sure that you only use line breaks after blocks of text, and
-        be careful using linebreaks inside lists and other environments.\
-      </>
-    ),
-    humanReadableHint:
-      'You have used a \\\\ or \\newline command where LaTeX was not expecting one. Make sure that you only use line breaks after blocks of text, and be careful using linebreaks inside lists and other environments.\\',
   },
   {
+    ruleId: 'hint_verb_ended_by_end_of_line',
     regexToMatch: /LaTeX Error: \\verb ended by end of line/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors/LaTeX_Error:_%5Cverb_ended_by_end_of_line',
-    humanReadableHintComponent: (
-      <>
-        You have used a \verb command incorrectly. Try replacling the \verb
-        command with \begin
-        {'{verbatim}'}
-        …\end
-        {'{verbatim}'}
-        .\
-      </>
-    ),
-    humanReadableHint:
-      'You have used a \\verb command incorrectly. Try replacling the \\verb command with \\begin{verbatim}…\\end{verbatim}.\\',
   },
   {
+    ruleId: 'hint_illegal_unit_of_measure_pt_inserted',
     regexToMatch: /Illegal unit of measure (pt inserted)/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors%2FIllegal%20unit%20of%20measure%20(pt%20inserted)',
-    humanReadableHintComponent: (
-      <>
-        You have written a length, but have not specified the appropriate units
-        (pt, mm, cm etc.). If you have not written a length, check that you have
-        not witten a linebreak \\ followed by square brackets […] anywhere.
-      </>
-    ),
-    humanReadableHint:
-      'You have written a length, but have not specified the appropriate units (pt, mm, cm etc.). If you have not written a length, check that you have not witten a linebreak \\\\ followed by square brackets […] anywhere.',
   },
   {
+    ruleId: 'hint_extra_right',
     regexToMatch: /Extra \\right/,
-    extraInfoURL: 'https://www.overleaf.com/learn/Errors/Extra_%5Cright',
-    humanReadableHintComponent: (
-      <>
-        You have written a \right command without a corresponding \left command.
-        Check that all \left and \right commands balance everywhere.
-      </>
-    ),
-    humanReadableHint:
-      'You have written a \\right command without a corresponding \\left command. Check that all \\left and \\right commands balance everywhere.',
   },
   {
+    ruleId: 'hint_missing_begin_document_',
     regexToMatch: /Missing \\begin{document}/,
-    extraInfoURL:
-      'https://www.overleaf.com/learn/Errors%2FLaTeX%20Error%3A%20Missing%20%5Cbegin%20document',
-    humanReadableHintComponent: (
-      <>
-        No \begin
-        {'{document}'} command was found. Make sure you have included \begin
-        {'{document}'} in your preamble, and that your main document is set
-        correctly.
-      </>
-    ),
-    humanReadableHint:
-      'No \\begin{document} command was found. Make sure you have included \\begin{document} in your preamble, and that your main document is set correctly.',
   },
   {
     ruleId: 'hint_mismatched_environment2',
@@ -518,15 +136,6 @@ const rules = [
     regexToMatch:
       /Error: `\\end\{([^\}]+)\}' expected but found `\\end\{([^\}]+)\}'.*/,
     newMessage: 'Error: environments do not match: \\begin{$1} ... \\end{$2}',
-    humanReadableHintComponent: (
-      <>
-        You have used \begin
-        {'{}'} without a corresponding \end
-        {'{}'}.
-      </>
-    ),
-    humanReadableHint:
-      'You have used \\begin{} without a corresponding \\end{}.',
   },
   {
     ruleId: 'hint_mismatched_environment3',
@@ -535,15 +144,6 @@ const rules = [
     regexToMatch:
       /Warning: No matching \\end found for `\\begin\{([^\}]+)\}'.*/,
     newMessage: 'Warning: No matching \\end found for \\begin{$1}',
-    humanReadableHintComponent: (
-      <>
-        You have used \begin
-        {'{}'} without a corresponding \end
-        {'{}'}.
-      </>
-    ),
-    humanReadableHint:
-      'You have used \\begin{} without a corresponding \\end{}.',
   },
   {
     ruleId: 'hint_mismatched_environment4',
@@ -552,29 +152,7 @@ const rules = [
     regexToMatch:
       /Error: Found `\\end\{([^\}]+)\}' without corresponding \\begin.*/,
     newMessage: 'Error: found \\end{$1} without a corresponding \\begin{$1}',
-    humanReadableHintComponent: (
-      <>
-        You have used \begin
-        {'{}'} without a corresponding \end
-        {'{}'}.
-      </>
-    ),
-    humanReadableHint:
-      'You have used \\begin{} without a corresponding \\end{}.',
   },
 ]
 
-if (!window.wikiEnabled) {
-  rules.forEach(rule => {
-    rule.extraInfoURL = null
-    rule.humanReadableHint = stripHTMLFromString(rule.humanReadableHint)
-  })
-}
-
-function stripHTMLFromString(htmlStr) {
-  const tmp = document.createElement('DIV')
-  tmp.innerHTML = htmlStr
-  return tmp.textContent || tmp.innerText || ''
-}
-
 export default rules

+ 386 - 0
services/web/frontend/js/shared/context/detach-compile-context.js

@@ -0,0 +1,386 @@
+import { createContext, useContext, useMemo } from 'react'
+import PropTypes from 'prop-types'
+import {
+  useLocalCompileContext,
+  CompileContextPropTypes,
+} from './local-compile-context'
+import useDetachStateWatcher from '../hooks/use-detach-state-watcher'
+import useDetachAction from '../hooks/use-detach-action'
+
+export const DetachCompileContext = createContext()
+
+DetachCompileContext.Provider.propTypes = CompileContextPropTypes
+
+export function DetachCompileProvider({ children }) {
+  const localCompileContext = useLocalCompileContext()
+  if (!localCompileContext) {
+    throw new Error(
+      'DetachCompileProvider is only available inside LocalCompileProvider'
+    )
+  }
+
+  const {
+    autoCompile: _autoCompile,
+    clearingCache: _clearingCache,
+    clsiServerId: _clsiServerId,
+    codeCheckFailed: _codeCheckFailed,
+    compiling: _compiling,
+    draft: _draft,
+    error: _error,
+    fileList: _fileList,
+    hasChanges: _hasChanges,
+    highlights: _highlights,
+    logEntries: _logEntries,
+    logEntryAnnotations: _logEntryAnnotations,
+    pdfDownloadUrl: _pdfDownloadUrl,
+    pdfUrl: _pdfUrl,
+    pdfViewer: _pdfViewer,
+    position: _position,
+    rawLog: _rawLog,
+    setAutoCompile: _setAutoCompile,
+    setDraft: _setDraft,
+    setError: _setError,
+    setHasLintingError: _setHasLintingError,
+    setHighlights: _setHighlights,
+    setPosition: _setPosition,
+    setShowLogs: _setShowLogs,
+    toggleLogs: _toggleLogs,
+    setStopOnValidationError: _setStopOnValidationError,
+    showLogs: _showLogs,
+    stopOnValidationError: _stopOnValidationError,
+    uncompiled: _uncompiled,
+    validationIssues: _validationIssues,
+    firstRenderDone: _firstRenderDone,
+    cleanupCompileResult: _cleanupCompileResult,
+    recompileFromScratch: _recompileFromScratch,
+    setCompiling: _setCompiling,
+    startCompile: _startCompile,
+    stopCompile: _stopCompile,
+    setChangedAt: _setChangedAt,
+    clearCache: _clearCache,
+  } = localCompileContext
+
+  const [autoCompile] = useDetachStateWatcher(
+    'autoCompile',
+    _autoCompile,
+    'detacher',
+    'detached'
+  )
+  const [clearingCache] = useDetachStateWatcher(
+    'clearingCache',
+    _clearingCache,
+    'detacher',
+    'detached'
+  )
+  const [clsiServerId] = useDetachStateWatcher(
+    'clsiServerId',
+    _clsiServerId,
+    'detacher',
+    'detached'
+  )
+  const [codeCheckFailed] = useDetachStateWatcher(
+    'codeCheckFailed',
+    _codeCheckFailed,
+    'detacher',
+    'detached'
+  )
+  const [compiling] = useDetachStateWatcher(
+    'compiling',
+    _compiling,
+    'detacher',
+    'detached'
+  )
+  const [draft] = useDetachStateWatcher('draft', _draft, 'detacher', 'detached')
+  const [error] = useDetachStateWatcher('error', _error, 'detacher', 'detached')
+  const [fileList] = useDetachStateWatcher(
+    'fileList',
+    _fileList,
+    'detacher',
+    'detached'
+  )
+  const [hasChanges] = useDetachStateWatcher(
+    'hasChanges',
+    _hasChanges,
+    'detacher',
+    'detached'
+  )
+  const [highlights] = useDetachStateWatcher(
+    'highlights',
+    _highlights,
+    'detacher',
+    'detached'
+  )
+  const [logEntries] = useDetachStateWatcher(
+    'logEntries',
+    _logEntries,
+    'detacher',
+    'detached'
+  )
+  const [logEntryAnnotations] = useDetachStateWatcher(
+    'logEntryAnnotations',
+    _logEntryAnnotations,
+    'detacher',
+    'detached'
+  )
+  const [pdfDownloadUrl] = useDetachStateWatcher(
+    'pdfDownloadUrl',
+    _pdfDownloadUrl,
+    'detacher',
+    'detached'
+  )
+  const [pdfUrl] = useDetachStateWatcher(
+    'pdfUrl',
+    _pdfUrl,
+    'detacher',
+    'detached'
+  )
+  const [pdfViewer] = useDetachStateWatcher(
+    'pdfViewer',
+    _pdfViewer,
+    'detacher',
+    'detached'
+  )
+  const [position] = useDetachStateWatcher(
+    'position',
+    _position,
+    'detacher',
+    'detached'
+  )
+  const [rawLog] = useDetachStateWatcher(
+    'rawLog',
+    _rawLog,
+    'detacher',
+    'detached'
+  )
+  const [showLogs] = useDetachStateWatcher(
+    'showLogs',
+    _showLogs,
+    'detacher',
+    'detached'
+  )
+  const [stopOnValidationError] = useDetachStateWatcher(
+    'stopOnValidationError',
+    _stopOnValidationError,
+    'detacher',
+    'detached'
+  )
+  const [uncompiled] = useDetachStateWatcher(
+    'uncompiled',
+    _uncompiled,
+    'detacher',
+    'detached'
+  )
+  const [validationIssues] = useDetachStateWatcher(
+    'validationIssues',
+    _validationIssues,
+    'detacher',
+    'detached'
+  )
+
+  const setAutoCompile = useDetachAction(
+    'setAutoCompile',
+    _setAutoCompile,
+    'detached',
+    'detacher'
+  )
+  const setDraft = useDetachAction(
+    'setDraft',
+    _setDraft,
+    'detached',
+    'detacher'
+  )
+  const setError = useDetachAction(
+    'setError',
+    _setError,
+    'detacher',
+    'detached'
+  )
+  const setPosition = useDetachAction(
+    'setPosition',
+    _setPosition,
+    'detached',
+    'detacher'
+  )
+  const firstRenderDone = useDetachAction(
+    'firstRenderDone',
+    _firstRenderDone,
+    'detacher',
+    'detached'
+  )
+  const setHasLintingError = useDetachAction(
+    'setHasLintingError',
+    _setHasLintingError,
+    'detacher',
+    'detached'
+  )
+  const setHighlights = useDetachAction(
+    'setHighlights',
+    _setHighlights,
+    'detacher',
+    'detached'
+  )
+  const setShowLogs = useDetachAction(
+    'setShowLogs',
+    _setShowLogs,
+    'detached',
+    'detacher'
+  )
+  const toggleLogs = useDetachAction(
+    'toggleLogs',
+    _toggleLogs,
+    'detached',
+    'detacher'
+  )
+  const setStopOnValidationError = useDetachAction(
+    'setStopOnValidationError',
+    _setStopOnValidationError,
+    'detached',
+    'detacher'
+  )
+  const cleanupCompileResult = useDetachAction(
+    'cleanupCompileResult',
+    _cleanupCompileResult,
+    'detached',
+    'detacher'
+  )
+  const recompileFromScratch = useDetachAction(
+    'recompileFromScratch',
+    _recompileFromScratch,
+    'detached',
+    'detacher'
+  )
+  const setCompiling = useDetachAction(
+    'setCompiling',
+    _setCompiling,
+    'detacher',
+    'detached'
+  )
+  const startCompile = useDetachAction(
+    'startCompile',
+    _startCompile,
+    'detached',
+    'detacher'
+  )
+  const stopCompile = useDetachAction(
+    'stopCompile',
+    _stopCompile,
+    'detached',
+    'detacher'
+  )
+  const setChangedAt = useDetachAction(
+    'setChangedAt',
+    _setChangedAt,
+    'detached',
+    'detacher'
+  )
+  const clearCache = useDetachAction(
+    'clearCache',
+    _clearCache,
+    'detached',
+    'detacher'
+  )
+
+  const value = useMemo(
+    () => ({
+      autoCompile,
+      clearCache,
+      clearingCache,
+      clsiServerId,
+      codeCheckFailed,
+      compiling,
+      draft,
+      error,
+      fileList,
+      hasChanges,
+      highlights,
+      logEntryAnnotations,
+      logEntries,
+      pdfDownloadUrl,
+      pdfUrl,
+      pdfViewer,
+      position,
+      rawLog,
+      recompileFromScratch,
+      setAutoCompile,
+      setCompiling,
+      setDraft,
+      setError,
+      setHasLintingError,
+      setHighlights,
+      setPosition,
+      setShowLogs,
+      toggleLogs,
+      setStopOnValidationError,
+      showLogs,
+      startCompile,
+      stopCompile,
+      stopOnValidationError,
+      uncompiled,
+      validationIssues,
+      firstRenderDone,
+      setChangedAt,
+      cleanupCompileResult,
+    }),
+    [
+      autoCompile,
+      clearCache,
+      clearingCache,
+      clsiServerId,
+      codeCheckFailed,
+      compiling,
+      draft,
+      error,
+      fileList,
+      hasChanges,
+      highlights,
+      logEntryAnnotations,
+      logEntries,
+      pdfDownloadUrl,
+      pdfUrl,
+      pdfViewer,
+      position,
+      rawLog,
+      recompileFromScratch,
+      setAutoCompile,
+      setCompiling,
+      setDraft,
+      setError,
+      setHasLintingError,
+      setHighlights,
+      setPosition,
+      setShowLogs,
+      toggleLogs,
+      setStopOnValidationError,
+      showLogs,
+      startCompile,
+      stopCompile,
+      stopOnValidationError,
+      uncompiled,
+      validationIssues,
+      firstRenderDone,
+      setChangedAt,
+      cleanupCompileResult,
+    ]
+  )
+
+  return (
+    <DetachCompileContext.Provider value={value}>
+      {children}
+    </DetachCompileContext.Provider>
+  )
+}
+
+DetachCompileProvider.propTypes = {
+  children: PropTypes.any,
+}
+
+export function useDetachCompileContext(propTypes) {
+  const data = useContext(DetachCompileContext)
+  PropTypes.checkPropTypes(
+    propTypes,
+    data,
+    'data',
+    'DetachCompileContext.Provider'
+  )
+  return data
+}

+ 20 - 1
services/web/frontend/js/shared/context/detach-context.js

@@ -29,6 +29,7 @@ const debugPdfDetach = getMeta('ol-debugPdfDetach')
 const SYSEND_CHANNEL = `detach-${getMeta('ol-project_id')}`
 
 export function DetachProvider({ children }) {
+  const [lastDetachedConnectedAt, setLastDetachedConnectedAt] = useState()
   const [role, setRole] = useState(() => getMeta('ol-detachRole') || null)
   const {
     addHandler: addEventHandler,
@@ -94,15 +95,33 @@ export function DetachProvider({ children }) {
     return () => window.removeEventListener('beforeunload', onBeforeUnload)
   }, [broadcastEvent])
 
+  useEffect(() => {
+    const updateLastDetachedConnectedAt = message => {
+      if (message.role === 'detached' && message.event === 'connected') {
+        setLastDetachedConnectedAt(new Date())
+      }
+    }
+    addEventHandler(updateLastDetachedConnectedAt)
+    return () => deleteEventHandler(updateLastDetachedConnectedAt)
+  }, [addEventHandler, deleteEventHandler])
+
   const value = useMemo(
     () => ({
       role,
       setRole,
       broadcastEvent,
+      lastDetachedConnectedAt,
       addEventHandler,
       deleteEventHandler,
     }),
-    [role, setRole, broadcastEvent, addEventHandler, deleteEventHandler]
+    [
+      role,
+      setRole,
+      broadcastEvent,
+      lastDetachedConnectedAt,
+      addEventHandler,
+      deleteEventHandler,
+    ]
   )
 
   return (

+ 1 - 1
services/web/frontend/js/shared/context/editor-context.js

@@ -155,7 +155,7 @@ export function EditorProvider({ children, settings }) {
 
 EditorProvider.propTypes = {
   children: PropTypes.any,
-  settings: PropTypes.any.isRequired,
+  settings: PropTypes.object,
 }
 
 export function useEditorContext(propTypes) {

+ 6 - 3
services/web/frontend/js/shared/context/ide-context.js

@@ -1,5 +1,6 @@
-import { createContext, useContext } from 'react'
+import { createContext, useContext, useState } from 'react'
 import PropTypes from 'prop-types'
+import { getMockIde } from './mock/mock-ide'
 
 const IdeContext = createContext()
 
@@ -20,11 +21,13 @@ export function useIdeContext() {
 }
 
 export function IdeProvider({ ide, children }) {
-  return <IdeContext.Provider value={ide}>{children}</IdeContext.Provider>
+  const [value] = useState(() => ide || getMockIde())
+
+  return <IdeContext.Provider value={value}>{children}</IdeContext.Provider>
 }
 IdeProvider.propTypes = {
   children: PropTypes.any.isRequired,
   ide: PropTypes.shape({
     $scope: PropTypes.object.isRequired,
-  }).isRequired,
+  }),
 }

+ 14 - 1
services/web/frontend/js/shared/context/layout-context.js

@@ -108,11 +108,13 @@ export function LayoutProvider({ children }) {
     isLinking: detachIsLinking,
     isLinked: detachIsLinked,
     role: detachRole,
+    isRedundant: detachIsRedundant,
   } = useDetachLayout()
 
   useEffect(() => {
     if (debugPdfDetach) {
       console.log('Layout Effect', {
+        detachIsRedundant,
         detachRole,
         detachIsLinking,
         detachIsLinked,
@@ -121,12 +123,23 @@ export function LayoutProvider({ children }) {
 
     if (detachRole !== 'detacher') return // not in a PDF detacher layout
 
+    if (detachIsRedundant) {
+      changeLayout('sideBySide')
+      return
+    }
+
     if (detachIsLinking || detachIsLinked) {
       // the tab is linked to a detached tab (or about to be linked); show
       // editor only
       changeLayout('flat', 'editor')
     }
-  }, [detachRole, detachIsLinking, detachIsLinked, changeLayout])
+  }, [
+    detachIsRedundant,
+    detachRole,
+    detachIsLinking,
+    detachIsLinked,
+    changeLayout,
+  ])
 
   const value = useMemo(
     () => ({

+ 35 - 9
services/web/frontend/js/shared/context/compile-context.js → services/web/frontend/js/shared/context/local-compile-context.js

@@ -13,7 +13,11 @@ import useScopeValueSetterOnly from '../hooks/use-scope-value-setter-only'
 import usePersistedState from '../hooks/use-persisted-state'
 import useAbortController from '../hooks/use-abort-controller'
 import DocumentCompiler from '../../features/pdf-preview/util/compiler'
-import { send, sendMBSampled } from '../../infrastructure/event-tracking'
+import {
+  send,
+  sendMBOnce,
+  sendMBSampled,
+} from '../../infrastructure/event-tracking'
 import {
   buildLogEntryAnnotations,
   handleLogFiles,
@@ -24,9 +28,9 @@ import { useProjectContext } from './project-context'
 import { useEditorContext } from './editor-context'
 import { buildFileList } from '../../features/pdf-preview/util/file-list'
 
-export const CompileContext = createContext()
+export const LocalCompileContext = createContext()
 
-CompileContext.Provider.propTypes = {
+export const CompileContextPropTypes = {
   value: PropTypes.shape({
     autoCompile: PropTypes.bool.isRequired,
     clearingCache: PropTypes.bool.isRequired,
@@ -52,6 +56,7 @@ CompileContext.Provider.propTypes = {
     setHighlights: PropTypes.func.isRequired,
     setPosition: PropTypes.func.isRequired,
     setShowLogs: PropTypes.func.isRequired,
+    toggleLogs: PropTypes.func.isRequired,
     setStopOnValidationError: PropTypes.func.isRequired,
     showLogs: PropTypes.bool.isRequired,
     stopOnValidationError: PropTypes.bool.isRequired,
@@ -62,7 +67,9 @@ CompileContext.Provider.propTypes = {
   }),
 }
 
-export function CompileProvider({ children }) {
+LocalCompileContext.Provider.propTypes = CompileContextPropTypes
+
+export function LocalCompileProvider({ children }) {
   const ide = useIdeContext()
 
   const { hasPremiumCompile, isProjectOwner } = useEditorContext()
@@ -111,6 +118,15 @@ export function CompileProvider({ children }) {
   // whether the logs should be visible
   const [showLogs, setShowLogs] = useState(false)
 
+  const toggleLogs = useCallback(() => {
+    setShowLogs(prev => {
+      if (!prev) {
+        sendMBOnce('ide-open-logs-once')
+      }
+      return !prev
+    })
+  }, [setShowLogs])
+
   // an error that occurred
   const [error, setError] = useState()
 
@@ -445,6 +461,7 @@ export function CompileProvider({ children }) {
       setHighlights,
       setPosition,
       setShowLogs,
+      toggleLogs,
       setStopOnValidationError,
       showLogs,
       startCompile,
@@ -492,20 +509,29 @@ export function CompileProvider({ children }) {
       firstRenderDone,
       setChangedAt,
       cleanupCompileResult,
+      setShowLogs,
+      toggleLogs,
     ]
   )
 
   return (
-    <CompileContext.Provider value={value}>{children}</CompileContext.Provider>
+    <LocalCompileContext.Provider value={value}>
+      {children}
+    </LocalCompileContext.Provider>
   )
 }
 
-CompileProvider.propTypes = {
+LocalCompileProvider.propTypes = {
   children: PropTypes.any,
 }
 
-export function useCompileContext(propTypes) {
-  const data = useContext(CompileContext)
-  PropTypes.checkPropTypes(propTypes, data, 'data', 'CompileContext.Provider')
+export function useLocalCompileContext(propTypes) {
+  const data = useContext(LocalCompileContext)
+  PropTypes.checkPropTypes(
+    propTypes,
+    data,
+    'data',
+    'LocalCompileContext.Provider'
+  )
   return data
 }

+ 65 - 0
services/web/frontend/js/shared/context/mock/mock-ide.js

@@ -0,0 +1,65 @@
+import getMeta from '../../../utils/meta'
+
+// When rendered without Angular, ide isn't defined. In that case we use
+// a mock object that only has the required properties to pass proptypes
+// checks and the values needed for the app. In the longer term, the mock
+// object will replace ide completely.
+export const getMockIde = () => {
+  return {
+    _id: getMeta('ol-project_id'),
+    $scope: {
+      $on: () => {},
+      $watch: () => {},
+      $applyAsync: () => {},
+      user: {},
+      project: {
+        _id: getMeta('ol-project_id'),
+        name: getMeta('ol-projectName'),
+        rootDocId: '',
+        members: [],
+        invites: [],
+        features: {
+          collaborators: 0,
+          compileGroup: 'standard',
+          trackChangesVisible: false,
+          references: false,
+          mendeley: false,
+          zotero: false,
+        },
+        publicAccessLevel: '',
+        tokens: {
+          readOnly: '',
+          readAndWrite: '',
+        },
+        owner: {
+          _id: '',
+          email: '',
+        },
+      },
+      state: { loading: false },
+      permissionsLevel: 'readOnly',
+      editor: {
+        sharejs_doc: null,
+        showSymbolPalette: false,
+        toggleSymbolPalette: () => {},
+      },
+      ui: {
+        view: 'pdf',
+        chatOpen: false,
+        reviewPanelOpen: false,
+        leftMenuShown: false,
+        pdfLayout: 'flat',
+      },
+      pdf: {
+        uncompiled: true,
+        logEntryAnnotations: {},
+      },
+      settings: { syntaxValidation: false, pdfViewer: 'pdfjs' },
+      hasLintingError: false,
+    },
+    editorManager: {
+      openDoc: () => {},
+      getCurrentDocId: () => {},
+    },
+  }
+}

+ 9 - 6
services/web/frontend/js/shared/context/root-context.js

@@ -4,7 +4,8 @@ import createSharedContext from 'react2angular-shared-context'
 import { UserProvider } from './user-context'
 import { IdeProvider } from './ide-context'
 import { EditorProvider } from './editor-context'
-import { CompileProvider } from './compile-context'
+import { LocalCompileProvider } from './local-compile-context'
+import { DetachCompileProvider } from './detach-compile-context'
 import { LayoutProvider } from './layout-context'
 import { DetachProvider } from './detach-context'
 import { ChatProvider } from '../../features/chat/context/chat-context'
@@ -22,9 +23,11 @@ export function ContextRoot({ children, ide, settings }) {
               <EditorProvider settings={settings}>
                 <DetachProvider>
                   <LayoutProvider>
-                    <CompileProvider>
-                      <ChatProvider>{children}</ChatProvider>
-                    </CompileProvider>
+                    <LocalCompileProvider>
+                      <DetachCompileProvider>
+                        <ChatProvider>{children}</ChatProvider>
+                      </DetachCompileProvider>
+                    </LocalCompileProvider>
                   </LayoutProvider>
                 </DetachProvider>
               </EditorProvider>
@@ -38,8 +41,8 @@ export function ContextRoot({ children, ide, settings }) {
 
 ContextRoot.propTypes = {
   children: PropTypes.any,
-  ide: PropTypes.any.isRequired,
-  settings: PropTypes.any.isRequired,
+  ide: PropTypes.object,
+  settings: PropTypes.object,
 }
 
 export const rootContext = createSharedContext(ContextRoot)

+ 2 - 2
services/web/frontend/js/shared/context/user-context.js

@@ -1,6 +1,6 @@
 import { createContext, useContext } from 'react'
 import PropTypes from 'prop-types'
-import useScopeValue from '../hooks/use-scope-value'
+import getMeta from '../../utils/meta'
 
 export const UserContext = createContext()
 
@@ -18,7 +18,7 @@ UserContext.Provider.propTypes = {
 }
 
 export function UserProvider({ children }) {
-  const [user] = useScopeValue('user', true)
+  const user = getMeta('ol-user')
 
   return <UserContext.Provider value={user}>{children}</UserContext.Provider>
 }

+ 13 - 25
services/web/frontend/js/shared/hooks/use-callback-handlers.js

@@ -1,33 +1,21 @@
-import { useCallback, useState } from 'react'
+import { useCallback, useRef } from 'react'
 
 export default function useCallbackHandlers() {
-  const [handlers, setHandlers] = useState(new Set())
+  const handlersRef = useRef(new Set())
 
-  const addHandler = useCallback(
-    handler => {
-      setHandlers(prev => new Set(prev.add(handler)))
-    },
-    [setHandlers]
-  )
+  const addHandler = useCallback(handler => {
+    handlersRef.current.add(handler)
+  }, [])
 
-  const deleteHandler = useCallback(
-    handler => {
-      setHandlers(prev => {
-        prev.delete(handler)
-        return new Set(prev)
-      })
-    },
-    [setHandlers]
-  )
+  const deleteHandler = useCallback(handler => {
+    handlersRef.current.delete(handler)
+  }, [])
 
-  const callHandlers = useCallback(
-    (...args) => {
-      for (const handler of handlers) {
-        handler(...args)
-      }
-    },
-    [handlers]
-  )
+  const callHandlers = useCallback((...args) => {
+    for (const handler of handlersRef.current) {
+      handler(...args)
+    }
+  }, [])
 
   return { addHandler, deleteHandler, callHandlers }
 }

+ 22 - 4
services/web/frontend/js/shared/hooks/use-detach-layout.js

@@ -20,6 +20,10 @@ export default function useDetachLayout() {
   // isLinked: when the tab is linked to another tab (of different role)
   const [isLinked, setIsLinked] = useState(false)
 
+  // isRedundant: when a second detacher tab is opened, the first becomes
+  // redundant
+  const [isRedundant, setIsRedundant] = useState(false)
+
   const uiTimeoutRef = useRef()
 
   useEffect(() => {
@@ -76,12 +80,24 @@ export default function useDetachLayout() {
   }, [setRole, setIsLinked, broadcastEvent])
 
   const detach = useCallback(() => {
+    setIsRedundant(false)
     setRole('detacher')
     setIsLinking(true)
 
     window.open(buildUrlWithDetachRole('detached').toString(), '_blank')
   }, [setRole, setIsLinking])
 
+  const handleEventForDetacherFromDetacher = useCallback(() => {
+    if (debugPdfDetach) {
+      console.log(
+        'Duplicate detacher detected, turning into a regular editor again'
+      )
+    }
+    setIsRedundant(true)
+    setIsLinked(false)
+    setRole(null)
+  }, [setRole, setIsLinked])
+
   const handleEventForDetacherFromDetached = useCallback(
     message => {
       switch (message.event) {
@@ -122,7 +138,7 @@ export default function useDetachLayout() {
     [setIsLinked, broadcastEvent]
   )
 
-  const handleEventFromSelf = useCallback(
+  const handleEventForDetachedFromDetached = useCallback(
     message => {
       switch (message.event) {
         case 'closed':
@@ -137,7 +153,7 @@ export default function useDetachLayout() {
     message => {
       if (role === 'detacher') {
         if (message.role === 'detacher') {
-          handleEventFromSelf(message)
+          handleEventForDetacherFromDetacher(message)
         } else if (message.role === 'detached') {
           handleEventForDetacherFromDetached(message)
         }
@@ -145,15 +161,16 @@ export default function useDetachLayout() {
         if (message.role === 'detacher') {
           handleEventForDetachedFromDetacher(message)
         } else if (message.role === 'detached') {
-          handleEventFromSelf(message)
+          handleEventForDetachedFromDetached(message)
         }
       }
     },
     [
       role,
+      handleEventForDetacherFromDetacher,
       handleEventForDetacherFromDetached,
       handleEventForDetachedFromDetacher,
-      handleEventFromSelf,
+      handleEventForDetachedFromDetached,
     ]
   )
 
@@ -168,5 +185,6 @@ export default function useDetachLayout() {
     isLinked,
     isLinking,
     role,
+    isRedundant,
   }
 }

+ 22 - 0
services/web/frontend/js/shared/hooks/use-detach-state-watcher.js

@@ -0,0 +1,22 @@
+import { useEffect } from 'react'
+import useDetachState from './use-detach-state'
+
+export default function useDetachStateWatcher(
+  key,
+  stateValue,
+  senderRole,
+  targetRole
+) {
+  const [value, setValue] = useDetachState(
+    key,
+    stateValue,
+    senderRole,
+    targetRole
+  )
+
+  useEffect(() => {
+    setValue(stateValue)
+  }, [setValue, stateValue])
+
+  return [value, setValue]
+}

+ 17 - 3
services/web/frontend/js/shared/hooks/use-detach-state.js

@@ -12,16 +12,30 @@ export default function useDetachState(
 ) {
   const [value, setValue] = useState(defaultValue)
 
-  const { role, broadcastEvent, addEventHandler, deleteEventHandler } =
-    useDetachContext()
+  const {
+    role,
+    broadcastEvent,
+    lastDetachedConnectedAt,
+    addEventHandler,
+    deleteEventHandler,
+  } = useDetachContext()
 
   const eventName = `state-${key}`
 
+  // lastDetachedConnectedAt is added as a dependency in order to re-broadcast
+  // all states when a new detached tab connects
   useEffect(() => {
     if (role === senderRole) {
       broadcastEvent(eventName, { value })
     }
-  }, [role, senderRole, eventName, value, broadcastEvent])
+  }, [
+    role,
+    senderRole,
+    eventName,
+    value,
+    broadcastEvent,
+    lastDetachedConnectedAt,
+  ])
 
   const handleStateEvent = useCallback(
     message => {

+ 2 - 2
services/web/frontend/js/shared/hooks/use-persisted-state.js

@@ -37,7 +37,7 @@ function usePersistedState(key, defaultValue, listen = false) {
         if (event.key === key) {
           // note: this value is read via getItem rather than from event.newValue
           // because getItem handles deserializing the JSON that's stored in localStorage.
-          setValue(localStorage.getItem(key))
+          setValue(localStorage.getItem(key) ?? defaultValue)
         }
       }
 
@@ -47,7 +47,7 @@ function usePersistedState(key, defaultValue, listen = false) {
         window.removeEventListener('storage', listener)
       }
     }
-  }, [key, listen])
+  }, [key, listen, defaultValue])
 
   return [value, updateFunction]
 }

+ 1 - 1
services/web/frontend/stories/pdf-preview.stories.js

@@ -10,7 +10,7 @@ import { buildFileList } from '../js/features/pdf-preview/util/file-list'
 import PdfLogsViewer from '../js/features/pdf-preview/components/pdf-logs-viewer'
 import PdfPreviewError from '../js/features/pdf-preview/components/pdf-preview-error'
 import PdfPreviewHybridToolbar from '../js/features/pdf-preview/components/pdf-preview-hybrid-toolbar'
-import { useCompileContext } from '../js/shared/context/compile-context'
+import { useDetachCompileContext as useCompileContext } from '../js/shared/context/detach-compile-context'
 import {
   dispatchDocChanged,
   mockBuildFile,

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

@@ -1580,6 +1580,7 @@
   "project_layout_sharing_submission": "Project Layout, Sharing, and Submission",
   "pdf_in_separate_tab": "PDF in separate tab",
   "tab_no_longer_connected": "This tab is no longer connected with the editor",
+  "tab_connecting": "Connecting with the editor",
   "redirect_to_editor": "Redirect to editor",
   "layout_processing": "Layout processing",
   "show_in_code": "Show in code",

+ 9 - 6
services/web/test/frontend/features/chat/components/chat-pane.test.js

@@ -15,24 +15,27 @@ import { stubMathJax, tearDownMathJaxStubs } from './stubs'
 import sinon from 'sinon'
 
 describe('<ChatPane />', function () {
+  const user = {
+    id: 'fake_user',
+    first_name: 'fake_user_first_name',
+    email: 'fake@example.com',
+  }
+
   beforeEach(function () {
     this.clock = sinon.useFakeTimers({
       toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'],
     })
+    window.metaAttributesCache = new Map()
+    window.metaAttributesCache.set('ol-user', user)
   })
 
   afterEach(function () {
     this.clock.runAll()
     this.clock.restore()
     fetchMock.reset()
+    window.metaAttributesCache = new Map()
   })
 
-  const user = {
-    id: 'fake_user',
-    first_name: 'fake_user_first_name',
-    email: 'fake@example.com',
-  }
-
   const testMessages = [
     {
       id: 'msg_1',

+ 20 - 19
services/web/test/frontend/features/chat/context/chat-context.test.js

@@ -25,10 +25,15 @@ describe('ChatContext', function () {
     cleanUpContext()
 
     stubMathJax()
+
+    window.metaAttributesCache = new Map()
+    window.metaAttributesCache.set('ol-user', user)
   })
 
   afterEach(function () {
     tearDownMathJaxStubs()
+
+    window.metaAttributesCache = new Map()
   })
 
   describe('socket connection', function () {
@@ -42,7 +47,7 @@ describe('ChatContext', function () {
 
     it('subscribes when mounted', function () {
       const socket = new EventEmitter()
-      renderChatContextHook({ user, socket })
+      renderChatContextHook({ socket })
 
       // Assert that there is 1 listener
       expect(socket.rawListeners('new-chat-message').length).to.equal(1)
@@ -50,7 +55,7 @@ describe('ChatContext', function () {
 
     it('unsubscribes when unmounted', function () {
       const socket = new EventEmitter()
-      const { unmount } = renderChatContextHook({ user, socket })
+      const { unmount } = renderChatContextHook({ socket })
 
       unmount()
 
@@ -62,7 +67,6 @@ describe('ChatContext', function () {
       // Mock socket: we only need to emit events, not mock actual connections
       const socket = new EventEmitter()
       const { result, waitForNextUpdate } = renderChatContextHook({
-        user,
         socket,
       })
 
@@ -93,7 +97,6 @@ describe('ChatContext', function () {
     it("doesn't add received messages from the current user if a message was just sent", async function () {
       const socket = new EventEmitter()
       const { result, waitForNextUpdate } = renderChatContextHook({
-        user,
         socket,
       })
 
@@ -123,7 +126,6 @@ describe('ChatContext', function () {
     it('adds the new message from the current user if another message was received after sending', async function () {
       const socket = new EventEmitter()
       const { result, waitForNextUpdate } = renderChatContextHook({
-        user,
         socket,
       })
 
@@ -187,7 +189,7 @@ describe('ChatContext', function () {
     })
 
     it('adds messages to the list', async function () {
-      const { result, waitForNextUpdate } = renderChatContextHook({ user })
+      const { result, waitForNextUpdate } = renderChatContextHook({})
 
       result.current.loadInitialMessages()
       await waitForNextUpdate()
@@ -196,7 +198,7 @@ describe('ChatContext', function () {
     })
 
     it("won't load messages a second time", async function () {
-      const { result, waitForNextUpdate } = renderChatContextHook({ user })
+      const { result, waitForNextUpdate } = renderChatContextHook({})
 
       result.current.loadInitialMessages()
       await waitForNextUpdate()
@@ -211,7 +213,7 @@ describe('ChatContext', function () {
     it('provides an error on failure', async function () {
       fetchMock.reset()
       fetchMock.get('express:/project/:projectId/messages', 500)
-      const { result, waitForNextUpdate } = renderChatContextHook({ user })
+      const { result, waitForNextUpdate } = renderChatContextHook({})
 
       result.current.loadInitialMessages()
       await waitForNextUpdate()
@@ -233,7 +235,7 @@ describe('ChatContext', function () {
         },
       ])
 
-      const { result, waitForNextUpdate } = renderChatContextHook({ user })
+      const { result, waitForNextUpdate } = renderChatContextHook({})
 
       result.current.loadMoreMessages()
       await waitForNextUpdate()
@@ -267,7 +269,7 @@ describe('ChatContext', function () {
           { overwriteRoutes: false }
         )
 
-      const { result, waitForNextUpdate } = renderChatContextHook({ user })
+      const { result, waitForNextUpdate } = renderChatContextHook({})
 
       result.current.loadMoreMessages()
       await waitForNextUpdate()
@@ -297,7 +299,7 @@ describe('ChatContext', function () {
         createMessages(49, user)
       )
 
-      const { result, waitForNextUpdate } = renderChatContextHook({ user })
+      const { result, waitForNextUpdate } = renderChatContextHook({})
 
       result.current.loadMoreMessages()
       await waitForNextUpdate()
@@ -322,7 +324,6 @@ describe('ChatContext', function () {
 
       const socket = new EventEmitter()
       const { result, waitForNextUpdate } = renderChatContextHook({
-        user,
         socket,
       })
 
@@ -367,7 +368,7 @@ describe('ChatContext', function () {
     it('provides an error on failures', async function () {
       fetchMock.reset()
       fetchMock.get('express:/project/:projectId/messages', 500)
-      const { result, waitForNextUpdate } = renderChatContextHook({ user })
+      const { result, waitForNextUpdate } = renderChatContextHook({})
 
       result.current.loadMoreMessages()
       await waitForNextUpdate()
@@ -387,7 +388,7 @@ describe('ChatContext', function () {
     })
 
     it('optimistically adds the message to the list', function () {
-      const { result } = renderChatContextHook({ user })
+      const { result } = renderChatContextHook({})
 
       result.current.sendMessage('sent message')
 
@@ -397,7 +398,7 @@ describe('ChatContext', function () {
     })
 
     it('POSTs the message to the backend', function () {
-      const { result } = renderChatContextHook({ user })
+      const { result } = renderChatContextHook({})
 
       result.current.sendMessage('sent message')
 
@@ -409,7 +410,7 @@ describe('ChatContext', function () {
     })
 
     it("doesn't send if the content is empty", function () {
-      const { result } = renderChatContextHook({ user })
+      const { result } = renderChatContextHook({})
 
       result.current.sendMessage('')
 
@@ -426,7 +427,7 @@ describe('ChatContext', function () {
       fetchMock
         .get('express:/project/:projectId/messages', [])
         .postOnce('express:/project/:projectId/messages', 500)
-      const { result, waitForNextUpdate } = renderChatContextHook({ user })
+      const { result, waitForNextUpdate } = renderChatContextHook({})
 
       result.current.sendMessage('sent message')
       await waitForNextUpdate()
@@ -444,7 +445,7 @@ describe('ChatContext', function () {
 
     it('increments unreadMessageCount when a new message is received', function () {
       const socket = new EventEmitter()
-      const { result } = renderChatContextHook({ user, socket })
+      const { result } = renderChatContextHook({ socket })
 
       // Receive a new message from the socket
       socket.emit('new-chat-message', {
@@ -459,7 +460,7 @@ describe('ChatContext', function () {
 
     it('resets unreadMessageCount when markMessagesAsRead is called', function () {
       const socket = new EventEmitter()
-      const { result } = renderChatContextHook({ user, socket })
+      const { result } = renderChatContextHook({ socket })
 
       // Receive a new message from the socket, incrementing unreadMessageCount
       // by 1

+ 0 - 6
services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js

@@ -18,7 +18,6 @@ describe('<LayoutDropdownButton />', function () {
   beforeEach(function () {
     openStub = sinon.stub(window, 'open')
     window.metaAttributesCache = new Map()
-    fetchMock.post('express:/project/:projectId/compile/stop', () => 204)
   })
 
   afterEach(function () {
@@ -101,11 +100,6 @@ describe('<LayoutDropdownButton />', function () {
       screen.getByText('Layout processing')
     })
 
-    it('should stop compile when detaching', function () {
-      expect(fetchMock.called('express:/project/:projectId/compile/stop')).to.be
-        .true
-    })
-
     it('should record event', function () {
       sinon.assert.calledWith(eventTrackingSpy.sendMB, 'project-layout-detach')
     })

+ 3 - 0
services/web/test/frontend/features/file-tree/components/file-tree-root.test.js

@@ -15,6 +15,8 @@ describe('<FileTreeRoot/>', function () {
 
   beforeEach(function () {
     global.requestAnimationFrame = sinon.stub()
+    window.metaAttributesCache = new Map()
+    window.metaAttributesCache.set('ol-user', { id: 'user1' })
   })
 
   afterEach(function () {
@@ -24,6 +26,7 @@ describe('<FileTreeRoot/>', function () {
     onInit.reset()
     cleanUpContext()
     global.localStorage.clear()
+    window.metaAttributesCache = new Map()
   })
 
   it('renders', function () {

+ 6 - 0
services/web/test/frontend/features/file-tree/flows/context-menu.test.js

@@ -12,10 +12,16 @@ describe('FileTree Context Menu Flow', function () {
   const onSelect = sinon.stub()
   const onInit = sinon.stub()
 
+  beforeEach(function () {
+    window.metaAttributesCache = new Map()
+    window.metaAttributesCache.set('ol-user', { id: 'user1' })
+  })
+
   afterEach(function () {
     onSelect.reset()
     onInit.reset()
     cleanUpContext()
+    window.metaAttributesCache = new Map()
   })
 
   it('opens on contextMenu event', async function () {

+ 3 - 0
services/web/test/frontend/features/file-tree/flows/create-folder.test.js

@@ -16,6 +16,8 @@ describe('FileTree Create Folder Flow', function () {
 
   beforeEach(function () {
     global.requestAnimationFrame = sinon.stub()
+    window.metaAttributesCache = new Map()
+    window.metaAttributesCache.set('ol-user', { id: 'user1' })
   })
 
   afterEach(function () {
@@ -24,6 +26,7 @@ describe('FileTree Create Folder Flow', function () {
     onSelect.reset()
     onInit.reset()
     cleanUpContext()
+    window.metaAttributesCache = new Map()
   })
 
   it('add to root when no files are selected', async function () {

+ 6 - 0
services/web/test/frontend/features/file-tree/flows/delete-entity.test.js

@@ -14,11 +14,17 @@ describe('FileTree Delete Entity Flow', function () {
   const onSelect = sinon.stub()
   const onInit = sinon.stub()
 
+  beforeEach(function () {
+    window.metaAttributesCache = new Map()
+    window.metaAttributesCache.set('ol-user', { id: 'user1' })
+  })
+
   afterEach(function () {
     fetchMock.restore()
     onSelect.reset()
     onInit.reset()
     cleanUpContext()
+    window.metaAttributesCache = new Map()
   })
 
   describe('single entity', function () {

+ 3 - 0
services/web/test/frontend/features/file-tree/flows/rename-entity.test.js

@@ -16,6 +16,8 @@ describe('FileTree Rename Entity Flow', function () {
 
   beforeEach(function () {
     global.requestAnimationFrame = sinon.stub()
+    window.metaAttributesCache = new Map()
+    window.metaAttributesCache.set('ol-user', { id: 'user1' })
   })
 
   afterEach(function () {
@@ -24,6 +26,7 @@ describe('FileTree Rename Entity Flow', function () {
     onSelect.reset()
     onInit.reset()
     cleanUpContext()
+    window.metaAttributesCache = new Map()
   })
 
   beforeEach(function () {

+ 1 - 20
services/web/test/frontend/features/pdf-preview/components/detach-compile-button.test.js

@@ -1,6 +1,6 @@
 import DetachCompileButton from '../../../../../frontend/js/features/pdf-preview/components/detach-compile-button'
 import { renderWithEditorContext } from '../../../helpers/render-with-context'
-import { screen, fireEvent } from '@testing-library/react'
+import { screen } from '@testing-library/react'
 import sysendTestHelper from '../../../helpers/sysend'
 import { expect } from 'chai'
 
@@ -48,23 +48,4 @@ describe('<DetachCompileButton/>', function () {
       })
     ).to.not.exist
   })
-
-  it('send compile clicks via detached action', async function () {
-    window.metaAttributesCache.set('ol-detachRole', 'detacher')
-    renderWithEditorContext(<DetachCompileButton />)
-    sysendTestHelper.receiveMessage({
-      role: 'detached',
-      event: 'connected',
-    })
-
-    const compileButton = await screen.getByRole('button', {
-      name: 'Recompile',
-    })
-    fireEvent.click(compileButton)
-    expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
-      role: 'detacher',
-      event: 'action-start-compile',
-      data: { args: [] },
-    })
-  })
 })

+ 9 - 3
services/web/test/frontend/features/pdf-preview/components/pdf-logs-entries.test.js

@@ -17,9 +17,7 @@ describe('<PdfLogsEntries/>', function () {
       message: 'LaTeX Error',
       content: 'See the LaTeX manual',
       raw: '',
-      ruleId: 'latex_error',
-      humanReadableHint: '',
-      humanReadableHintComponent: <></>,
+      ruleId: 'hint_misplaced_alignment_tab_character',
       key: '',
     },
   ]
@@ -36,6 +34,14 @@ describe('<PdfLogsEntries/>', function () {
     fileTreeManager.findEntityByPath.resetHistory()
   })
 
+  it('displays human readable hint', async function () {
+    renderWithEditorContext(<PdfLogsEntries entries={logEntries} />, {
+      fileTreeManager,
+      editorManager,
+    })
+    screen.getByText(/You have placed an alignment tab character/)
+  })
+
   it('opens doc on click', async function () {
     renderWithEditorContext(<PdfLogsEntries entries={logEntries} />, {
       fileTreeManager,

+ 70 - 0
services/web/test/frontend/features/pdf-preview/components/pdf-preview-detached-root.test.js

@@ -0,0 +1,70 @@
+import { expect } from 'chai'
+import { render, screen, fireEvent } from '@testing-library/react'
+import sysendTestHelper from '../../../helpers/sysend'
+import PdfPreviewDetachedRoot from '../../../../../frontend/js/features/pdf-preview/components/pdf-preview-detached-root'
+
+describe('<PdfPreviewDetachedRoot/>', function () {
+  beforeEach(function () {
+    const user = { id: 'user1' }
+    window.user = user
+
+    window.metaAttributesCache = new Map()
+    window.metaAttributesCache.set('ol-user', user)
+    window.metaAttributesCache.set('ol-project_id', 'project1')
+    window.metaAttributesCache.set('ol-detachRole', 'detached')
+    window.metaAttributesCache.set('ol-projectName', 'Project Name')
+  })
+
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  it('syncs compiling state', async function () {
+    render(<PdfPreviewDetachedRoot />)
+
+    sysendTestHelper.receiveMessage({
+      role: 'detacher',
+      event: 'connected',
+    })
+
+    sysendTestHelper.receiveMessage({
+      role: 'detacher',
+      event: 'state-compiling',
+      data: { value: true },
+    })
+    await screen.findByRole('button', { name: 'Compiling…' })
+    expect(screen.queryByRole('button', { name: 'Recompile' })).to.not.exist
+
+    sysendTestHelper.receiveMessage({
+      role: 'detacher',
+      event: 'state-compiling',
+      data: { value: false },
+    })
+    await screen.findByRole('button', { name: 'Recompile' })
+    expect(screen.queryByRole('button', { name: 'Compiling…' })).to.not.exist
+  })
+
+  it('sends a clear cache request when the button is pressed', async function () {
+    render(<PdfPreviewDetachedRoot />)
+
+    sysendTestHelper.receiveMessage({
+      role: 'detacher',
+      event: 'state-showLogs',
+      data: { value: true },
+    })
+
+    const clearCacheButton = await screen.findByRole('button', {
+      name: 'Clear cached files',
+    })
+    expect(clearCacheButton.hasAttribute('disabled')).to.be.false
+
+    fireEvent.click(clearCacheButton)
+    expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
+      role: 'detached',
+      event: 'action-clearCache',
+      data: {
+        args: [],
+      },
+    })
+  })
+})

+ 53 - 5
services/web/test/frontend/features/pdf-preview/components/pdf-preview-hybrid-toolbar.test.js

@@ -1,10 +1,21 @@
+import sinon from 'sinon'
 import PdfPreviewHybridToolbar from '../../../../../frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar'
 import { renderWithEditorContext } from '../../../helpers/render-with-context'
 import { screen } from '@testing-library/react'
+import sysendTestHelper from '../../../helpers/sysend'
 
 describe('<PdfPreviewHybridToolbar/>', function () {
+  let clock
+
+  beforeEach(function () {
+    clock = sinon.useFakeTimers()
+  })
+
   afterEach(function () {
     window.metaAttributesCache = new Map()
+    sysendTestHelper.resetHistory()
+    clock.runAll()
+    clock.restore()
   })
 
   it('shows normal mode', async function () {
@@ -15,12 +26,49 @@ describe('<PdfPreviewHybridToolbar/>', function () {
     })
   })
 
-  it('shows orphan mode', async function () {
-    window.metaAttributesCache.set('ol-detachRole', 'detached')
-    renderWithEditorContext(<PdfPreviewHybridToolbar />)
+  describe('orphan mode', async function () {
+    it('shows connecting message  on load', async function () {
+      window.metaAttributesCache.set('ol-detachRole', 'detached')
+      renderWithEditorContext(<PdfPreviewHybridToolbar />)
 
-    await screen.getByRole('button', {
-      name: 'Redirect to editor',
+      await screen.getByText(/Connecting with the editor/)
+    })
+
+    it('shows compile UI when connected', async function () {
+      window.metaAttributesCache.set('ol-detachRole', 'detached')
+      renderWithEditorContext(<PdfPreviewHybridToolbar />)
+      sysendTestHelper.receiveMessage({
+        role: 'detacher',
+        event: 'connected',
+      })
+      await screen.getByRole('button', {
+        name: 'Recompile',
+      })
+    })
+
+    it('shows connecting message when disconnected', async function () {
+      window.metaAttributesCache.set('ol-detachRole', 'detached')
+      renderWithEditorContext(<PdfPreviewHybridToolbar />)
+      sysendTestHelper.receiveMessage({
+        role: 'detacher',
+        event: 'connected',
+      })
+      sysendTestHelper.receiveMessage({
+        role: 'detacher',
+        event: 'closed',
+      })
+
+      await screen.getByText(/Connecting with the editor/)
+    })
+
+    it('shows redirect button after timeout', async function () {
+      window.metaAttributesCache.set('ol-detachRole', 'detached')
+      renderWithEditorContext(<PdfPreviewHybridToolbar />)
+      clock.tick(6000)
+
+      await screen.getByRole('button', {
+        name: 'Redirect to editor',
+      })
     })
   })
 })

+ 10 - 3
services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js

@@ -350,7 +350,10 @@ describe('<PdfPreview/>', function () {
 
     // click the button
     clearCacheButton.click()
-    expect(clearCacheButton.hasAttribute('disabled')).to.be.true
+    await waitFor(() => {
+      expect(clearCacheButton.hasAttribute('disabled')).to.be.true
+    })
+
     await waitFor(() => {
       expect(clearCacheButton.hasAttribute('disabled')).to.be.false
     })
@@ -382,7 +385,7 @@ describe('<PdfPreview/>', function () {
     expect(clearCacheButton.hasAttribute('disabled')).to.be.false
 
     mockValidPdf()
-    mockClearCache()
+    const finishClearCache = mockDelayed(mockClearCache)
 
     const recompileFromScratch = screen.getByRole('menuitem', {
       name: 'Recompile from scratch',
@@ -390,7 +393,11 @@ describe('<PdfPreview/>', function () {
     })
     recompileFromScratch.click()
 
-    expect(clearCacheButton.hasAttribute('disabled')).to.be.true
+    await waitFor(() => {
+      expect(clearCacheButton.hasAttribute('disabled')).to.be.true
+    })
+
+    finishClearCache()
 
     // wait for compile to finish
     await screen.findByRole('button', { name: 'Compiling…' })

+ 82 - 95
services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js

@@ -7,7 +7,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
 import fs from 'fs'
 import path from 'path'
 import { expect } from 'chai'
-import { useCompileContext } from '../../../../../frontend/js/shared/context/compile-context'
+import { useDetachCompileContext as useCompileContext } from '../../../../../frontend/js/shared/context/detach-compile-context'
 import { useFileTreeData } from '../../../../../frontend/js/shared/context/file-tree-data-context'
 import { useEffect } from 'react'
 
@@ -122,7 +122,6 @@ const WithSelectedEntities = ({ mockSelectedEntities = [] }) => {
 
   return null
 }
-
 describe('<PdfSynctexControls/>', function () {
   beforeEach(function () {
     window.metaAttributesCache = new Map()
@@ -185,7 +184,6 @@ describe('<PdfSynctexControls/>', function () {
         .true
     })
   })
-
   it('disables button when multiple entities are selected', async function () {
     renderWithEditorContext(
       <>
@@ -236,9 +234,14 @@ describe('<PdfSynctexControls/>', function () {
     })
 
     it('does not have go to PDF location button nor arrow icon', async function () {
-      const { container } = renderWithEditorContext(<PdfSynctexControls />, {
-        scope,
-      })
+      const { container } = renderWithEditorContext(
+        <>
+          <WithPosition mockPosition={mockPosition} />
+          <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
+          <PdfSynctexControls />
+        </>,
+        { scope }
+      )
 
       expect(
         await screen.queryByRole('button', {
@@ -249,7 +252,7 @@ describe('<PdfSynctexControls/>', function () {
       expect(container.querySelector('.synctex-control-icon')).to.not.exist
     })
 
-    it('send go to PDF location action', async function () {
+    it('send set highlights action', async function () {
       renderWithEditorContext(
         <>
           <WithPosition mockPosition={mockPosition} />
@@ -258,7 +261,6 @@ describe('<PdfSynctexControls/>', function () {
         </>,
         { scope }
       )
-
       sysendTestHelper.resetHistory()
 
       const syncToPdfButton = await screen.findByRole('button', {
@@ -273,20 +275,28 @@ describe('<PdfSynctexControls/>', function () {
         })
       )
 
+      expect(syncToPdfButton.disabled).to.be.false
+
       fireEvent.click(syncToPdfButton)
 
-      // the button is only disabled when the state is updated via sysend
-      expect(syncToPdfButton.disabled).to.be.false
+      expect(syncToPdfButton.disabled).to.be.true
 
+      await waitFor(() => {
+        expect(fetchMock.called('express:/project/:projectId/sync/code')).to.be
+          .true
+      })
+
+      // synctex is called locally and the result are broadcast for the detached
+      // tab
       expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
         role: 'detacher',
-        event: 'action-go-to-pdf-location',
-        data: { args: ['file=&line=101&column=10'] },
+        event: 'action-setHighlights',
+        data: { args: [mockHighlights] },
       })
     })
 
-    it('update inflight state', async function () {
-      const { container } = renderWithEditorContext(
+    it('reacts to sync to code action', async function () {
+      renderWithEditorContext(
         <>
           <WithPosition mockPosition={mockPosition} />
           <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
@@ -294,39 +304,24 @@ describe('<PdfSynctexControls/>', function () {
         </>,
         { scope }
       )
-      sysendTestHelper.resetHistory()
 
-      const syncToPdfButton = await screen.findByRole('button', {
-        name: 'Go to code location in PDF',
+      await waitFor(() => {
+        expect(fetchMock.called('express:/project/:projectId/compile')).to.be
+          .true
       })
 
-      // mock editor cursor position update
-      fireEvent(
-        window,
-        new CustomEvent('cursor:editor:update', {
-          detail: { row: 100, column: 10 },
-        })
-      )
-
       sysendTestHelper.receiveMessage({
         role: 'detached',
-        event: 'state-sync-to-pdf-inflight',
-        data: { value: true },
+        event: 'action-sync-to-code',
+        data: {
+          args: [mockPosition],
+        },
       })
-      expect(syncToPdfButton.disabled).to.be.true
-      expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
-        1
-      )
 
-      sysendTestHelper.receiveMessage({
-        role: 'detached',
-        event: 'state-sync-to-pdf-inflight',
-        data: { value: false },
+      await waitFor(() => {
+        expect(fetchMock.called('express:/project/:projectId/sync/pdf')).to.be
+          .true
       })
-      expect(syncToPdfButton.disabled).to.be.false
-      expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
-        0
-      )
     })
   })
 
@@ -336,9 +331,13 @@ describe('<PdfSynctexControls/>', function () {
     })
 
     it('does not have go to code location button nor arrow icon', async function () {
-      const { container } = renderWithEditorContext(<PdfSynctexControls />, {
-        scope,
-      })
+      const { container } = renderWithEditorContext(
+        <>
+          <WithPosition mockPosition={mockPosition} />
+          <PdfSynctexControls />
+        </>,
+        { scope }
+      )
 
       expect(
         await screen.queryByRole('button', {
@@ -349,102 +348,90 @@ describe('<PdfSynctexControls/>', function () {
       expect(container.querySelector('.synctex-control-icon')).to.not.exist
     })
 
-    it('send go to code line action and update inflight state', async function () {
+    it('send go to code line action', async function () {
       const { container } = renderWithEditorContext(
         <>
           <WithPosition mockPosition={mockPosition} />
-          <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
           <PdfSynctexControls />
         </>,
         { scope }
       )
-      sysendTestHelper.resetHistory()
 
       const syncToCodeButton = await screen.findByRole('button', {
         name: /Go to PDF location in code/,
       })
+      expect(syncToCodeButton.disabled).to.be.true
 
-      sysendTestHelper.resetHistory()
-
+      sysendTestHelper.receiveMessage({
+        role: 'detached',
+        event: 'state-has-single-selected-doc',
+        data: { value: true },
+      })
       expect(syncToCodeButton.disabled).to.be.false
-      expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
-        0
-      )
+
+      sysendTestHelper.resetHistory()
 
       fireEvent.click(syncToCodeButton)
 
-      expect(syncToCodeButton.disabled).to.be.true
+      // the button is only disabled when the state is updated via sysend
+      expect(syncToCodeButton.disabled).to.be.false
       expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
-        1
+        0
       )
 
-      await waitFor(() => {
-        expect(fetchMock.called('express:/project/:projectId/sync/pdf')).to.be
-          .true
-      })
-
       expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
         role: 'detached',
-        event: 'action-go-to-code-line',
-        data: { args: ['main.tex', 100] },
+        event: 'action-sync-to-code',
+        data: {
+          args: [mockPosition, 72],
+        },
       })
     })
 
-    it('sends PDF exists state', async function () {
-      renderWithEditorContext(
+    it('update inflight state', async function () {
+      const { container } = renderWithEditorContext(
         <>
           <WithPosition mockPosition={mockPosition} />
-          <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
           <PdfSynctexControls />
         </>,
         { scope }
       )
-      sysendTestHelper.resetHistory()
-
-      await waitFor(() => {
-        expect(fetchMock.called('express:/project/:projectId/compile')).to.be
-          .true
-      })
-      expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
+      sysendTestHelper.receiveMessage({
         role: 'detached',
-        event: 'state-pdf-exists',
+        event: 'state-has-single-selected-doc',
         data: { value: true },
       })
-    })
-
-    it('reacts to go to PDF location action', async function () {
-      renderWithEditorContext(
-        <>
-          <WithPosition mockPosition={mockPosition} />
-          <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
-          <PdfSynctexControls />
-        </>,
-        { scope }
-      )
-      sysendTestHelper.resetHistory()
 
-      await waitFor(() => {
-        expect(fetchMock.called('express:/project/:projectId/compile')).to.be
-          .true
+      const syncToCodeButton = await screen.findByRole('button', {
+        name: /Go to PDF location in code/,
       })
-      sysendTestHelper.spy.broadcast.resetHistory()
+
+      expect(syncToCodeButton.disabled).to.be.false
+      expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
+        0
+      )
 
       sysendTestHelper.receiveMessage({
         role: 'detacher',
-        event: 'action-go-to-pdf-location',
-        data: { args: ['file=&line=101&column=10'] },
+        event: 'state-sync-to-code-inflight',
+        data: { value: true },
       })
 
-      await waitFor(() => {
-        expect(fetchMock.called('express:/project/:projectId/sync/code')).to.be
-          .true
-      })
+      expect(syncToCodeButton.disabled).to.be.true
+      expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
+        1
+      )
 
-      expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
-        role: 'detached',
-        event: 'state-sync-to-pdf-inflight',
+      sysendTestHelper.receiveMessage({
+        role: 'detacher',
+        event: 'state-sync-to-code-inflight',
         data: { value: false },
       })
+
+      expect(syncToCodeButton.disabled).to.be.false
+      expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
+        0
+      )
     })
   })
 })

+ 9 - 2
services/web/test/frontend/features/pdf-preview/utils/mock-compile.js

@@ -82,8 +82,15 @@ export const mockValidationProblems = validationProblems =>
     },
   })
 
-export const mockClearCache = () =>
-  fetchMock.delete('express:/project/:projectId/output', 204)
+export const mockClearCache = (delayPromise = Promise.resolve()) =>
+  fetchMock.delete(
+    'express:/project/:projectId/output',
+    delayPromise.then(() => ({
+      body: {
+        status: 204,
+      },
+    }))
+  )
 
 export const mockValidPdf = () => {
   nock('https://clsi.test-overleaf.com')

+ 6 - 7
services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.js

@@ -83,11 +83,14 @@ describe('<ShareProjectModal/>', function () {
 
   beforeEach(function () {
     fetchMock.get('/user/contacts', { contacts })
+    window.metaAttributesCache = new Map()
+    window.metaAttributesCache.set('ol-user', { allowedFreeTrial: true })
   })
 
   afterEach(function () {
     fetchMock.restore()
     cleanUpContext()
+    window.metaAttributesCache = new Map()
   })
 
   it('renders the modal', async function () {
@@ -179,7 +182,7 @@ describe('<ShareProjectModal/>', function () {
     ]
 
     // render as admin: actions should be present
-    const { rerender } = render(
+    render(
       <EditorProviders
         scope={{
           project: {
@@ -197,7 +200,7 @@ describe('<ShareProjectModal/>', function () {
     await screen.findByRole('button', { name: 'Resend' })
 
     // render as non-admin (non-owner), link sharing on: actions should be missing and message should be present
-    rerender(
+    render(
       <EditorProviders
         scope={{
           project: {
@@ -222,7 +225,7 @@ describe('<ShareProjectModal/>', function () {
     expect(screen.queryByRole('button', { name: 'Resend' })).to.be.null
 
     // render as non-admin (non-owner), link sharing off: actions should be missing and message should be present
-    rerender(
+    render(
       <EditorProviders
         scope={{
           project: {
@@ -619,10 +622,6 @@ describe('<ShareProjectModal/>', function () {
     )
 
     renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      user: {
-        id: '123abd',
-        allowedFreeTrial: true,
-      },
       scope: {
         project: {
           ...project,

+ 5 - 2
services/web/test/frontend/helpers/editor-providers.js

@@ -10,7 +10,8 @@ import { FileTreeDataProvider } from '../../../frontend/js/shared/context/file-t
 import { EditorProvider } from '../../../frontend/js/shared/context/editor-context'
 import { DetachProvider } from '../../../frontend/js/shared/context/detach-context'
 import { LayoutProvider } from '../../../frontend/js/shared/context/layout-context'
-import { CompileProvider } from '../../../frontend/js/shared/context/compile-context'
+import { LocalCompileProvider } from '../../../frontend/js/shared/context/local-compile-context'
+import { DetachCompileProvider } from '../../../frontend/js/shared/context/detach-compile-context'
 
 // these constants can be imported in tests instead of
 // using magic strings
@@ -110,7 +111,9 @@ export function EditorProviders({
               <EditorProvider settings={{}}>
                 <DetachProvider>
                   <LayoutProvider>
-                    <CompileProvider>{children}</CompileProvider>
+                    <LocalCompileProvider>
+                      <DetachCompileProvider>{children}</DetachCompileProvider>
+                    </LocalCompileProvider>
                   </LayoutProvider>
                 </DetachProvider>
               </EditorProvider>

+ 5 - 0
services/web/test/frontend/helpers/sysend.js

@@ -25,6 +25,10 @@ function getLastBroacastMessage() {
   return getLastDetachCall('broadcast').args[1]
 }
 
+function getAllBroacastMessages() {
+  return getDetachCalls('broadcast')
+}
+
 // this fakes receiving a message by calling the handler add to `on`. A bit
 // funky, but works for now
 function receiveMessage(message) {
@@ -37,5 +41,6 @@ export default {
   getDetachCalls,
   getLastDetachCall,
   getLastBroacastMessage,
+  getAllBroacastMessages,
   receiveMessage,
 }

+ 36 - 15
services/web/test/frontend/shared/hooks/use-detach-layout.test.js

@@ -46,6 +46,7 @@ describe('useDetachLayout', function () {
   })
 
   it('detacher role', async function () {
+    sysendTestHelper.spy.broadcast.resetHistory()
     window.metaAttributesCache.set('ol-detachRole', 'detacher')
 
     // 1. create hook in detacher mode
@@ -55,6 +56,8 @@ describe('useDetachLayout', function () {
     expect(result.current.isLinked).to.be.false
     expect(result.current.isLinking).to.be.false
     expect(result.current.role).to.equal('detacher')
+    const broadcastMessagesCount =
+      sysendTestHelper.getAllBroacastMessages().length
 
     // 2. simulate connected detached tab
     sysendTestHelper.spy.broadcast.resetHistory()
@@ -70,6 +73,12 @@ describe('useDetachLayout', function () {
     expect(result.current.isLinking).to.be.false
     expect(result.current.role).to.equal('detacher')
 
+    // check that all message were re-broadcast for the new tab
+    await nextTick() // necessary to ensure all event handler have run
+    const reBroadcastMessagesCount =
+      sysendTestHelper.getAllBroacastMessages().length
+    expect(reBroadcastMessagesCount).to.equal(broadcastMessagesCount)
+
     // 3. simulate closed detached tab
     sysendTestHelper.spy.broadcast.resetHistory()
     sysendTestHelper.receiveMessage({
@@ -90,21 +99,7 @@ describe('useDetachLayout', function () {
     expect(result.current.isLinking).to.be.false
     expect(result.current.role).to.equal('detacher')
 
-    // 5. simulate closed detacher tab
-    sysendTestHelper.spy.broadcast.resetHistory()
-    sysendTestHelper.receiveMessage({
-      role: 'detacher',
-      event: 'closed',
-    })
-    expect(result.current.isLinked).to.be.true
-    expect(result.current.isLinking).to.be.false
-    expect(result.current.role).to.equal('detacher')
-    expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
-      role: 'detacher',
-      event: 'up',
-    })
-
-    // 6. reattach
+    // 5. reattach
     sysendTestHelper.spy.broadcast.resetHistory()
     act(() => {
       result.current.reattach()
@@ -118,6 +113,26 @@ describe('useDetachLayout', function () {
     })
   })
 
+  it('reset detacher role when other detacher tab connects', function () {
+    window.metaAttributesCache.set('ol-detachRole', 'detacher')
+
+    // 1. create hook in detacher mode
+    const { result } = renderHookWithEditorContext(() => useDetachLayout())
+    expect(result.current.reattach).to.be.a('function')
+    expect(result.current.detach).to.be.a('function')
+    expect(result.current.isLinked).to.be.false
+    expect(result.current.isLinking).to.be.false
+    expect(result.current.role).to.equal('detacher')
+
+    // 2. simulate other detacher tab
+    sysendTestHelper.receiveMessage({
+      role: 'detacher',
+      event: 'up',
+    })
+    expect(result.current.isRedundant).to.be.true
+    expect(result.current.role).to.equal(null)
+  })
+
   it('detached role', async function () {
     window.metaAttributesCache.set('ol-detachRole', 'detached')
 
@@ -185,3 +200,9 @@ describe('useDetachLayout', function () {
     sinon.assert.called(closeStub)
   })
 })
+
+const nextTick = () => {
+  return new Promise(resolve => {
+    setTimeout(resolve)
+  })
+}

+ 1 - 0
services/web/webpack.config.js

@@ -12,6 +12,7 @@ const entryPoints = {
   serviceWorker: './frontend/js/serviceWorker.js',
   main: './frontend/js/main.js',
   ide: './frontend/js/ide.js',
+  'ide-detached': './frontend/js/ide-detached.js',
   marketing: './frontend/js/marketing.js',
   style: './frontend/stylesheets/style.less',
   'ieee-style': './frontend/stylesheets/ieee-style.less',