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

Merge pull request #30637 from overleaf/mj-ce-sp-new-editor

[web] Release editor redesign to Community Edition and Server Pro

GitOrigin-RevId: 062779fb418e44a4b245572fab1b4f365585a7f0
Mathias Jakobsen 7 месяцев назад
Родитель
Сommit
19545b35d8

+ 3 - 1
server-ce/test/graceful-shutdown.spec.ts

@@ -87,7 +87,9 @@ describe('GracefulShutdown', function () {
     bringServerProBackUp()
     bringServerProBackUp()
 
 
     cy.then(() => {
     cy.then(() => {
-      cy.visit(`/project/${projectId}?trick-cypress-into-page-reload=true`)
+      cy.visit(
+        `/project/${projectId}?trick-cypress-into-page-reload=true&old-editor-override=true`
+      )
     })
     })
 
 
     cy.log('check loading doc from mongo')
     cy.log('check loading doc from mongo')

+ 133 - 18
server-ce/test/helpers/project.ts

@@ -1,19 +1,38 @@
 import { login } from './login'
 import { login } from './login'
 import { openEmail } from './email'
 import { openEmail } from './email'
 import { v4 as uuid } from 'uuid'
 import { v4 as uuid } from 'uuid'
+import { prepareWaitForNextCompileSlot } from './compile'
 
 
 export const NEW_PROJECT_BUTTON_MATCHER = /new project/i
 export const NEW_PROJECT_BUTTON_MATCHER = /new project/i
 
 
+const NEW_EDITOR_QUERY_PARAMS = ''
+const OLD_EDITOR_QUERY_PARAMS = '?old-editor-override=true'
+
+export function redirectEditorUrlWithQueryParams(newEditor: boolean) {
+  const queryString = newEditor
+    ? NEW_EDITOR_QUERY_PARAMS
+    : OLD_EDITOR_QUERY_PARAMS
+  cy.intercept(
+    { method: 'GET', url: /\/project\/[a-fA-F0-9]{24}$/, times: 1 },
+    req => {
+      // Intercept redirect and add the query param to use the new editor
+      req.redirect(`${req.url}${queryString}`)
+    }
+  )
+}
+
 export function createProject(
 export function createProject(
   name: string,
   name: string,
   {
   {
     type = 'Blank project',
     type = 'Blank project',
     newProjectButtonMatcher = NEW_PROJECT_BUTTON_MATCHER,
     newProjectButtonMatcher = NEW_PROJECT_BUTTON_MATCHER,
     open = true,
     open = true,
+    newEditor = false,
   }: {
   }: {
     type?: 'Blank project' | 'Example project'
     type?: 'Blank project' | 'Example project'
     newProjectButtonMatcher?: RegExp
     newProjectButtonMatcher?: RegExp
     open?: boolean
     open?: boolean
+    newEditor?: boolean
   } = {}
   } = {}
 ): Cypress.Chainable<string> {
 ): Cypress.Chainable<string> {
   cy.url().then(url => {
   cy.url().then(url => {
@@ -30,12 +49,14 @@ export function createProject(
       cy.intercept(
       cy.intercept(
         { method: 'GET', url: /\/project\/[a-fA-F0-9]{24}$/, times: 1 },
         { method: 'GET', url: /\/project\/[a-fA-F0-9]{24}$/, times: 1 },
         req => {
         req => {
-          projectId = req.url.split('/').pop()!
+          projectId = req.url.split('/').pop()!.split('?')[0]
           // Redirect back to the project dashboard, effectively reload the page.
           // Redirect back to the project dashboard, effectively reload the page.
           req.redirect('/project')
           req.redirect('/project')
         }
         }
       ).as(interceptId)
       ).as(interceptId)
     })
     })
+  } else {
+    redirectEditorUrlWithQueryParams(newEditor)
   }
   }
   cy.findAllByRole('button').contains(newProjectButtonMatcher).click()
   cy.findAllByRole('button').contains(newProjectButtonMatcher).click()
   // FIXME: This should only look in the left menu
   // FIXME: This should only look in the left menu
@@ -51,7 +72,7 @@ export function createProject(
     return cy
     return cy
       .url()
       .url()
       .should('match', /\/project\/[a-fA-F0-9]{24}/)
       .should('match', /\/project\/[a-fA-F0-9]{24}/)
-      .then(url => url.split('/').pop())
+      .then(url => url.split('/').pop()?.split('?')[0])
   } else {
   } else {
     const alias = `@${interceptId}` // IDEs do not like computed values in cy.wait().
     const alias = `@${interceptId}` // IDEs do not like computed values in cy.wait().
     cy.wait(alias)
     cy.wait(alias)
@@ -59,35 +80,93 @@ export function createProject(
   }
   }
 }
 }
 
 
-export function openProjectByName(projectName: string) {
+// TODO ide-redesign-cleanup: Remove this and just use createProject directly
+export function createProjectAndOpenInNewEditor(
+  projectName: string,
+  options: {
+    type?: 'Blank project' | 'Example project'
+    newProjectButtonMatcher?: RegExp
+  } = {}
+) {
+  return createProject(projectName, { ...options, open: false }).then(
+    projectId => {
+      // Open the new project in the new editor
+      openProjectById(projectId, true)
+      return cy.then(() => projectId)
+    }
+  )
+}
+
+export function openProjectByName(projectName: string, newEditor = false) {
   cy.visit('/project')
   cy.visit('/project')
+
+  redirectEditorUrlWithQueryParams(newEditor)
+
   cy.findByText(projectName).click()
   cy.findByText(projectName).click()
   waitForMainDocToLoad()
   waitForMainDocToLoad()
+
+  if (newEditor) {
+    // Close the beta intro modal if it appears
+    // TODO ide-redesign-cleanup: Remove this when the intro modal is removed
+    cy.get('body').type('{esc}')
+  }
 }
 }
 
 
-export function openProjectById(projectId: string) {
-  cy.visit(`/project/${projectId}`)
+export function openProjectById(projectId: string, newEditor = false) {
+  const url = newEditor
+    ? `/project/${projectId}${NEW_EDITOR_QUERY_PARAMS}`
+    : `/project/${projectId}${OLD_EDITOR_QUERY_PARAMS}`
+  cy.visit(url)
   waitForMainDocToLoad()
   waitForMainDocToLoad()
+
+  if (newEditor) {
+    // Close the beta intro modal if it appears
+    // TODO ide-redesign-cleanup: Remove this when the intro modal is removed
+    cy.get('body').type('{esc}')
+  }
 }
 }
 
 
-export function openProjectViaLinkSharingAsAnon(url: string) {
+export function openProjectViaLinkSharingAsAnon(
+  url: string,
+  newEditor = false
+) {
+  redirectEditorUrlWithQueryParams(newEditor)
   cy.visit(url)
   cy.visit(url)
   waitForMainDocToLoad()
   waitForMainDocToLoad()
+
+  if (newEditor) {
+    // Close the beta intro modal if it appears
+    // TODO ide-redesign-cleanup: Remove this when the intro modal is removed
+    cy.get('body').type('{esc}')
+  }
 }
 }
 
 
 export function openProjectViaLinkSharingAsUser(
 export function openProjectViaLinkSharingAsUser(
   url: string,
   url: string,
   projectName: string,
   projectName: string,
-  email: string
+  email: string,
+  newEditor: boolean = false
 ) {
 ) {
   cy.visit(url)
   cy.visit(url)
   cy.findByText(projectName) // wait for lazy loading
   cy.findByText(projectName) // wait for lazy loading
   cy.contains(`as ${email}`)
   cy.contains(`as ${email}`)
+
+  redirectEditorUrlWithQueryParams(newEditor)
+
   cy.findByText('OK, join project').click()
   cy.findByText('OK, join project').click()
   waitForMainDocToLoad()
   waitForMainDocToLoad()
+
+  if (newEditor) {
+    // Close the beta intro modal if it appears
+    // TODO ide-redesign-cleanup: Remove this when the intro modal is removed
+    cy.get('body').type('{esc}')
+  }
 }
 }
 
 
-export function openProjectViaInviteNotification(projectName: string) {
+export function openProjectViaInviteNotification(
+  projectName: string,
+  newEditor: boolean = false
+) {
   cy.visit('/project')
   cy.visit('/project')
   cy.findByText(projectName)
   cy.findByText(projectName)
     .parent()
     .parent()
@@ -95,17 +174,42 @@ export function openProjectViaInviteNotification(projectName: string) {
     .within(() => {
     .within(() => {
       cy.findByText('Join Project').click()
       cy.findByText('Join Project').click()
     })
     })
-  cy.findByText('Open Project').click()
-  cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
-  waitForMainDocToLoad()
+
+  cy.intercept(
+    // NOTE: Struggling to work out why but this only seems to work with times: 2.
+    // This is temporary code so leaving it for now.
+    { method: 'GET', url: /\/project\/[a-fA-F0-9]{24}$/, times: 2 },
+    req => {
+      const queryString = newEditor
+        ? NEW_EDITOR_QUERY_PARAMS
+        : OLD_EDITOR_QUERY_PARAMS
+      // Intercept redirect and add the query param to use the new editor
+      req.redirect(`${req.url}${queryString}`)
+    }
+  )
+
+  const { waitForCompile } = prepareWaitForNextCompileSlot()
+  waitForCompile(() => {
+    cy.findByText('Open Project').click()
+
+    cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
+    waitForMainDocToLoad()
+
+    if (newEditor) {
+      // Close the beta intro modal if it appears
+      // TODO ide-redesign-cleanup: Remove this when the intro modal is removed
+      cy.get('body').type('{esc}')
+    }
+  })
 }
 }
 
 
 function shareProjectByEmail(
 function shareProjectByEmail(
   projectName: string,
   projectName: string,
   email: string,
   email: string,
-  level: 'Viewer' | 'Editor'
+  level: 'Viewer' | 'Editor',
+  newEditor: boolean = false
 ) {
 ) {
-  openProjectByName(projectName)
+  openProjectByName(projectName, newEditor)
   cy.findByRole('button', { name: 'Share' }).click()
   cy.findByRole('button', { name: 'Share' }).click()
   cy.findByRole('dialog').within(() => {
   cy.findByRole('dialog').within(() => {
     cy.findByLabelText('Add email address', { selector: 'input' }).type(
     cy.findByLabelText('Add email address', { selector: 'input' }).type(
@@ -127,12 +231,13 @@ function shareProjectByEmail(
 export function shareProjectByEmailAndAcceptInviteViaDash(
 export function shareProjectByEmailAndAcceptInviteViaDash(
   projectName: string,
   projectName: string,
   email: string,
   email: string,
-  level: 'Viewer' | 'Editor'
+  level: 'Viewer' | 'Editor',
+  newEditor: boolean = false
 ) {
 ) {
-  shareProjectByEmail(projectName, email, level)
+  shareProjectByEmail(projectName, email, level, newEditor)
 
 
   login(email)
   login(email)
-  openProjectViaInviteNotification(projectName)
+  openProjectViaInviteNotification(projectName, newEditor)
 }
 }
 
 
 export function getSpamSafeProjectName() {
 export function getSpamSafeProjectName() {
@@ -150,9 +255,10 @@ export function getSpamSafeProjectName() {
 export function shareProjectByEmailAndAcceptInviteViaEmail(
 export function shareProjectByEmailAndAcceptInviteViaEmail(
   projectName: string,
   projectName: string,
   email: string,
   email: string,
-  level: 'Viewer' | 'Editor'
+  level: 'Viewer' | 'Editor',
+  newEditor: boolean = false
 ) {
 ) {
-  shareProjectByEmail(projectName, email, level)
+  shareProjectByEmail(projectName, email, level, newEditor)
 
 
   login(email)
   login(email)
 
 
@@ -167,8 +273,17 @@ export function shareProjectByEmailAndAcceptInviteViaEmail(
   cy.url().should('match', /\/project\/[a-f0-9]+\/invite\/token\/[a-f0-9]+/)
   cy.url().should('match', /\/project\/[a-f0-9]+\/invite\/token\/[a-f0-9]+/)
   cy.findByText(/user would like you to join/)
   cy.findByText(/user would like you to join/)
   cy.contains(new RegExp(`You are accepting this invite as ${email}`))
   cy.contains(new RegExp(`You are accepting this invite as ${email}`))
+
+  redirectEditorUrlWithQueryParams(newEditor)
+
   cy.findByText('Join Project').click()
   cy.findByText('Join Project').click()
   waitForMainDocToLoad()
   waitForMainDocToLoad()
+
+  if (newEditor) {
+    // Close the beta intro modal if it appears
+    // TODO ide-redesign-cleanup: Remove this when the intro modal is removed
+    cy.get('body').type('{esc}')
+  }
 }
 }
 
 
 export function enableLinkSharing() {
 export function enableLinkSharing() {

+ 144 - 0
server-ce/test/new-editor-create-and-compile-project.spec.ts

@@ -0,0 +1,144 @@
+import { ensureUserExists, login } from './helpers/login'
+import {
+  createProject,
+  createProjectAndOpenInNewEditor,
+  openProjectViaInviteNotification,
+} from './helpers/project'
+import { isExcludedBySharding, startWith } from './helpers/config'
+import { prepareWaitForNextCompileSlot } from './helpers/compile'
+
+const USER = 'user@example.com'
+const COLLABORATOR = 'collaborator@example.com'
+describe('new editor.Project creation and compilation', function () {
+  if (isExcludedBySharding('CE_DEFAULT')) return
+  startWith({})
+  ensureUserExists({ email: USER })
+  ensureUserExists({ email: COLLABORATOR })
+
+  it('users can create project and compile it', function () {
+    login(USER)
+    const { recompile, waitForCompile, waitForCompileRateLimitCoolOff } =
+      prepareWaitForNextCompileSlot()
+    waitForCompile(() => {
+      createProjectAndOpenInNewEditor('test-project')
+    })
+    cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
+      cy.findByText('\\maketitle').parent().click()
+      cy.findByText('\\maketitle').parent().type('\n\\section{{}Test Section}')
+    })
+    waitForCompileRateLimitCoolOff()
+    recompile()
+    cy.findByRole('region', { name: 'PDF preview' }).within(() => {
+      cy.findByLabelText(/Page.*1/i).should('be.visible')
+      cy.findByText('Test Section').should('be.visible')
+    })
+  })
+
+  it('create and edit markdown file', function () {
+    const fileName = `test-${Date.now()}.md`
+    const markdownContent = '# Markdown title'
+    login(USER)
+    createProjectAndOpenInNewEditor('test-project')
+
+    cy.findByRole('button', { name: 'New file' }).click()
+    cy.findByRole('dialog').within(() => {
+      cy.findByLabelText('File Name').as('fileName').clear()
+      cy.get('@fileName').type(fileName)
+      cy.findByRole('button', { name: 'Create' }).click()
+    })
+    cy.findByRole('treeitem', { name: fileName }).click()
+    // wait until we've switched to the newly created empty file
+    cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
+      'have.length',
+      1
+    )
+    cy.findByRole('textbox', { name: 'Source Editor editing' }).type(
+      markdownContent
+    )
+    cy.findByRole('treeitem', { name: 'main.tex' }).click()
+    cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
+      'contain.text',
+      '\\maketitle'
+    )
+    cy.findByRole('treeitem', { name: fileName }).click()
+    cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
+      'contain.text',
+      markdownContent
+    )
+  })
+
+  it('can link and display linked image from other project', function () {
+    const sourceProjectName = `test-project-${Date.now()}`
+    const targetProjectName = `${sourceProjectName}-target`
+    login(USER)
+
+    createProject(sourceProjectName, {
+      type: 'Example project',
+      open: false,
+      newEditor: true,
+    }).as('sourceProjectId')
+    createProjectAndOpenInNewEditor(targetProjectName)
+
+    // link the image from `projectName` into this project
+    cy.findByRole('button', { name: 'New file' }).click()
+    cy.findByRole('dialog').within(() => {
+      cy.findByRole('button', { name: 'From another project' }).click()
+      cy.findByLabelText('Select a Project').select(sourceProjectName)
+      cy.findByLabelText('Select a File').select('frog.jpg')
+      cy.findByRole('button', { name: 'Create' }).click()
+    })
+    cy.findByRole('treeitem', { name: 'frog.jpg' }).click()
+    cy.findByRole('link', { name: 'Another project' })
+      .should('have.attr', 'href')
+      .then(href => {
+        cy.get('@sourceProjectId').then(sourceProjectId => {
+          expect(href).to.equal(`/project/${sourceProjectId}`)
+        })
+      })
+  })
+
+  it('can refresh linked files as collaborator', function () {
+    const sourceProjectName = `test-project-${Date.now()}`
+    const targetProjectName = `${sourceProjectName}-target`
+    login(USER)
+    createProject(sourceProjectName, {
+      type: 'Example project',
+      open: false,
+      newEditor: true,
+    }).as('sourceProjectId')
+    createProjectAndOpenInNewEditor(targetProjectName).as('targetProjectId')
+
+    // link the image from `projectName` into this project
+    cy.findByRole('button', { name: 'New file' }).click()
+    cy.findByRole('dialog').within(() => {
+      cy.findByRole('button', { name: 'From another project' }).click()
+      cy.findByLabelText('Select a Project').select(sourceProjectName)
+      cy.findByLabelText('Select a File').select('frog.jpg')
+      cy.findByRole('button', { name: 'Create' }).click()
+    })
+
+    cy.findByRole('navigation', { name: 'Project actions' }).within(() => {
+      cy.findByRole('button', { name: 'Share' }).click()
+    })
+    cy.findByRole('dialog').within(() => {
+      cy.findByTestId('collaborator-email-input').type(COLLABORATOR + ',')
+      cy.findByRole('button', { name: 'Invite' }).click()
+      cy.findByText('Invite not yet accepted.')
+    })
+
+    login(COLLABORATOR)
+    openProjectViaInviteNotification(targetProjectName, true)
+    cy.get('@targetProjectId').then(targetProjectId => {
+      cy.url().should('include', targetProjectId)
+    })
+
+    cy.findByRole('treeitem', { name: 'frog.jpg' }).click()
+    cy.findByRole('link', { name: 'Another project' })
+      .should('have.attr', 'href')
+      .then(href => {
+        cy.get('@sourceProjectId').then(sourceProjectId => {
+          expect(href).to.equal(`/project/${sourceProjectId}`)
+        })
+      })
+  })
+})

+ 290 - 0
server-ce/test/new-editor-editor.spec.ts

@@ -0,0 +1,290 @@
+import {
+  createNewFile,
+  createProjectAndOpenInNewEditor,
+  openProjectById,
+  testNewFileUpload,
+} from './helpers/project'
+import { isExcludedBySharding, startWith } from './helpers/config'
+import { ensureUserExists, login } from './helpers/login'
+import { v4 as uuid } from 'uuid'
+import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
+import { prepareWaitForNextCompileSlot } from './helpers/compile'
+
+const USER = 'user@example.com'
+const COLLABORATOR = 'collaborator@example.com'
+
+describe('new editor.editor', function () {
+  if (isExcludedBySharding('PRO_DEFAULT_1')) return
+  startWith({ pro: true })
+  ensureUserExists({ email: USER })
+  ensureUserExists({ email: COLLABORATOR })
+
+  let projectName: string
+  let projectId: string
+  let recompile: () => void
+  let waitForCompile: (fn: () => void) => void
+  beforeWithReRunOnTestRetry(() => {
+    projectName = `project-${uuid()}`
+    login(USER)
+    createProjectAndOpenInNewEditor(projectName, {
+      type: 'Example project',
+    }).then(id => (projectId = id))
+    ;({ recompile, waitForCompile } = prepareWaitForNextCompileSlot())
+  })
+
+  beforeEach(function () {
+    login(USER)
+    waitForCompile(() => {
+      openProjectById(projectId, true)
+    })
+  })
+
+  describe('spelling', function () {
+    function changeSpellCheckLanguageTo(lng: string) {
+      cy.log(`change project language to '${lng}'`)
+      cy.findByRole('button', { name: 'Settings' }).click()
+      cy.findByRole('dialog').within(() => {
+        cy.findByLabelText('Spellcheck language').select(lng)
+      })
+      cy.get('body').type('{esc}')
+    }
+
+    afterEach(function () {
+      changeSpellCheckLanguageTo('Off')
+    })
+
+    it('word dictionary and spelling', function () {
+      changeSpellCheckLanguageTo('English (American)')
+      createNewFile()
+      const word = createRandomLetterString()
+
+      cy.log('edit project file')
+      cy.get('.cm-line').type(word)
+      cy.findByText(word).should('have.class', 'ol-cm-spelling-error')
+
+      changeSpellCheckLanguageTo('Off')
+      cy.findByText(word).should('not.have.class', 'ol-cm-spelling-error')
+
+      changeSpellCheckLanguageTo('Spanish')
+      cy.findByText(word).should('have.class', 'ol-cm-spelling-error')
+
+      cy.log('add word to dictionary')
+      cy.findByText(word).rightclick()
+      cy.findByRole('menuitem', { name: 'Add to dictionary' }).click()
+      cy.findByText(word).should('not.have.class', 'ol-cm-spelling-error')
+
+      cy.log('remove word from dictionary')
+      cy.findByRole('button', { name: 'Settings' }).click()
+      cy.findByRole('dialog').within(() => {
+        cy.findByLabelText('Dictionary').click()
+      })
+      cy.findByTestId('dictionary-modal').within(() => {
+        cy.findByText(word)
+          .parent()
+          .within(() =>
+            cy.findByRole('button', { name: 'Remove from dictionary' }).click()
+          )
+
+        cy.findByRole('button', { name: 'Close dialog' }).click()
+      })
+
+      cy.log('close modal')
+      cy.get('body').type('{esc}')
+
+      cy.log('rewrite word to force spelling error')
+      cy.get('.cm-line').type('{selectAll}{del}' + word + '{enter}')
+
+      cy.get('.ol-cm-spelling-error').should('contain.text', word)
+    })
+  })
+
+  describe('editor', function () {
+    it('renders jpg', function () {
+      cy.findByRole('treeitem', { name: 'frog.jpg' }).click({ force: true })
+
+      cy.get('[alt="frog.jpg"]')
+        .should('be.visible')
+        .and('have.prop', 'naturalWidth')
+        .should('be.greaterThan', 0)
+    })
+
+    it('symbol palette', function () {
+      createNewFile()
+
+      cy.get('button[aria-label="Insert symbol"]').click({
+        force: true,
+      })
+      cy.get('button').contains('𝜉').click()
+      cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
+        'contain.text',
+        '\\xi'
+      )
+
+      cy.log('recompile to force flush and avoid "unsaved changes" prompt')
+      recompile()
+    })
+  })
+
+  describe('add new file to project', function () {
+    beforeEach(function () {
+      cy.findByRole('button', { name: 'New file' }).click()
+    })
+
+    testNewFileUpload()
+
+    it('should not display import from URL', function () {
+      cy.findByRole('button', { name: 'From external URL' }).should('not.exist')
+    })
+  })
+
+  describe('file menu', function () {
+    it('can download project sources', function () {
+      cy.findByRole('button', { name: 'File' }).click()
+      cy.findByRole('menuitem', { name: 'Download as source (.zip)' }).click()
+      const zipName = projectName.replaceAll('-', '_')
+      cy.task('readFileInZip', {
+        pathToZip: `cypress/downloads/${zipName}.zip`,
+        fileToRead: 'main.tex',
+      }).should('contain', 'Your introduction goes here')
+    })
+
+    it('can download project PDF', function () {
+      cy.log('ensure project is compiled')
+      cy.findByRole('region', { name: 'PDF preview' }).should(
+        'contain.text',
+        'Your Paper'
+      )
+
+      cy.findByRole('button', { name: 'File' }).click()
+      cy.findByRole('menuitem', { name: 'Download as PDF' }).click()
+      const pdfName = projectName.replaceAll('-', '_')
+      cy.task('readPdf', `cypress/downloads/${pdfName}.pdf`).should(
+        'contain',
+        'Your introduction goes here'
+      )
+    })
+
+    it('word count', function () {
+      cy.log('ensure project is compiled')
+      cy.findByRole('region', { name: 'PDF preview' }).should(
+        'contain.text',
+        'Your Paper'
+      )
+
+      cy.findByRole('button', { name: 'File' }).click()
+      cy.findByRole('menuitem', { name: 'Word count' }).click()
+
+      cy.findByTestId('word-count-modal').within(() => {
+        cy.findByText('Total Words:')
+        cy.findByText('607')
+        cy.findByText('Headers:')
+        cy.findByText('14')
+        cy.findByText('Math Inline:')
+        cy.findByText('6')
+        cy.findByText('Math Display:')
+        cy.findByText('1')
+      })
+    })
+  })
+
+  describe('cite key search', function () {
+    it('can insert citation from cite key', function () {
+      createNewFile()
+      cy.get('.cm-line').type('\\cite{{}gre')
+      cy.findByRole('listbox').within(() => {
+        cy.findByRole('option').should('contain.text', 'greenwade93').click()
+      })
+      cy.get('.cm-line').should('have.text', '\\cite{greenwade93}')
+    })
+
+    it('updates citation search when bib file is changed', function () {
+      createNewFile()
+      cy.get('.cm-line').type('\\cite{{}new')
+      // Wait a reasonable time to ensure the autocomplete would've appeared if there were any matches
+      // eslint-disable-next-line cypress/no-unnecessary-waiting
+      cy.wait(200)
+      cy.findByRole('listbox').should('not.exist')
+      cy.findByRole('treeitem', { name: 'sample.bib' }).click()
+      cy.get('.cm-line')
+        .last()
+        .type(
+          '\n@article{{}newkey2024,\n author = {{}Doe, John},\n title = {{}A New Article},\n journal = {{}Journal of Testing},\n year = 2024\n}\n'
+        )
+      createNewFile()
+      cy.get('.cm-line').type('\\cite{{}new')
+      cy.findByRole('listbox').within(() => {
+        cy.findByRole('option').should('contain.text', 'newkey2024').click()
+      })
+      cy.get('.cm-line').should('have.text', '\\cite{newkey2024}')
+    })
+  })
+
+  describe('layout selector', function () {
+    it('show editor only and switch between editor and pdf', function () {
+      cy.findByRole('region', { name: 'PDF preview' }).should('be.visible')
+      cy.get('.cm-editor').should('be.visible')
+
+      cy.findByRole('button', { name: 'Layout options' }).click()
+      cy.findByRole('menu').within(() => {
+        cy.findByRole('menuitem', { name: /Editor only/ }).click()
+      })
+
+      cy.findByRole('region', { name: 'PDF preview' }).should('not.be.visible')
+      cy.get('.cm-editor').should('be.visible')
+
+      // force click as tooltip may cover button for some reason
+      cy.findByRole('button', { name: 'Switch to PDF' }).click({ force: true })
+
+      cy.findByRole('region', { name: 'PDF preview' }).should('be.visible')
+      cy.get('.cm-editor').should('not.be.visible')
+
+      cy.findByRole('button', { name: 'Switch to editor' }).click()
+
+      cy.findByRole('region', { name: 'PDF preview' }).should('not.be.visible')
+      cy.get('.cm-editor').should('be.visible')
+    })
+
+    it('show PDF only and go back to split view', function () {
+      cy.findByRole('region', { name: 'PDF preview' }).should('be.visible')
+      cy.get('.cm-editor').should('be.visible')
+
+      cy.findByRole('button', { name: 'Layout options' }).click()
+      cy.findByRole('menu').within(() => {
+        cy.findByRole('menuitem', { name: /PDF only/ }).click()
+      })
+
+      cy.findByRole('region', { name: 'PDF preview' }).should('be.visible')
+      cy.get('.cm-editor').should('not.be.visible')
+
+      cy.findByRole('button', { name: 'Layout options' }).click()
+      cy.findByRole('menu').within(() => {
+        cy.findByRole('menuitem', { name: 'Split view' }).click()
+      })
+
+      cy.findByRole('region', { name: 'PDF preview' }).should('be.visible')
+      cy.get('.cm-editor').should('be.visible')
+    })
+
+    it('PDF in a separate tab (tests editor only)', function () {
+      cy.findByTestId('pdf-viewer').should('be.visible')
+      cy.get('.cm-editor').should('be.visible')
+
+      cy.findByRole('button', { name: 'Layout options' }).click()
+      cy.findByRole('menu').within(() => {
+        cy.findByRole('menuitem', { name: 'Open PDF in separate tab' }).click()
+      })
+
+      cy.findByTestId('pdf-viewer').should('not.exist')
+      cy.get('.cm-editor').should('be.visible')
+    })
+  })
+})
+
+function createRandomLetterString() {
+  const chars = 'abcdefghijklmnopqrstuvwxyz'
+  let result = ''
+  for (let i = 0; i < 12; i++) {
+    result += chars.charAt(Math.floor(Math.random() * chars.length))
+  }
+  return result
+}

+ 458 - 0
server-ce/test/new-editor-git-bridge.spec.ts

@@ -0,0 +1,458 @@
+import { v4 as uuid } from 'uuid'
+import { isExcludedBySharding, startWith } from './helpers/config'
+import { ensureUserExists, login } from './helpers/login'
+import {
+  createProject,
+  createProjectAndOpenInNewEditor,
+  enableLinkSharing,
+  openProjectByName,
+  openProjectViaLinkSharingAsUser,
+  shareProjectByEmailAndAcceptInviteViaDash,
+} from './helpers/project'
+
+import git from 'isomorphic-git'
+import http from 'isomorphic-git/http/web'
+import LightningFS from '@isomorphic-git/lightning-fs'
+import { prepareWaitForNextCompileSlot } from './helpers/compile'
+
+const USER = 'user@example.com'
+
+describe('new editor.git-bridge', function () {
+  const ENABLED_VARS = {
+    GIT_BRIDGE_ENABLED: 'true',
+    GIT_BRIDGE_HOST: 'git-bridge',
+    GIT_BRIDGE_PORT: '8000',
+    V1_HISTORY_URL: 'http://sharelatex:3100/api',
+  }
+
+  function gitURL(projectId: string) {
+    const url = new URL(Cypress.config().baseUrl!)
+    url.username = 'git'
+    url.pathname = `/git/${projectId}`
+    return url
+  }
+
+  describe('enabled in Server Pro', function () {
+    if (isExcludedBySharding('PRO_CUSTOM_1')) return
+    startWith({
+      pro: true,
+      vars: ENABLED_VARS,
+    })
+    ensureUserExists({ email: USER })
+
+    function clearAllTokens() {
+      cy.findAllByRole('button', { name: 'Remove' })
+        .not('[disabled]')
+        .each($button => {
+          cy.wrap($button).click()
+          cy.findByRole('button', { name: 'Delete token' }).click()
+        })
+      cy.findByRole('dialog').should('not.exist')
+    }
+
+    function maybeClearAllTokens() {
+      cy.visit('/user/settings')
+      cy.findByRole('heading', { name: 'Git integration' })
+      cy.findByRole('button', {
+        name: /Generate token|Add another token/i,
+      }).then(btn => {
+        if (btn.text() === 'Add another token') {
+          clearAllTokens()
+        }
+      })
+    }
+
+    beforeEach(function () {
+      login(USER)
+    })
+
+    it('should render the git-bridge UI in the settings', function () {
+      maybeClearAllTokens()
+      cy.visit('/user/settings')
+      cy.findByRole('heading', { name: 'Git integration' })
+      cy.findByRole('button', {
+        name: 'Git integration Generate token',
+      }).click()
+      cy.findByRole('dialog').within(() => {
+        cy.findByLabelText('Git authentication token')
+          .contains(/olp_[a-zA-Z0-9]{16}/)
+          .then(el => el.text())
+          .as('newToken')
+        cy.findByRole('button', { name: 'Close dialog' }).click()
+      })
+      cy.get('@newToken').then(token => {
+        // There can be more than one token with the same prefix when retrying
+        cy.findAllByText(
+          `${token.slice(0, 'olp_1234'.length)}${'*'.repeat(12)}`
+        ).should('have.length.at.least', 1)
+      })
+      cy.findByRole('button', {
+        name: 'Git integration Generate token',
+      }).should('not.exist')
+      cy.findByRole('button', { name: 'Add another token' }).should('exist')
+      clearAllTokens()
+      cy.findByRole('button', {
+        name: 'Git integration Generate token',
+      }).should('exist')
+      cy.findByRole('button', { name: 'Add another token' }).should('not.exist')
+    })
+
+    it('should render the git-bridge UI in the editor', function () {
+      maybeClearAllTokens()
+      createProjectAndOpenInNewEditor('git').as('projectId')
+      cy.findByRole('tab', { name: 'Integrations' }).click()
+      cy.findByText('Git clone this project.').click()
+
+      cy.findByTestId('git-bridge-modal').within(() => {
+        cy.get('@projectId').then(id => {
+          cy.findByLabelText('Git clone project command').contains(
+            `git clone ${gitURL(id.toString())}`
+          )
+        })
+        cy.findByRole('button', {
+          name: 'Generate token',
+        }).click()
+        cy.findByLabelText('Git authentication token').contains(
+          /olp_[a-zA-Z0-9]{16}/
+        )
+      })
+
+      // Re-open
+      cy.url().then(url => cy.visit(url))
+      cy.findByText('Git clone this project.').click()
+
+      cy.findByTestId('git-bridge-modal').within(() => {
+        cy.get('@projectId').then(id => {
+          cy.findByLabelText('Git clone project command').contains(
+            `git clone ${gitURL(id.toString())}`
+          )
+        })
+        cy.findByRole('button', {
+          name: 'Generate token',
+        }).should('not.exist')
+        cy.findByText(/generate a new one in Account settings/)
+        cy.findByRole('link', { name: 'Go to settings' })
+          .should('have.attr', 'target', '_blank')
+          .and('have.attr', 'href', '/user/settings')
+      })
+    })
+
+    describe('git access', function () {
+      ensureUserExists({ email: 'collaborator-rw@example.com' })
+      ensureUserExists({ email: 'collaborator-ro@example.com' })
+      ensureUserExists({ email: 'collaborator-link-rw@example.com' })
+      ensureUserExists({ email: 'collaborator-link-ro@example.com' })
+
+      let projectName: string
+      let recompile: () => void
+      let waitForCompile: (triggerCompile: () => void) => void
+      beforeEach(function () {
+        projectName = uuid()
+        createProject(projectName, { open: false }).as('projectId')
+        ;({ recompile, waitForCompile } = prepareWaitForNextCompileSlot())
+      })
+
+      it('should expose r/w interface to owner', function () {
+        maybeClearAllTokens()
+        waitForCompile(() => {
+          openProjectByName(projectName, true)
+        })
+        checkGitAccess('readAndWrite')
+      })
+
+      it('should expose r/w interface to invited r/w collaborator', function () {
+        shareProjectByEmailAndAcceptInviteViaDash(
+          projectName,
+          'collaborator-rw@example.com',
+          'Editor',
+          true
+        )
+        maybeClearAllTokens()
+        waitForCompile(() => {
+          openProjectByName(projectName, true)
+        })
+        checkGitAccess('readAndWrite')
+      })
+
+      it('should expose r/o interface to invited r/o collaborator', function () {
+        shareProjectByEmailAndAcceptInviteViaDash(
+          projectName,
+          'collaborator-ro@example.com',
+          'Viewer',
+          true
+        )
+        maybeClearAllTokens()
+        waitForCompile(() => {
+          openProjectByName(projectName, true)
+        })
+        checkGitAccess('readOnly')
+      })
+
+      it('should expose r/w interface to link-sharing r/w collaborator', function () {
+        openProjectByName(projectName, true)
+        enableLinkSharing().then(({ linkSharingReadAndWrite }) => {
+          const email = 'collaborator-link-rw@example.com'
+          login(email)
+          maybeClearAllTokens()
+          waitForCompile(() => {
+            openProjectViaLinkSharingAsUser(
+              linkSharingReadAndWrite,
+              projectName,
+              email,
+              true
+            )
+          })
+          checkGitAccess('readAndWrite')
+        })
+      })
+
+      it('should expose r/o interface to link-sharing r/o collaborator', function () {
+        waitForCompile(() => {
+          openProjectByName(projectName, true)
+        })
+        enableLinkSharing().then(({ linkSharingReadOnly }) => {
+          const email = 'collaborator-link-ro@example.com'
+          login(email)
+          maybeClearAllTokens()
+          waitForCompile(() => {
+            openProjectViaLinkSharingAsUser(
+              linkSharingReadOnly,
+              projectName,
+              email,
+              true
+            )
+          })
+          checkGitAccess('readOnly')
+        })
+      })
+
+      function checkGitAccess(access: 'readOnly' | 'readAndWrite') {
+        cy.findByRole('tab', { name: 'Integrations' }).click()
+        cy.findByText('Git clone this project.').click()
+
+        cy.get('@projectId').then(projectId => {
+          cy.findByTestId('git-bridge-modal').within(() => {
+            cy.findByLabelText('Git clone project command').contains(
+              `git clone ${gitURL(projectId.toString())}`
+            )
+            cy.findByRole('heading', { name: 'Clone with Git' })
+            cy.findByRole('button', {
+              name: 'Generate token',
+            }).click()
+          })
+          cy.findByLabelText('Git authentication token')
+            .contains(/olp_[a-zA-Z0-9]{16}/)
+            .then(async tokenEl => {
+              const token = tokenEl.text()
+
+              // close Git modal
+              cy.findByRole('button', { name: 'Close dialog' }).click()
+              cy.findByTestId('git-bridge-modal').should('not.exist')
+              // close the modal
+              cy.get('body').type('{esc}')
+              cy.findByTestId('left-menu').should('not.exist')
+              const fs = new LightningFS('fs')
+              const dir = `/${projectId}`
+
+              async function readFile(path: string): Promise<string> {
+                return await new Promise((resolve, reject) => {
+                  fs.readFile(path, { encoding: 'utf8' }, (err, blob) => {
+                    if (err) return reject(err)
+                    resolve(blob as string)
+                  })
+                })
+              }
+
+              async function writeFile(path: string, data: string) {
+                return await new Promise<void>((resolve, reject) => {
+                  fs.writeFile(path, data, undefined, err => {
+                    if (err) return reject(err)
+                    resolve()
+                  })
+                })
+              }
+
+              const commonOptions = {
+                dir,
+                fs,
+              }
+              const url = gitURL(projectId.toString())
+              url.username = '' // basic auth is specified separately.
+              const httpOptions = {
+                http,
+                url: url.toString(),
+                headers: {
+                  Authorization: `Basic ${Buffer.from(`git:${token}`).toString('base64')}`,
+                },
+              }
+              const authorOptions = {
+                author: { name: 'user', email: USER },
+                committer: { name: 'user', email: USER },
+              }
+              const mainTex = `${dir}/main.tex`
+
+              // Clone
+              cy.then({ timeout: 10_000 }, async () => {
+                await git.clone({
+                  ...commonOptions,
+                  ...httpOptions,
+                })
+              })
+              cy.findByText(/\\documentclass/)
+                .parent()
+                .parent()
+                .then(async editor => {
+                  const onDisk = await readFile(mainTex)
+                  expect(onDisk.replaceAll('\n', '')).to.equal(editor.text())
+                })
+              const text = `
+\\documentclass{article}
+\\begin{document}
+Hello world
+\\end{document}
+`
+
+              // Make a change
+              cy.then(async () => {
+                await writeFile(mainTex, text)
+                await git.add({
+                  ...commonOptions,
+                  filepath: 'main.tex',
+                })
+                await git.commit({
+                  ...commonOptions,
+                  ...authorOptions,
+                  message: 'Swap main.tex',
+                })
+              })
+
+              if (access === 'readAndWrite') {
+                // check history before push
+                cy.findByRole('navigation', {
+                  name: 'Project actions',
+                })
+                  .findByRole('button', { name: 'History' })
+                  .click()
+                cy.findByText('(via Git)').should('not.exist')
+                cy.findAllByText('Back to editor').last().click()
+                cy.then(async () => {
+                  await git.push({
+                    ...commonOptions,
+                    ...httpOptions,
+                  })
+                })
+              } else {
+                cy.then(async () => {
+                  try {
+                    await git.push({
+                      ...commonOptions,
+                      ...httpOptions,
+                    })
+                    expect.fail('push should have failed')
+                  } catch (err) {
+                    expect(err).to.match(/branches were not updated/)
+                    expect(err).to.match(/forbidden/)
+                  }
+                })
+
+                return // return early, below are write access bits
+              }
+
+              // check push in editor
+              cy.findByText(/\\documentclass/)
+                .parent()
+                .parent()
+                .should('have.text', text.replaceAll('\n', ''))
+
+              // Wait for history sync - trigger flush by toggling the UI
+              cy.findByRole('navigation', {
+                name: 'Project actions',
+              })
+                .findByRole('button', { name: 'History' })
+                .click()
+              cy.findAllByText('Back to editor').last().click()
+
+              // check push in history
+              cy.findByRole('navigation', {
+                name: 'Project actions',
+              })
+                .findByRole('button', { name: 'History' })
+                .click()
+              cy.findByText(/Hello world/)
+              cy.findByText('(via Git)').should('exist')
+
+              // Back to the editor
+              cy.findAllByText('Back to editor').last().click()
+              cy.findByText(/\\documentclass/)
+                .parent()
+                .parent()
+                .as('editor')
+                .click()
+              cy.get('@editor').type('% via editor{enter}')
+
+              // Trigger flush via compile
+              recompile()
+
+              // Back into the history, check what we just added
+              cy.findByRole('navigation', {
+                name: 'Project actions',
+              })
+                .findByRole('button', { name: 'History' })
+                .click()
+              cy.findByText(/% via editor/)
+
+              // Pull the change
+              cy.then(async () => {
+                await git.pull({
+                  ...commonOptions,
+                  ...httpOptions,
+                  ...authorOptions,
+                })
+
+                expect(await readFile(mainTex)).to.equal(
+                  text + '% via editor\n'
+                )
+              })
+            })
+        })
+      }
+    })
+  })
+
+  function checkDisabled() {
+    ensureUserExists({ email: USER })
+
+    it('should not render the git-bridge UI in the settings', function () {
+      login(USER)
+      cy.visit('/user/settings')
+      cy.findByRole('heading', { name: 'Git integration' }).should('not.exist')
+    })
+    it('should not render the git-bridge UI in the editor', function () {
+      login(USER)
+      createProjectAndOpenInNewEditor('maybe git')
+      cy.findByRole('tab', {
+        name: 'File tree',
+      }).should('exist') // Wait for load
+      cy.findByRole('tab', {
+        name: 'Integrations',
+      }).should('not.exist')
+    })
+  }
+
+  describe('disabled in Server Pro', function () {
+    if (isExcludedBySharding('PRO_DEFAULT_1')) return
+    startWith({
+      pro: true,
+    })
+    checkDisabled()
+  })
+
+  describe('unavailable in CE', function () {
+    if (isExcludedBySharding('CE_CUSTOM_1')) return
+    startWith({
+      pro: false,
+      vars: ENABLED_VARS,
+    })
+    checkDisabled()
+  })
+})

+ 109 - 0
server-ce/test/new-editor-graceful-shutdown.spec.ts

@@ -0,0 +1,109 @@
+import { ensureUserExists, login } from './helpers/login'
+import {
+  isExcludedBySharding,
+  STARTUP_TIMEOUT,
+  startWith,
+} from './helpers/config'
+import { dockerCompose, getRedisKeys } from './helpers/hostAdminClient'
+import { createProjectAndOpenInNewEditor } from './helpers/project'
+import { prepareWaitForNextCompileSlot } from './helpers/compile'
+
+const USER = 'user@example.com'
+const PROJECT_NAME = 'Old Project'
+
+function bringServerProBackUp() {
+  cy.log('bring server pro back up')
+  cy.then({ timeout: STARTUP_TIMEOUT }, async () => {
+    await dockerCompose('up', '--detach', '--wait', 'sharelatex')
+  })
+}
+
+describe('new editor.GracefulShutdown', function () {
+  if (isExcludedBySharding('PRO_CUSTOM_1')) return
+  startWith({
+    pro: true,
+    withDataDir: true,
+    resetData: true,
+  })
+  ensureUserExists({ email: USER })
+
+  let projectId: string
+  it('should display banner and flush changes out of redis', function () {
+    bringServerProBackUp()
+    login(USER)
+    const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
+    waitForCompile(() => {
+      createProjectAndOpenInNewEditor(PROJECT_NAME).then(id => {
+        projectId = id
+      })
+    })
+
+    cy.log('add additional content')
+    cy.findByRole('region', { name: 'Editor' }).within(() => {
+      cy.findByText('\\maketitle').parent().click()
+      cy.findByText('\\maketitle').parent().type(`\n\\section{{}New Section}`)
+    })
+    recompile()
+
+    cy.log(
+      'check flush from frontend to backend: should include new section in PDF'
+    )
+    cy.findByRole('region', { name: 'PDF preview' }).should(
+      'contain.text',
+      'New Section'
+    )
+
+    cy.log('should have unflushed content in redis before shutdown')
+    cy.then(async () => {
+      const keys = await getRedisKeys()
+      expect(keys).to.contain(`DocsIn:${projectId}`)
+      expect(keys).to.contain(`ProjectHistory:Ops:{${projectId}}`)
+    })
+
+    cy.log('trigger graceful shutdown')
+    let pendingShutdown: Promise<any>
+    cy.then(() => {
+      pendingShutdown = dockerCompose('stop', '--timeout=60', 'sharelatex')
+    })
+
+    cy.log('wait for banner')
+    cy.findByRole('dialog').findByText(/performing maintenance/)
+    cy.log('wait for page reload')
+    cy.findByRole('heading', { name: 'Maintenance' })
+    cy.findByText(/is currently down for maintenance/)
+
+    cy.log('wait for shutdown to complete')
+    cy.then({ timeout: 60 * 1000 }, async () => {
+      await pendingShutdown
+    })
+
+    cy.log('should not have any unflushed content in redis after shutdown')
+    cy.then(async () => {
+      const keys = await getRedisKeys()
+      expect(keys).to.not.contain(`DocsIn:${projectId}`)
+      expect(keys).to.not.contain(`ProjectHistory:Ops:{${projectId}}`)
+    })
+
+    bringServerProBackUp()
+
+    cy.then(() => {
+      cy.visit(`/project/${projectId}?trick-cypress-into-page-reload=true`)
+    })
+
+    cy.log('check loading doc from mongo')
+    cy.findByRole('region', { name: 'Editor' }).findByText('New Section')
+
+    cy.log('check PDF')
+    cy.findByRole('region', { name: 'PDF preview' }).should(
+      'contain.text',
+      'New Section'
+    )
+    cy.log('check history')
+    cy.findByRole('navigation', {
+      name: 'Project actions',
+    })
+      .findByRole('button', { name: 'History' })
+      .click()
+    cy.findByText(/\\section\{New Section}/)
+  })
+})

+ 164 - 0
server-ce/test/new-editor-history.spec.ts

@@ -0,0 +1,164 @@
+import { createProjectAndOpenInNewEditor } from './helpers/project'
+import { prepareWaitForNextCompileSlot } from './helpers/compile'
+import { ensureUserExists, login } from './helpers/login'
+import { isExcludedBySharding, startWith } from './helpers/config'
+
+describe('new editor.History', function () {
+  if (isExcludedBySharding('CE_DEFAULT')) return
+  startWith({})
+  ensureUserExists({ email: 'user@example.com' })
+  beforeEach(function () {
+    login('user@example.com')
+  })
+
+  function addLabel(name: string) {
+    cy.log(`add label ${JSON.stringify(name)}`)
+    // The input is not clickable due to being visually hidden, click its label instead
+    cy.findByRole('complementary', {
+      name: 'Project history and labels',
+    }).within(() => {
+      cy.findByRole('group', {
+        name: 'Show all of the project history or only labelled versions.',
+      }).within(() => {
+        cy.findByText('Labels').click()
+      })
+      cy.findByRole('radio', { name: 'Labels' }).should('be.checked')
+      cy.findByRole('radio', { name: 'All history' }).should('not.be.checked')
+      cy.findAllByTestId('history-version-details')
+        .first()
+        .within(() => {
+          cy.findByRole('button', { name: 'More actions' }).click()
+          cy.findByRole('menuitem', { name: 'Label this version' }).click()
+        })
+    })
+    cy.findByRole('dialog').within(() => {
+      cy.findByLabelText('New label name').type(`${name}{enter}`)
+    })
+  }
+
+  function downloadVersion(name: string) {
+    cy.log(`download version ${JSON.stringify(name)}`)
+    // The input is not clickable due to being visually hidden, click its label instead
+    cy.findByRole('complementary', {
+      name: 'Project history and labels',
+    }).within(() => {
+      cy.findByRole('group', {
+        name: 'Show all of the project history or only labelled versions.',
+      }).within(() => {
+        cy.findByText('Labels').click()
+      })
+      cy.findByRole('radio', { name: 'Labels' }).should('be.checked')
+      cy.findByRole('radio', { name: 'All history' }).should('not.be.checked')
+      cy.findByText(name)
+        .closest('[data-testid="history-version-details"]')
+        .within(() => {
+          cy.findByRole('button', { name: 'More actions' }).click()
+          cy.findByRole('menuitem', { name: 'Download this version' }).click()
+        })
+    })
+  }
+
+  const CLASS_ADDITION = 'ol-cm-addition-marker'
+  const CLASS_DELETION = 'ol-cm-deletion-marker'
+
+  it('should support labels, comparison and download', function () {
+    const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
+    waitForCompile(() => {
+      createProjectAndOpenInNewEditor('labels')
+    })
+
+    cy.log('add content, including a line that will get removed soon')
+    cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
+      cy.findByText('\\maketitle').parent().click()
+      cy.findByText('\\maketitle').parent().type('\n% added')
+      cy.findByText('\\maketitle').parent().type('\n% to be removed')
+    })
+    recompile()
+    cy.findByRole('button', { name: 'History' }).click()
+
+    cy.log('expect to see additions in history')
+    cy.get('.document-diff-container').within(() => {
+      cy.findByText('% to be removed').should('have.class', CLASS_ADDITION)
+      cy.findByText('% added').should('have.class', CLASS_ADDITION)
+    })
+
+    addLabel('Before removal')
+
+    cy.log('remove content')
+    cy.findByRole('button', { name: 'Back to editor' }).click()
+    cy.findByText('% to be removed').parent().type('{end}{shift}{upArrow}{del}')
+    recompile()
+    cy.findByRole('button', { name: 'History' }).click()
+
+    cy.log('expect to see annotation for newly removed content in history')
+    cy.get('.document-diff-container').within(() => {
+      cy.findByText('% to be removed').should('have.class', CLASS_DELETION)
+      cy.findByText('% added').should('not.have.class', CLASS_ADDITION)
+    })
+
+    addLabel('After removal')
+
+    cy.log('add more content after labeling')
+    cy.findByRole('button', { name: 'Back to editor' }).click()
+    cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
+      cy.findByText('\\maketitle').parent().click()
+      cy.findByText('\\maketitle').parent().type('\n% more')
+    })
+    recompile()
+
+    cy.log('compare non current versions')
+    cy.findByRole('button', { name: 'History' }).click()
+    // The input is not clickable due to being visually hidden, click its label instead
+    cy.findByRole('complementary', {
+      name: 'Project history and labels',
+    }).within(() => {
+      cy.findByRole('group', {
+        name: 'Show all of the project history or only labelled versions.',
+      }).within(() => {
+        cy.findByText('Labels').click()
+      })
+      cy.findByRole('radio', { name: 'Labels' }).should('be.checked')
+      cy.findByRole('radio', { name: 'All history' }).should('not.be.checked')
+      cy.findAllByTestId('compare-icon-version').last().click()
+      cy.findAllByTestId('compare-icon-version').filter(':visible').click()
+      cy.findByRole('menuitem', {
+        name: 'Compare up to this version',
+      }).click()
+    })
+    cy.log(
+      'expect to see annotation for removed content between the two versions'
+    )
+    cy.get('.document-diff-container').within(() => {
+      cy.findByText('% to be removed').should('have.class', CLASS_DELETION)
+      cy.findByText('% added').should('not.have.class', CLASS_ADDITION)
+      cy.findByText('% more').should('not.exist')
+    })
+
+    downloadVersion('Before removal')
+    cy.task('readFileInZip', {
+      pathToZip: `cypress/downloads/labels (Version 2).zip`,
+      fileToRead: 'main.tex',
+    })
+      .should('contain', '% added')
+      .should('contain', '% to be removed')
+      .should('not.contain', '% more')
+
+    downloadVersion('After removal')
+    cy.task('readFileInZip', {
+      pathToZip: `cypress/downloads/labels (Version 3).zip`,
+      fileToRead: 'main.tex',
+    })
+      .should('contain', '% added')
+      .should('not.contain', '% to be removed')
+      .should('not.contain', '% more')
+
+    downloadVersion('Current state')
+    cy.task('readFileInZip', {
+      pathToZip: `cypress/downloads/labels (Version 4).zip`,
+      fileToRead: 'main.tex',
+    })
+      .should('contain', '% added')
+      .should('not.contain', '% to be removed')
+      .should('contain', '% more')
+  })
+})

+ 534 - 0
server-ce/test/new-editor-project-sharing.spec.ts

@@ -0,0 +1,534 @@
+import { v4 as uuid } from 'uuid'
+import {
+  isExcludedBySharding,
+  startWith,
+  reloadWith,
+  STARTUP_TIMEOUT,
+} from './helpers/config'
+import { ensureUserExists, login } from './helpers/login'
+import {
+  createProjectAndOpenInNewEditor,
+  enableLinkSharing,
+  getSpamSafeProjectName,
+  openProjectByName,
+  openProjectViaLinkSharingAsAnon,
+  openProjectViaLinkSharingAsUser,
+  shareProjectByEmailAndAcceptInviteViaDash,
+  shareProjectByEmailAndAcceptInviteViaEmail,
+} from './helpers/project'
+import { prepareWaitForNextCompileSlot } from './helpers/compile'
+import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
+
+describe('new editor.Project Sharing', function () {
+  if (isExcludedBySharding('PRO_CUSTOM_4')) return
+  ensureUserExists({ email: 'user@example.com' })
+  startWith({ withDataDir: true, pro: true })
+
+  let projectName: string
+  let recompile: () => void
+  let waitForCompile: (triggerCompile: () => void) => void
+  beforeWithReRunOnTestRetry(() => {
+    projectName = getSpamSafeProjectName()
+    ;({ recompile, waitForCompile } = prepareWaitForNextCompileSlot())
+    setupTestProject()
+  })
+
+  beforeEach(function () {
+    // Always start with a fresh session
+    cy.session([uuid()], () => {})
+  })
+
+  let linkSharingReadOnly: string
+  let linkSharingReadAndWrite: string
+
+  function setupTestProject() {
+    login('user@example.com')
+    waitForCompile(() => {
+      createProjectAndOpenInNewEditor(projectName)
+    })
+
+    // Add chat message
+    cy.findByRole('tab', { name: 'Chat' }).click()
+    // wait for lazy loading of the chat pane
+    cy.findByText('Start the conversation by saying hello or sharing an update')
+    cy.get(
+      'textarea[placeholder="Send a message to your collaborators…"]'
+    ).type('New Chat Message{enter}')
+
+    // Get link sharing links
+    enableLinkSharing().then(
+      ({ linkSharingReadOnly: ro, linkSharingReadAndWrite: rw }) => {
+        linkSharingReadAndWrite = rw
+        linkSharingReadOnly = ro
+      }
+    )
+  }
+
+  function expectContentReadOnlyAccess() {
+    cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
+    cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
+      'contain.text',
+      '\\maketitle'
+    )
+    cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
+      'have.attr',
+      'contenteditable',
+      'false'
+    )
+  }
+
+  function expectContentWriteAccess() {
+    const section = `Test Section ${uuid()}`
+    cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
+    // wait for the editor to finish loading
+    cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
+      'contain.text',
+      '\\maketitle'
+    )
+    // the editor should be writable
+    cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
+      'have.attr',
+      'contenteditable',
+      'true'
+    )
+    cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
+      cy.findByText('\\maketitle').parent().click()
+      cy.findByText('\\maketitle').parent().type(`\n\\section{{}${section}}`)
+    })
+    // should have written
+    cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
+      'contain.text',
+      `\\section{${section}}`
+    )
+    // check PDF
+    recompile()
+    cy.findByRole('region', { name: 'PDF preview' }).within(() => {
+      cy.findByLabelText(/Page.*1/i).should('be.visible')
+      cy.findByText(projectName).should('be.visible')
+    })
+    cy.findByRole('region', { name: 'PDF preview' }).within(() => {
+      cy.findByLabelText(/Page.*1/i).should('be.visible')
+      cy.contains(section)
+    })
+  }
+
+  function expectNoAccess() {
+    // try read only access link
+    cy.visit(linkSharingReadOnly)
+    cy.url().should('match', /\/login/)
+
+    // Cypress bugs: cypress resolves the link-sharing link outside the browser, and it carries over the hash of the link-sharing link to the login page redirect (bug 1).
+    // Effectively, cypress then instructs the browser to change the page from /login#read-only-hash to /login#read-and-write-hash.
+    // This is turn does not trigger a "page load", but rather just "scrolling", which in turn trips up the "page loaded" detection in cypress (bug 2).
+    // Work around this by navigating away from the /login page in between checks.
+    cy.visit('/user/password/reset')
+
+    // try read and write access link
+    cy.visit(linkSharingReadAndWrite)
+    cy.url().should('match', /\/login/)
+  }
+
+  function expectChatAccess() {
+    cy.findByRole('tab', { name: 'Chat' }).click()
+    cy.findByText('New Chat Message')
+  }
+
+  function expectHistoryAccess() {
+    cy.findByRole('button', { name: 'History' }).click()
+    // The input is not clickable due to being visually hidden, click its label instead
+    cy.findByRole('complementary', {
+      name: 'Project history and labels',
+    }).within(() => {
+      cy.findByRole('group', {
+        name: 'Show all of the project history or only labelled versions.',
+      }).within(() => {
+        cy.findByText('All history').click()
+      })
+      cy.findByRole('radio', { name: 'Labels' }).should('not.be.checked')
+      cy.findByRole('radio', { name: 'All history' }).should('be.checked')
+    })
+    cy.findByText(/\\begin\{document}/)
+    cy.findByRole('complementary', {
+      name: 'Project history and labels',
+    }).within(() => {
+      cy.findAllByTestId('history-version-metadata-users')
+        .last()
+        .should('have.text', 'user')
+    })
+    cy.findByRole('button', { name: 'Back to editor' }).click()
+  }
+
+  function expectNoChatAccess() {
+    cy.findByRole('button', { name: 'Layout options' }) // wait for lazy loading
+    cy.findByRole('tab', { name: 'Chat' }).should('not.exist')
+  }
+
+  function expectNoHistoryAccess() {
+    cy.findByRole('button', { name: 'Layout options' }) // wait for lazy loading
+    cy.findByRole('button', { name: 'History' }).should('not.exist')
+  }
+
+  function expectCommentAccess() {
+    cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
+      'contain.text',
+      '\\maketitle'
+    )
+
+    cy.findByText('\\maketitle').parent().dblclick()
+
+    cy.findByRole('button', { name: 'Add comment' }).should('be.visible')
+
+    cy.findByRole('textbox', { name: 'Source Editor editing' }).click()
+  }
+
+  function expectNoCommentAccess() {
+    cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
+      'contain.text',
+      '\\maketitle'
+    )
+
+    cy.findByText('\\maketitle').parent().dblclick()
+
+    cy.findByRole('button', { name: 'Add comment' }).should('not.exist')
+    cy.findByRole('textbox', { name: 'Source Editor editing' }).click()
+  }
+
+  function expectFullReadOnlyAccess() {
+    expectContentReadOnlyAccess()
+    expectChatAccess()
+    expectHistoryAccess()
+    expectNoCommentAccess()
+  }
+
+  function expectRestrictedReadOnlyAccess() {
+    expectContentReadOnlyAccess()
+    expectNoChatAccess()
+    expectNoHistoryAccess()
+    expectNoCommentAccess()
+  }
+
+  function expectFullReadAndWriteAccess() {
+    expectContentWriteAccess()
+    expectChatAccess()
+    expectHistoryAccess()
+    expectCommentAccess()
+  }
+
+  function expectAnonymousReadAndWriteAccess() {
+    expectContentWriteAccess()
+    expectChatAccess()
+    expectHistoryAccess()
+    expectNoCommentAccess()
+  }
+
+  function expectProjectDashboardEntry() {
+    cy.visit('/project')
+    cy.findByText(projectName)
+  }
+
+  function expectEditAuthoredAs(author: string) {
+    cy.findByRole('button', { name: 'History' }).click()
+    cy.findByRole('complementary', {
+      name: 'Project history and labels',
+    }).within(() => {
+      cy.findAllByTestId('history-version-metadata-users')
+        .first()
+        .should('contain.text', author) // might have other edits in the same group
+    })
+  }
+  describe('via email', function () {
+    const email = 'collaborator-email@example.com'
+    ensureUserExists({ email })
+
+    beforeEach(function () {
+      login('user@example.com')
+      shareProjectByEmailAndAcceptInviteViaEmail(
+        projectName,
+        email,
+        'Viewer',
+        true
+      )
+    })
+
+    it('should grant the collaborator read access', function () {
+      expectFullReadOnlyAccess()
+      expectProjectDashboardEntry()
+    })
+  })
+
+  describe('read only', function () {
+    const email = 'collaborator-ro@example.com'
+    ensureUserExists({ email })
+
+    beforeWithReRunOnTestRetry(() => {
+      login('user@example.com')
+      shareProjectByEmailAndAcceptInviteViaDash(
+        projectName,
+        email,
+        'Viewer',
+        true
+      )
+    })
+
+    it('should grant the collaborator read access', function () {
+      login(email)
+      openProjectByName(projectName, true)
+      expectFullReadOnlyAccess()
+      expectProjectDashboardEntry()
+    })
+  })
+
+  describe('read and write', function () {
+    const email = 'collaborator-rw@example.com'
+    ensureUserExists({ email })
+
+    beforeWithReRunOnTestRetry(() => {
+      login('user@example.com')
+      shareProjectByEmailAndAcceptInviteViaDash(
+        projectName,
+        email,
+        'Editor',
+        true
+      )
+    })
+
+    it('should grant the collaborator write access', function () {
+      login(email)
+      openProjectByName(projectName, true)
+      expectFullReadAndWriteAccess()
+      expectEditAuthoredAs('You')
+      expectProjectDashboardEntry()
+    })
+  })
+
+  describe('token access', function () {
+    describe('logged in', function () {
+      describe('read only', function () {
+        const email = 'collaborator-link-ro@example.com'
+        ensureUserExists({ email })
+
+        it('should grant restricted read access', function () {
+          login(email)
+          openProjectViaLinkSharingAsUser(
+            linkSharingReadOnly,
+            projectName,
+            email,
+            true
+          )
+          expectRestrictedReadOnlyAccess()
+          expectProjectDashboardEntry()
+        })
+      })
+
+      describe('read and write', function () {
+        const email = 'collaborator-link-rw@example.com'
+        ensureUserExists({ email })
+
+        it('should grant full write access', function () {
+          login(email)
+          openProjectViaLinkSharingAsUser(
+            linkSharingReadAndWrite,
+            projectName,
+            email,
+            true
+          )
+          expectFullReadAndWriteAccess()
+          expectEditAuthoredAs('You')
+          expectProjectDashboardEntry()
+        })
+      })
+    })
+
+    describe('with OVERLEAF_ALLOW_PUBLIC_ACCESS=false', function () {
+      describe('wrap startup', function () {
+        startWith({
+          pro: true,
+          vars: {
+            OVERLEAF_ALLOW_PUBLIC_ACCESS: 'false',
+          },
+          withDataDir: true,
+        })
+        it('should block access', function () {
+          expectNoAccess()
+        })
+      })
+
+      describe('with OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING=true', function () {
+        startWith({
+          pro: true,
+          vars: {
+            OVERLEAF_ALLOW_PUBLIC_ACCESS: 'false',
+            OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true',
+          },
+          withDataDir: true,
+        })
+        it('should block access', function () {
+          expectNoAccess()
+        })
+      })
+    })
+
+    describe('with OVERLEAF_ALLOW_PUBLIC_ACCESS=true', function () {
+      describe('wrap startup', function () {
+        startWith({
+          pro: true,
+          vars: {
+            OVERLEAF_ALLOW_PUBLIC_ACCESS: 'true',
+          },
+          withDataDir: true,
+        })
+        it('should grant read access with read link', function () {
+          openProjectViaLinkSharingAsAnon(linkSharingReadOnly, true)
+          expectRestrictedReadOnlyAccess()
+        })
+
+        it('should prompt for login with write link', function () {
+          cy.visit(linkSharingReadAndWrite)
+          cy.url().should('match', /\/login/)
+        })
+      })
+
+      describe('with OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING=true', function () {
+        startWith({
+          pro: true,
+          vars: {
+            OVERLEAF_ALLOW_PUBLIC_ACCESS: 'true',
+            OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true',
+          },
+          withDataDir: true,
+        })
+
+        it('should grant read access with read link', function () {
+          openProjectViaLinkSharingAsAnon(linkSharingReadOnly, true)
+          expectRestrictedReadOnlyAccess()
+        })
+
+        it('should grant write access with write link', function () {
+          openProjectViaLinkSharingAsAnon(linkSharingReadAndWrite, true)
+          expectAnonymousReadAndWriteAccess()
+          expectEditAuthoredAs('Anonymous')
+        })
+      })
+    })
+
+    describe('with OVERLEAF_DISABLE_LINK_SHARING=true', function () {
+      const email = 'collaborator-email@example.com'
+      ensureUserExists({ email })
+
+      const invitedEmail = 'invited-email@example.com'
+      ensureUserExists({ email: invitedEmail })
+
+      const retainedViewerEmail = 'collaborator-retained-viewer@example.com'
+      ensureUserExists({ email: retainedViewerEmail })
+
+      const retainedEditorEmail = 'collaborator-retained-editor@example.com'
+      ensureUserExists({ email: retainedEditorEmail })
+
+      // Link-sharing urls have to be created before disabling link sharing.
+      // We use the `beforeEach` hook to reload the server with link sharing
+      // disabled **after** the initial setup which happens in the `before`
+      // block. The `before` hook always runs prior to the `beforeEach` hook.
+
+      // Set up retained access before disabling link sharing
+      before(function () {
+        // Set up retained viewer access
+        login(retainedViewerEmail)
+        openProjectViaLinkSharingAsUser(
+          linkSharingReadOnly,
+          projectName,
+          retainedViewerEmail,
+          true
+        )
+
+        // Set up retained editor access
+        login(retainedEditorEmail)
+        openProjectViaLinkSharingAsUser(
+          linkSharingReadAndWrite,
+          projectName,
+          retainedEditorEmail,
+          true
+        )
+      })
+
+      beforeEach(function () {
+        this.timeout(STARTUP_TIMEOUT) // Increase timeout for server reload
+
+        return cy.wrap(
+          reloadWith({
+            pro: true,
+            vars: {
+              OVERLEAF_ALLOW_PUBLIC_ACCESS: 'true',
+              OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true',
+              OVERLEAF_DISABLE_LINK_SHARING: 'true',
+            },
+            withDataDir: true,
+          }),
+          { timeout: STARTUP_TIMEOUT }
+        )
+      })
+
+      it('should not display link sharing in the sharing modal', function () {
+        login('user@example.com')
+        openProjectByName(projectName, true)
+        cy.findByRole('navigation', {
+          name: 'Project actions',
+        })
+          .findByRole('button', { name: 'Share' })
+          .click()
+        cy.findByRole('button', { name: 'Turn on link sharing' }).should(
+          'not.exist'
+        )
+      })
+
+      it('should block new access to read-only link shared projects', function () {
+        login(email)
+
+        // Test read-only link returns 404
+        cy.request({
+          url: linkSharingReadOnly,
+          failOnStatusCode: false,
+        }).then(response => {
+          expect(response.status).to.eq(404)
+        })
+      })
+
+      it('should block new access to read-write link shared projects', function () {
+        login(email)
+
+        // Test read-write link returns 404
+        cy.request({
+          url: linkSharingReadAndWrite,
+          failOnStatusCode: false,
+        }).then(response => {
+          expect(response.status).to.eq(404)
+        })
+      })
+
+      it('should continue to allow email sharing', function () {
+        login('user@example.com')
+        shareProjectByEmailAndAcceptInviteViaEmail(
+          projectName,
+          invitedEmail,
+          'Viewer',
+          true
+        )
+        expectFullReadOnlyAccess()
+        expectProjectDashboardEntry()
+      })
+
+      it('should retain read-only access when project was joined via link before link sharing was turned off', function () {
+        login(retainedViewerEmail)
+        openProjectByName(projectName, true)
+        expectRestrictedReadOnlyAccess()
+        expectProjectDashboardEntry()
+      })
+
+      it('should retain read-write access when project was joined via link before link sharing was turned off', function () {
+        login(retainedEditorEmail)
+        openProjectByName(projectName, true)
+        expectFullReadAndWriteAccess()
+        expectProjectDashboardEntry()
+      })
+    })
+  })
+})

+ 345 - 0
server-ce/test/new-editor-sandboxed-compiles.spec.ts

@@ -0,0 +1,345 @@
+import { ensureUserExists, login } from './helpers/login'
+import { createProjectAndOpenInNewEditor } from './helpers/project'
+import { isExcludedBySharding, startWith } from './helpers/config'
+import { prepareWaitForNextCompileSlot, stopCompile } from './helpers/compile'
+import { v4 as uuid } from 'uuid'
+import { waitUntilScrollingFinished } from './helpers/waitUntilScrollingFinished'
+
+const LABEL_TEX_LIVE_VERSION = 'TeX Live version'
+
+describe('new editor.SandboxedCompiles', function () {
+  const enabledVars = {
+    SANDBOXED_COMPILES: 'true',
+    ALL_TEX_LIVE_DOCKER_IMAGE_NAMES: '2023,2022',
+  }
+
+  describe('enabled in Server Pro', function () {
+    if (isExcludedBySharding('PRO_CUSTOM_2')) return
+    startWith({
+      pro: true,
+      vars: enabledVars,
+      resetData: true,
+    })
+    ensureUserExists({ email: 'user@example.com' })
+    beforeEach(function () {
+      login('user@example.com')
+    })
+
+    it('should offer TexLive images and switch the compiler', function () {
+      const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
+      waitForCompile(() => {
+        createProjectAndOpenInNewEditor('sandboxed')
+      })
+      cy.log('wait for compile')
+      cy.findByRole('region', { name: 'PDF preview' }).should(
+        'contain.text',
+        'sandboxed'
+      )
+      cy.findByRole('button', { name: 'View logs' }).click()
+      cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => {
+        cy.log('Check which compiler version was used, expect 2023')
+        cy.findByRole('button', { name: 'Expand' }).click()
+        cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2023\) /)
+      })
+
+      cy.log('Switch TeXLive version from 2023 to 2022')
+      cy.findByRole('button', { name: 'Settings' }).click()
+      cy.findByRole('dialog').findByRole('tab', { name: 'Compiler' }).click()
+      cy.findByRole('dialog').within(() => {
+        cy.findByRole('option', { name: '2023' }).should('be.selected')
+        cy.findByRole('combobox', {
+          name: LABEL_TEX_LIVE_VERSION,
+        }).select('2022')
+      })
+      cy.get('body').type('{esc}')
+      cy.findByRole('dialog').should('not.exist')
+      cy.log('Trigger compile with other TeX Live version')
+      recompile()
+
+      cy.findByRole('button', { name: 'View logs' }).click()
+      cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => {
+        cy.log('Check which compiler version was used, expect 2022')
+        cy.findByRole('button', { name: 'Expand' }).click()
+        cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2022\) /)
+      })
+    })
+
+    checkSyncTeX()
+    checkXeTeX()
+    checkRecompilesAfterErrors()
+    checkStopCompile()
+  })
+
+  function checkStopCompile() {
+    it('users can stop a running compile', function () {
+      login('user@example.com')
+      const { recompile, waitForCompile, waitForCompileRateLimitCoolOff } =
+        prepareWaitForNextCompileSlot()
+      waitForCompile(() => {
+        createProjectAndOpenInNewEditor('test-project')
+      })
+      // create an infinite loop in the main document
+      // this will cause the compile to run indefinitely
+      cy.findByText('\\maketitle').parent().click()
+      cy.findByText('\\maketitle')
+        .parent()
+        .type('\n\\def\\x{{}Hello!\\par\\x}\\x')
+      waitForCompileRateLimitCoolOff()
+      cy.log('Start compile')
+      // We need to start the compile manually because we do not want to wait for it to finish
+      cy.findByRole('button', { name: 'Recompile' }).click()
+      // Now stop the compile and kill the latex process
+      stopCompile({ delay: 1000 })
+      cy.findByRole('region', { name: 'PDF preview' })
+        .invoke('text')
+        .should('match', /PDF Rendering Error|Compilation cancelled/)
+      // Check that the previous compile is not running in the background by
+      // disabling the infinite loop and recompiling
+      cy.findByText('\\def').parent().click()
+      cy.findByText('\\def').parent().type('{home}disabled loop% ')
+      recompile()
+      cy.findByRole('region', { name: 'PDF preview' })
+        .should('contain.text', 'disabled loop')
+        .should('not.contain.text', 'A previous compile is still running')
+    })
+  }
+
+  function checkSyncTeX() {
+    describe('SyncTeX', function () {
+      let projectName: string
+      beforeEach(function () {
+        projectName = `Project ${uuid()}`
+        const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
+        waitForCompile(() => {
+          createProjectAndOpenInNewEditor(projectName)
+        })
+        cy.findByRole('textbox', { name: 'Source Editor editing' }).within(
+          () => {
+            cy.findByText('\\maketitle').parent().click()
+            cy.findByText('\\maketitle')
+              .parent()
+              .type(
+                `\n\\pagebreak\n\\section{{}Section A}\n\\pagebreak\n\\section{{}Section B}\n\\pagebreak`
+              )
+          }
+        )
+        recompile()
+        cy.log('wait for pdf-rendering')
+        cy.findByRole('region', { name: 'PDF preview' }).findByText(projectName)
+      })
+
+      it('should sync to code', function () {
+        cy.log('navigate to \\maketitle using double click in PDF')
+        cy.findByRole('region', { name: 'PDF preview' })
+          .findByText(projectName)
+          .dblclick()
+        cy.get('.cm-activeLine').should('have.text', '\\maketitle')
+
+        cy.log('navigate to Section A using double click in PDF')
+        cy.findByRole('region', { name: 'PDF preview' })
+          .findByText('Section A')
+          .dblclick()
+        cy.get('.cm-activeLine').should('have.text', '\\section{Section A}')
+
+        cy.log('navigate to Section B using arrow button')
+        cy.findByTestId('pdfjs-viewer-inner')
+          .should('have.prop', 'scrollTop')
+          .as('start')
+        cy.findByRole('region', { name: 'PDF preview' })
+          .findByText('Section B')
+          .scrollIntoView()
+        cy.get('@start').then((start: any) => {
+          waitUntilScrollingFinished(
+            '[data-testid="pdfjs-viewer-inner"]',
+            start
+          )
+        })
+        // The sync button is swapped as the position in the PDF changes.
+        // Cypress appears to click on a button that references a stale position.
+        // Adding a cy.wait() statement is the most reliable "fix" so far :/
+        // eslint-disable-next-line cypress/no-unnecessary-waiting
+        cy.wait(1000)
+        cy.findByRole('button', {
+          name: 'Go to PDF location in code (Tip: double click on the PDF for best results)',
+        }).click()
+        cy.get('.cm-activeLine').should('have.text', '\\section{Section B}')
+      })
+
+      it('should sync to pdf', function () {
+        cy.log('zoom in')
+        cy.findByRole('button', { name: 'PDF zoom level' }).click()
+        cy.findByRole('menuitem', { name: '400%' }).click()
+        cy.log('scroll to top')
+        cy.findByTestId('pdfjs-viewer-inner').scrollTo('top')
+        waitUntilScrollingFinished('[data-testid="pdfjs-viewer-inner"]', -1).as(
+          'start'
+        )
+
+        cy.log('navigate to title')
+        cy.findByRole('textbox', { name: 'Source Editor editing' }).within(
+          () => {
+            cy.findByText('\\maketitle').parent().click()
+          }
+        )
+        cy.findByRole('button', { name: 'Go to code location in PDF' }).click()
+        cy.get('@start').then((start: any) => {
+          waitUntilScrollingFinished(
+            '[data-testid="pdfjs-viewer-inner"]',
+            start
+          )
+            .as('title')
+            .should('be.greaterThan', start)
+        })
+
+        cy.log('navigate to Section A')
+        cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() =>
+          cy.findByText('Section A').click()
+        )
+        cy.findByRole('button', { name: 'Go to code location in PDF' }).click()
+        cy.get('@title').then((title: any) => {
+          waitUntilScrollingFinished(
+            '[data-testid="pdfjs-viewer-inner"]',
+            title
+          )
+            .as('sectionA')
+            .should('be.greaterThan', title)
+        })
+
+        cy.log('navigate to Section B')
+        cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() =>
+          cy.findByText('Section B').click()
+        )
+        cy.findByRole('button', { name: 'Go to code location in PDF' }).click()
+        cy.get('@sectionA').then((title: any) => {
+          waitUntilScrollingFinished(
+            '[data-testid="pdfjs-viewer-inner"]',
+            title
+          )
+            .as('sectionB')
+            .should('be.greaterThan', title)
+        })
+      })
+    })
+  }
+
+  function checkRecompilesAfterErrors() {
+    it('recompiles even if there are Latex errors', function () {
+      login('user@example.com')
+      const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
+      waitForCompile(() => {
+        createProjectAndOpenInNewEditor('test-project')
+      })
+      cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
+        cy.findByText('\\maketitle').parent().click()
+        cy.findByText('\\maketitle')
+          .parent()
+          .type('\n\\fakeCommand{} \n\\section{{}Test Section}')
+      })
+      recompile()
+      recompile()
+      cy.findByRole('region', { name: 'PDF preview' })
+        .findByText('Test Section')
+        .should('not.contain.text', 'No PDF')
+    })
+  }
+
+  function checkXeTeX() {
+    it('should be able to use XeLaTeX', function () {
+      const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
+      waitForCompile(() => {
+        createProjectAndOpenInNewEditor('XeLaTeX')
+      })
+      cy.log('wait for compile')
+      cy.findByRole('region', { name: 'PDF preview' }).findByText('XeLaTeX')
+      cy.log('Check which compiler was used, expect pdfLaTeX')
+      cy.findByRole('button', { name: 'View logs' }).click()
+      cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => {
+        cy.log('Check which compiler was used, expect pdfLaTeX')
+        cy.findByRole('button', { name: 'Expand' }).click()
+        cy.findByText(/This is pdfTeX/)
+      })
+
+      cy.log('Switch compiler to from pdfLaTeX to XeLaTeX')
+      cy.findByRole('button', { name: 'Settings' }).click()
+      cy.findByRole('dialog').findByRole('tab', { name: 'Compiler' }).click()
+      cy.findByRole('dialog').within(() => {
+        cy.findByRole('option', { name: 'pdfLaTeX' }).should('be.selected')
+        cy.findByRole('combobox', {
+          name: 'Compiler',
+        }).select('XeLaTeX')
+      })
+
+      cy.get('body').type('{esc}')
+      cy.findByRole('dialog').should('not.exist')
+
+      cy.log('Trigger compile with other compiler')
+      recompile()
+
+      cy.findByRole('button', { name: 'View logs' }).click()
+      cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => {
+        cy.log('Check which compiler was used, expect XeLaTeX')
+        cy.findByRole('button', { name: 'Expand' }).click()
+        cy.findByText(/This is XeTeX/)
+      })
+    })
+  }
+
+  function checkUsesDefaultCompiler() {
+    beforeEach(function () {
+      login('user@example.com')
+    })
+
+    it('should not offer TexLive images and use default compiler', function () {
+      createProjectAndOpenInNewEditor('sandboxed')
+      cy.log('wait for compile')
+      cy.findByRole('region', { name: 'PDF preview' }).findByText('sandboxed')
+
+      cy.log('Check which compiler version was used, expect 2025')
+      cy.findByRole('button', { name: 'View logs' }).click()
+      cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => {
+        cy.findByRole('button', { name: 'Expand' }).click()
+        cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2025\) /)
+      })
+
+      cy.log('Check that there is no TeX Live version toggle')
+      cy.findByRole('button', { name: 'Settings' }).click()
+      cy.findByRole('dialog').findByRole('tab', { name: 'Compiler' }).click()
+      cy.findByRole('dialog').within(() => {
+        cy.findByText('Main document').should('exist')
+        cy.findByText(LABEL_TEX_LIVE_VERSION).should('not.exist')
+      })
+    })
+  }
+
+  describe('disabled in Server Pro', function () {
+    if (isExcludedBySharding('PRO_DEFAULT_2')) return
+    startWith({ pro: true })
+    ensureUserExists({ email: 'user@example.com' })
+    beforeEach(function () {
+      login('user@example.com')
+    })
+
+    checkUsesDefaultCompiler()
+    checkSyncTeX()
+    checkXeTeX()
+    checkRecompilesAfterErrors()
+    checkStopCompile()
+  })
+
+  // https://github.com/overleaf/internal/issues/20216
+  // eslint-disable-next-line mocha/no-skipped-tests
+  describe.skip('unavailable in CE', function () {
+    if (isExcludedBySharding('CE_CUSTOM_1')) return
+    startWith({ pro: false, vars: enabledVars, resetData: true })
+    ensureUserExists({ email: 'user@example.com' })
+    beforeEach(function () {
+      login('user@example.com')
+    })
+
+    checkUsesDefaultCompiler()
+    checkSyncTeX()
+    checkXeTeX()
+    checkRecompilesAfterErrors()
+    checkStopCompile()
+  })
+})

+ 281 - 0
server-ce/test/new-editor-templates.spec.ts

@@ -0,0 +1,281 @@
+import { isExcludedBySharding, startWith } from './helpers/config'
+import { ensureUserExists, login } from './helpers/login'
+import {
+  createProjectAndOpenInNewEditor,
+  NEW_PROJECT_BUTTON_MATCHER,
+  redirectEditorUrlWithQueryParams,
+} from './helpers/project'
+
+const WITHOUT_PROJECTS_USER = 'user-without-projects@example.com'
+const ADMIN_USER = 'admin@example.com'
+const REGULAR_USER = 'user@example.com'
+const TEMPLATES_USER = 'templates@example.com'
+
+// Re-use value for "exists" and "does not exist" tests
+const LABEL_BROWSE_TEMPLATES = 'Browse templates'
+
+describe('new editor.Templates', function () {
+  ensureUserExists({ email: TEMPLATES_USER })
+  ensureUserExists({ email: WITHOUT_PROJECTS_USER })
+
+  let OVERLEAF_TEMPLATES_USER_ID: string
+  before(function () {
+    login(TEMPLATES_USER)
+    cy.visit('/')
+    cy.get('meta[name="ol-user_id"]').then(el => {
+      OVERLEAF_TEMPLATES_USER_ID = el.attr('content')!
+    })
+  })
+
+  function varsFn() {
+    return {
+      OVERLEAF_TEMPLATES_USER_ID,
+      OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS:
+        '[{"name":"All Templates","url":"/templates/all"}]',
+    }
+  }
+
+  describe('enabled in Server Pro', function () {
+    if (isExcludedBySharding('PRO_CUSTOM_2')) return
+    startWith({
+      pro: true,
+      varsFn,
+    })
+    ensureUserExists({ email: REGULAR_USER })
+    ensureUserExists({ email: ADMIN_USER, isAdmin: true })
+
+    it('should show templates link on welcome page', function () {
+      login(WITHOUT_PROJECTS_USER)
+      cy.visit('/')
+      cy.findByRole('link', { name: LABEL_BROWSE_TEMPLATES }).click()
+      cy.url().should('match', /\/templates$/)
+    })
+
+    it('should have templates feature', function () {
+      login(TEMPLATES_USER)
+      const name = `Template ${Date.now()}`
+      const description = `Template Description ${Date.now()}`
+
+      cy.visit('/')
+      createProjectAndOpenInNewEditor(name, { type: 'Example project' }).as(
+        'templateProjectId'
+      )
+
+      cy.findByRole('navigation', {
+        name: 'Project actions',
+      })
+        .findByRole('button', { name: 'File' })
+        .click()
+      cy.findByRole('menuitem', { name: 'Manage template' }).click()
+
+      cy.findByLabelText('Template Description').type(description)
+      cy.findByRole('button', { name: 'Publish' }).click()
+      cy.findByRole('button', { name: 'Publishing…' }).should('be.disabled')
+      cy.findByRole('button', { name: 'Publish' }).should('not.exist')
+      cy.findByRole('button', { name: 'Unpublish', timeout: 60_000 })
+      cy.findByRole('button', { name: 'Republish' })
+
+      cy.findByRole('link', { name: 'View it in the template gallery' }).click()
+      cy.url()
+        .should('match', /\/templates\/[a-f0-9]{24}$/)
+        .as('templateURL')
+
+      cy.findByRole('heading', { level: 2 }).findByText(name)
+      cy.findByText(description)
+      cy.findByRole('link', { name: 'Open as Template' })
+      cy.findByRole('button', { name: 'Unpublish' })
+      cy.findByRole('button', { name: 'Republish' })
+      cy.get('img')
+        .should('have.attr', 'src')
+        .and('match', /\/v\/0\//)
+      cy.findByRole('button', { name: 'Republish' }).click()
+      cy.findByRole('button', { name: 'Publishing…' }).should('be.disabled')
+      cy.findByRole('button', { name: 'Republish', timeout: 60_000 })
+      cy.get('img', { timeout: 60_000 })
+        .should('have.attr', 'src')
+        .and('match', /\/v\/1\//)
+
+      // custom tag
+      const tagName = `${Date.now()}`
+      cy.visit('/')
+      cy.findByRole('checkbox', { name: `Select ${name}` }).check()
+      cy.findByRole('navigation', { name: 'Project categories and tags' })
+        .findByRole('button', { name: 'New tag' })
+        .click()
+      cy.focused().type(tagName)
+      cy.findByRole('button', { name: 'Create' }).click()
+      cy.findByRole('navigation', {
+        name: 'Project categories and tags',
+      }).should('contain', `${tagName} (1)`)
+
+      // Check listing
+      cy.visit('/templates')
+      cy.findByRole('link', { name: tagName })
+      cy.visit('/templates/all')
+      cy.findByRole('heading', { name })
+      cy.visit(`/templates/${tagName}`)
+      cy.findByRole('heading', { name })
+
+      // Unpublish via template page
+      cy.get('@templateURL').then(url => cy.visit(`${url}`))
+      cy.findByRole('button', { name: 'Unpublish' }).click()
+      cy.url().should('match', /\/templates$/)
+      cy.get('@templateURL').then(url =>
+        cy.visit(`${url}`, {
+          failOnStatusCode: false,
+        })
+      )
+      cy.findByRole('heading', { name: 'Not found' })
+      cy.visit('/templates/all')
+      cy.findByRole('heading', { name }).should('not.exist')
+      cy.visit(`/templates/${tagName}`)
+      cy.findByRole('heading', { name }).should('not.exist')
+
+      // Publish again
+      cy.get('@templateProjectId').then(projectId =>
+        cy.visit(`/project/${projectId}`)
+      )
+      cy.findByRole('navigation', {
+        name: 'Project actions',
+      })
+        .findByRole('button', { name: 'File' })
+        .click()
+      cy.findByRole('menuitem', { name: 'Manage template' }).click()
+      cy.findByRole('button', { name: 'Publish' }).click()
+      cy.findByRole('button', { name: 'Unpublish', timeout: 60_000 })
+
+      // Should assign a new template id
+      cy.findByRole('link', { name: 'View it in the template gallery' }).click()
+      cy.url()
+        .should('match', /\/templates\/[a-f0-9]{24}$/)
+        .as('newTemplateURL')
+      cy.get('@newTemplateURL').then(newURL => {
+        cy.get('@templateURL').then(prevURL => {
+          expect(newURL).to.match(/\/templates\/[a-f0-9]{24}$/)
+          expect(prevURL).to.not.equal(newURL)
+        })
+      })
+
+      // Open project from template
+      login(REGULAR_USER)
+      cy.visit('/templates')
+      cy.findByRole('link', { name: tagName }).click()
+      cy.findByRole('link', { name }).click()
+      cy.findByRole('link', { name: 'Open as Template' }).click()
+      cy.findByRole('navigation', { name: 'Project actions' }).findByText(
+        /Your Paper/i
+      ) // might have (1) suffix
+      cy.findByRole('navigation', {
+        name: 'Project actions',
+      })
+        .findByRole('button', { name: 'File' })
+        .click()
+      cy.findByRole('menuitem', { name: 'Word count' }).click() // wait for lazy loading
+      cy.findByRole('menuitem', { name: 'Manage template' }).should('not.exist')
+
+      // Check management as regular user
+      cy.get('@newTemplateURL').then(url => cy.visit(`${url}`))
+      cy.findByRole('link', { name: 'Open as Template' })
+      cy.findByRole('button', { name: 'Unpublish' }).should('not.exist')
+      cy.findByRole('button', { name: 'Republish' }).should('not.exist')
+
+      // Check management as admin user
+      login(ADMIN_USER)
+
+      redirectEditorUrlWithQueryParams(true)
+
+      cy.get('@newTemplateURL').then(url => cy.visit(`${url}`))
+      cy.findByRole('link', { name: 'Open as Template' })
+      cy.findByRole('button', { name: 'Unpublish' })
+      cy.findByRole('button', { name: 'Republish' })
+      cy.get('@templateProjectId').then(projectId =>
+        cy.visit(`/project/${projectId}`)
+      )
+      cy.findByRole('navigation', {
+        name: 'Project actions',
+      })
+        .findByRole('button', { name: 'File' })
+        .click()
+      cy.findByRole('menuitem', { name: 'Manage template' }).click()
+      cy.findByRole('button', { name: 'Unpublish' })
+
+      // Back to templates user
+      login(TEMPLATES_USER)
+
+      // Unpublish via editor
+      cy.get('@templateProjectId').then(projectId =>
+        cy.visit(`/project/${projectId}`)
+      )
+      cy.findByRole('navigation', {
+        name: 'Project actions',
+      })
+        .findByRole('button', { name: 'File' })
+        .click()
+      cy.findByRole('menuitem', { name: 'Manage template' }).click()
+      cy.findByRole('button', { name: 'Unpublish' }).click()
+      cy.findByRole('button', { name: 'Publish' })
+      cy.visit('/templates/all')
+      cy.findByRole('link', { name }).should('not.exist')
+
+      // check for template links, after creating the first project
+      cy.visit('/')
+      cy.findAllByRole('button', { name: NEW_PROJECT_BUTTON_MATCHER }).click()
+      cy.findByRole('menuitem', { name: /All Templates/ }).should(
+        'have.attr',
+        'href',
+        '/templates/all'
+      )
+    })
+  })
+
+  function checkDisabled() {
+    it('should not have templates feature', function () {
+      login(TEMPLATES_USER)
+
+      cy.visit('/')
+      createProjectAndOpenInNewEditor('maybe templates')
+
+      cy.findByRole('navigation', {
+        name: 'Project actions',
+      })
+        .findByRole('button', { name: 'File' })
+        .click()
+      cy.findByRole('menuitem', { name: 'Word count' }) // wait for lazy loading
+      cy.findByRole('menuitem', { name: 'Manage template' }).should('not.exist')
+
+      cy.visit('/templates', { failOnStatusCode: false })
+      cy.findByRole('heading', { name: 'Not found' })
+      cy.visit('/templates/all', { failOnStatusCode: false })
+      cy.findByRole('heading', { name: 'Not found' })
+
+      // check for template links, after creating the first project
+      cy.visit('/')
+      cy.findAllByRole('button', { name: NEW_PROJECT_BUTTON_MATCHER }).click()
+      cy.findByRole('menuitem', { name: /All Templates/ }).should('not.exist')
+    })
+
+    it('should not show templates link on welcome page', function () {
+      login(WITHOUT_PROJECTS_USER)
+      cy.visit('/')
+      cy.findByRole('button', { name: NEW_PROJECT_BUTTON_MATCHER }) // wait for lazy loading
+      cy.findByRole('link', { name: LABEL_BROWSE_TEMPLATES }).should(
+        'not.exist'
+      )
+    })
+  }
+
+  describe('disabled Server Pro', function () {
+    if (isExcludedBySharding('PRO_DEFAULT_2')) return
+    startWith({ pro: true })
+    checkDisabled()
+  })
+
+  describe('unavailable in CE', function () {
+    if (isExcludedBySharding('CE_CUSTOM_1')) return
+    startWith({
+      pro: false,
+      varsFn,
+    })
+    checkDisabled()
+  })
+})

+ 9 - 1
server-ce/test/templates.spec.ts

@@ -1,6 +1,10 @@
 import { isExcludedBySharding, startWith } from './helpers/config'
 import { isExcludedBySharding, startWith } from './helpers/config'
 import { ensureUserExists, login } from './helpers/login'
 import { ensureUserExists, login } from './helpers/login'
-import { createProject, NEW_PROJECT_BUTTON_MATCHER } from './helpers/project'
+import {
+  createProject,
+  NEW_PROJECT_BUTTON_MATCHER,
+  redirectEditorUrlWithQueryParams,
+} from './helpers/project'
 
 
 const WITHOUT_PROJECTS_USER = 'user-without-projects@example.com'
 const WITHOUT_PROJECTS_USER = 'user-without-projects@example.com'
 const ADMIN_USER = 'admin@example.com'
 const ADMIN_USER = 'admin@example.com'
@@ -126,6 +130,7 @@ describe('Templates', function () {
       cy.findByRole('heading', { name }).should('not.exist')
       cy.findByRole('heading', { name }).should('not.exist')
 
 
       // Publish again
       // Publish again
+      redirectEditorUrlWithQueryParams(false)
       cy.get('@templateProjectId').then(projectId =>
       cy.get('@templateProjectId').then(projectId =>
         cy.visit(`/project/${projectId}`)
         cy.visit(`/project/${projectId}`)
       )
       )
@@ -155,6 +160,7 @@ describe('Templates', function () {
       cy.visit('/templates')
       cy.visit('/templates')
       cy.findByRole('link', { name: tagName }).click()
       cy.findByRole('link', { name: tagName }).click()
       cy.findByRole('link', { name }).click()
       cy.findByRole('link', { name }).click()
+      redirectEditorUrlWithQueryParams(false)
       cy.findByRole('link', { name: 'Open as Template' }).click()
       cy.findByRole('link', { name: 'Open as Template' }).click()
       cy.findByRole('navigation', { name: 'Project actions' }).findByText(
       cy.findByRole('navigation', { name: 'Project actions' }).findByText(
         /Your Paper/i
         /Your Paper/i
@@ -179,6 +185,7 @@ describe('Templates', function () {
       cy.findByRole('link', { name: 'Open as Template' })
       cy.findByRole('link', { name: 'Open as Template' })
       cy.findByRole('button', { name: 'Unpublish' })
       cy.findByRole('button', { name: 'Unpublish' })
       cy.findByRole('button', { name: 'Republish' })
       cy.findByRole('button', { name: 'Republish' })
+      redirectEditorUrlWithQueryParams(false)
       cy.get('@templateProjectId').then(projectId =>
       cy.get('@templateProjectId').then(projectId =>
         cy.visit(`/project/${projectId}`)
         cy.visit(`/project/${projectId}`)
       )
       )
@@ -194,6 +201,7 @@ describe('Templates', function () {
       login(TEMPLATES_USER)
       login(TEMPLATES_USER)
 
 
       // Unpublish via editor
       // Unpublish via editor
+      redirectEditorUrlWithQueryParams(false)
       cy.get('@templateProjectId').then(projectId =>
       cy.get('@templateProjectId').then(projectId =>
         cy.visit(`/project/${projectId}`)
         cy.visit(`/project/${projectId}`)
       )
       )

+ 11 - 1
services/web/frontend/js/features/ide-redesign/components/rail/rail.tsx

@@ -59,6 +59,8 @@ export const RailLayout = () => {
   const { selectedTab, openTab, isOpen, togglePane } = useRailContext()
   const { selectedTab, openTab, isOpen, togglePane } = useRailContext()
   const { features } = useProjectContext()
   const { features } = useProjectContext()
   const { isRestrictedTokenMember } = useEditorContext()
   const { isRestrictedTokenMember } = useEditorContext()
+  const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
+  const { isOverleaf } = getMeta('ol-ExposedSettings')
 
 
   const { view, setLeftMenuShown } = useLayoutContext()
   const { view, setLeftMenuShown } = useLayoutContext()
 
 
@@ -93,6 +95,7 @@ export const RailLayout = () => {
         icon: 'integration_instructions',
         icon: 'integration_instructions',
         title: t('integrations'),
         title: t('integrations'),
         component: <IntegrationsPanel />,
         component: <IntegrationsPanel />,
+        hide: !isOverleaf && !gitBridgeEnabled,
       },
       },
       {
       {
         key: 'review-panel',
         key: 'review-panel',
@@ -114,7 +117,14 @@ export const RailLayout = () => {
       },
       },
       ...moduleRailEntries,
       ...moduleRailEntries,
     ],
     ],
-    [t, features.trackChangesVisible, view, isRestrictedTokenMember]
+    [
+      t,
+      features.trackChangesVisible,
+      view,
+      isRestrictedTokenMember,
+      isOverleaf,
+      gitBridgeEnabled,
+    ]
   )
   )
 
 
   const railActions: RailAction[] = useMemo(
   const railActions: RailAction[] = useMemo(

+ 11 - 7
services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/new-editor-setting.tsx

@@ -5,8 +5,10 @@ import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-
 import { useCallback } from 'react'
 import { useCallback } from 'react'
 import { useLayoutContext } from '@/shared/context/layout-context'
 import { useLayoutContext } from '@/shared/context/layout-context'
 import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
 import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
+import getMeta from '@/utils/meta'
 
 
 export default function NewEditorSetting() {
 export default function NewEditorSetting() {
+  const { isOverleaf } = getMeta('ol-ExposedSettings')
   const { t } = useTranslation()
   const { t } = useTranslation()
   const { setEditorRedesignStatus } = useSwitchEnableNewEditorState()
   const { setEditorRedesignStatus } = useSwitchEnableNewEditorState()
   const { setLeftMenuShown } = useLayoutContext()
   const { setLeftMenuShown } = useLayoutContext()
@@ -25,13 +27,15 @@ export default function NewEditorSetting() {
       description={
       description={
         <>
         <>
           <div>{t('the_new_overleaf_editor_info')}</div>
           <div>{t('the_new_overleaf_editor_info')}</div>
-          <a
-            href="https://forms.gle/3tPYhXcBVGmUB2HXA"
-            target="_blank"
-            rel="noopener noreferrer"
-          >
-            {t('share_feedback_on_the_new_editor')}
-          </a>
+          {isOverleaf && (
+            <a
+              href="https://forms.gle/3tPYhXcBVGmUB2HXA"
+              target="_blank"
+              rel="noopener noreferrer"
+            >
+              {t('share_feedback_on_the_new_editor')}
+            </a>
+          )}
         </>
         </>
       }
       }
       checked={enabled}
       checked={enabled}

+ 1 - 5
services/web/frontend/js/features/ide-redesign/utils/new-editor-utils.ts

@@ -1,17 +1,13 @@
 import { useFeatureFlag } from '@/shared/context/split-test-context'
 import { useFeatureFlag } from '@/shared/context/split-test-context'
 import { useUserSettingsContext } from '@/shared/context/user-settings-context'
 import { useUserSettingsContext } from '@/shared/context/user-settings-context'
-import getMeta from '@/utils/meta'
 
 
 // For e2e tests purposes, allow overriding to old editor
 // For e2e tests purposes, allow overriding to old editor
 export const oldEditorOverride =
 export const oldEditorOverride =
   new URLSearchParams(window.location.search).get('old-editor-override') ===
   new URLSearchParams(window.location.search).get('old-editor-override') ===
   'true'
   'true'
 
 
-// We don't want to enable the new editor on server-pro/CE until we have fully rolled it out on SaaS
-const { isOverleaf } = getMeta('ol-ExposedSettings')
-
 export const canUseNewEditor = () => {
 export const canUseNewEditor = () => {
-  return isOverleaf && !oldEditorOverride
+  return !oldEditorOverride
 }
 }
 
 
 export const useIsNewEditorEnabled = () => {
 export const useIsNewEditorEnabled = () => {