Merge pull request #32655 from overleaf/mj-preview-tabs-setting

[web] Add setting for temporary tab behaviour

GitOrigin-RevId: efef9e1db55d4498daadf13efad7fe12578cec21
This commit is contained in:
Mathias Jakobsen
2026-04-07 14:02:25 +01:00
committed by Copybot
parent 77952e5d21
commit 2d9fc99274
14 changed files with 144 additions and 61 deletions
@@ -38,6 +38,7 @@ async function buildUserSettings(req, res, user) {
autoPairDelimiters: user.ace.autoPairDelimiters,
pdfViewer: user.ace.pdfViewer,
syntaxValidation: user.ace.syntaxValidation,
previewTabs: user.ace.previewTabs ?? false,
fontFamily: user.ace.fontFamily || 'lucida',
lineHeight: user.ace.lineHeight || 'normal',
overallTheme: await getOverallTheme(req, res, user),
@@ -406,6 +406,9 @@ async function updateUserSettings(req, res, next) {
if (body.syntaxValidation != null) {
user.ace.syntaxValidation = body.syntaxValidation
}
if (body.previewTabs != null) {
user.ace.previewTabs = Boolean(body.previewTabs)
}
if (body.fontFamily != null) {
user.ace.fontFamily = body.fontFamily
}
+1
View File
@@ -101,6 +101,7 @@ export const UserSchema = new Schema(
spellCheckLanguage: { type: String, default: 'en' },
pdfViewer: { type: String, default: 'pdfjs' },
syntaxValidation: { type: Boolean },
previewTabs: { type: Boolean, default: false },
fontFamily: { type: String },
lineHeight: { type: String },
mathPreview: { type: Boolean, default: true },
@@ -1390,6 +1390,7 @@
"premium_feature": "",
"premium_plan_label": "",
"presentation_mode": "",
"preview_editor_tabs": "",
"previous_page": "",
"price": "",
"primarily_work_study_question": "",
@@ -1880,6 +1881,7 @@
"syntax_checks": "",
"syntax_validation": "",
"table": "",
"tabs_open_in_preview_mode_until_you_interact_with_them": "",
"tag_color": "",
"tag_name_cannot_exceed_characters": "",
"tag_name_is_already_used": "",
@@ -19,6 +19,7 @@ type ProjectSettingsSetterContextValue = {
setSyntaxValidation: (
syntaxValidation: UserSettings['syntaxValidation']
) => void
setPreviewTabs: (previewTabs: UserSettings['previewTabs']) => void
setMode: (mode: UserSettings['mode']) => void
setEditorTheme: (editorTheme: UserSettings['editorTheme']) => void
setEditorLightTheme: (
@@ -67,6 +68,8 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
setAutoPairDelimiters,
syntaxValidation,
setSyntaxValidation,
previewTabs,
setPreviewTabs,
editorTheme,
setEditorTheme,
editorLightTheme,
@@ -117,6 +120,8 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
setAutoPairDelimiters,
syntaxValidation,
setSyntaxValidation,
previewTabs,
setPreviewTabs,
editorTheme,
setEditorTheme,
editorLightTheme,
@@ -163,6 +168,8 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
setAutoPairDelimiters,
syntaxValidation,
setSyntaxValidation,
previewTabs,
setPreviewTabs,
editorTheme,
setEditorTheme,
editorLightTheme,
@@ -13,6 +13,7 @@ export default function useUserWideSettings() {
autoComplete,
autoPairDelimiters,
syntaxValidation,
previewTabs,
editorTheme,
editorLightTheme,
editorDarkTheme,
@@ -51,6 +52,13 @@ export default function useUserWideSettings() {
[saveUserSettings]
)
const setPreviewTabs = useCallback(
(previewTabs: UserSettings['previewTabs']) => {
saveUserSettings('previewTabs', previewTabs)
},
[saveUserSettings]
)
const setEditorTheme = useCallback(
(editorTheme: UserSettings['editorTheme']) => {
saveUserSettings('editorTheme', editorTheme)
@@ -156,6 +164,8 @@ export default function useUserWideSettings() {
setAutoPairDelimiters,
syntaxValidation,
setSyntaxValidation,
previewTabs,
setPreviewTabs,
editorTheme,
setEditorTheme,
editorLightTheme,
@@ -8,6 +8,7 @@ import { useEditorManagerContext } from './editor-manager-context'
import { debugConsole } from '@/utils/debugging'
import { disambiguatePaths } from '../util/disambiguate-paths'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
type PersistedTabInfo = { id: string; lifetime: Lifetime }
@@ -43,6 +44,8 @@ export const TabsProvider: FC<React.PropsWithChildren> = ({ children }) => {
const { openEntity } = useFileTreeOpenContext()
const { openDocWithId, openFileWithId } = useEditorManagerContext()
const tabsEnabled = isSplitTestEnabled('editor-tabs')
const { userSettings } = useUserSettingsContext()
const { previewTabs } = userSettings
const [openTabs, setOpenTabs] = usePersistedState<PersistedTabInfo[]>(
`open-tabs:${projectId}`,
@@ -183,10 +186,13 @@ export const TabsProvider: FC<React.PropsWithChildren> = ({ children }) => {
}
return [
...current.filter(tab => tab.lifetime !== 'temporary'),
{ id: openEntity.entity._id, lifetime: 'temporary' },
{
id: openEntity.entity._id,
lifetime: previewTabs ? 'temporary' : 'permanent',
},
]
})
}, [openEntity, setOpenTabs, tabsEnabled])
}, [openEntity, previewTabs, setOpenTabs, tabsEnabled])
const value = useMemo(
() => ({ tabs, openTab, closeTab, moveTab, makeTabPermanent }),
@@ -0,0 +1,18 @@
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import ToggleSetting from '../toggle-setting'
import { useTranslation } from 'react-i18next'
export default function PreviewTabsSetting() {
const { previewTabs, setPreviewTabs } = useProjectSettingsContext()
const { t } = useTranslation()
return (
<ToggleSetting
id="previewTabs"
label={t('preview_editor_tabs')}
description={t('tabs_open_in_preview_mode_until_you_interact_with_them')}
checked={previewTabs}
onChange={setPreviewTabs}
/>
)
}
@@ -3,6 +3,7 @@ import { useLayoutContext } from '@/shared/context/layout-context'
import AutoCloseBracketsSetting from '@/features/settings/components/editor-settings/auto-close-brackets-setting'
import AutoCompleteSetting from '@/features/settings/components/editor-settings/auto-complete-setting'
import CodeCheckSetting from '@/features/settings/components/editor-settings/code-check-setting'
import PreviewTabsSetting from '@/features/settings/components/editor-settings/preview-tabs-setting'
import KeybindingSetting from '@/features/settings/components/editor-settings/keybinding-setting'
import PDFViewerSetting from '@/features/settings/components/editor-settings/pdf-viewer-setting'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
@@ -90,6 +91,7 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
const { leftMenuShown, setLeftMenuShown } = useLayoutContext()
const hasEmailNotifications = useFeatureFlag('email-notifications')
const hasEditorTabs = useFeatureFlag('editor-tabs')
const allSettingsTabs: SettingsEntry[] = useMemo(
() => [
@@ -113,6 +115,11 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
key: 'syntaxValidation',
component: <CodeCheckSetting />,
},
{
key: 'previewTabs',
component: <PreviewTabsSetting />,
hidden: !hasEditorTabs,
},
{
key: 'mode',
component: <KeybindingSetting />,
@@ -264,7 +271,7 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
hidden: !isOverleaf,
},
],
[t, overallTheme, hasEmailNotifications, isOverleaf]
[t, overallTheme, hasEmailNotifications, isOverleaf, hasEditorTabs]
)
const settingsTabs = useMemo(
@@ -19,6 +19,7 @@ export const defaultSettings: UserSettings = {
autoComplete: true,
autoPairDelimiters: true,
syntaxValidation: false,
previewTabs: false,
editorTheme: 'textmate',
editorDarkTheme: 'overleaf_dark',
editorLightTheme: 'textmate',
+2
View File
@@ -1800,6 +1800,7 @@
"premium_plan_label": "Youre using <b>Overleaf Premium</b>",
"presentation": "Presentation",
"presentation_mode": "Presentation mode",
"preview_editor_tabs": "Preview editor tabs",
"previous_page": "Previous page",
"price": "Price",
"pricing": "Pricing",
@@ -2389,6 +2390,7 @@
"syntax_validation": "Code check",
"table": "Table",
"table_generator": "Table Generator",
"tabs_open_in_preview_mode_until_you_interact_with_them": "Tabs open in preview mode until you interact with them",
"tag_color": "Tag color",
"tag_name_cannot_exceed_characters": "Tag name cannot exceed __maxLength__ characters",
"tag_name_is_already_used": "Tag \"__tagName__\" already exists",
@@ -0,0 +1,51 @@
import { screen, render } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import { EditorProviders } from '../../../helpers/editor-providers'
import { SettingsModalProvider } from '@/features/settings/context/settings-modal-context'
import PreviewTabsSetting from '@/features/settings/components/editor-settings/preview-tabs-setting'
describe('<PreviewTabsSetting />', function () {
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('can toggle', async function () {
render(
<EditorProviders>
<SettingsModalProvider>
<PreviewTabsSetting />
</SettingsModalProvider>
</EditorProviders>
)
const saveSettingsMock = fetchMock.post(
`express:/user/settings`,
{
status: 200,
},
{ delay: 0 }
)
const toggle = screen.getByLabelText('Preview editor tabs')
const startingCheckedValue = (toggle as HTMLInputElement).checked
// Toggle the checkbox
toggle.click()
expect((toggle as HTMLInputElement).checked).to.equal(!startingCheckedValue)
expect(
saveSettingsMock.callHistory.called(`/user/settings`, {
body: { previewTabs: !startingCheckedValue },
})
).to.be.true
// Toggle back to original value
toggle.click()
expect((toggle as HTMLInputElement).checked).to.equal(startingCheckedValue)
expect(
saveSettingsMock.callHistory.called(`/user/settings`, {
body: { previewTabs: startingCheckedValue },
})
).to.be.true
})
})
@@ -162,6 +162,7 @@ function selectEntity(
document.dispatchEvent(
new CustomEvent('test:selectEntity', { detail: entity })
)
cy.findByRole('tab', { name: new RegExp(entity.entity.name) }).should('exist')
}
function selectDoc(id: string, path?: string[]) {
@@ -178,12 +179,13 @@ function enableEditorTabs() {
}
describe('File Tabs', function () {
function mountTabs(options?: { rootFolder?: any }) {
function mountTabs(options?: { rootFolder?: any; userSettings?: any }) {
const rootFolder = options?.rootFolder ?? defaultRootFolder
cy.mount(
<EditorProviders
rootFolder={rootFolder as any}
rootDocId={DOC_IDS.main}
userSettings={options?.userSettings}
providers={{
EditorManagerProvider: makeEditorManagerProvider(),
}}
@@ -285,6 +287,10 @@ describe('File Tabs', function () {
})
describe('Temporary tabs', function () {
beforeEach(function () {
mountTabs({ userSettings: { previewTabs: true } })
})
it('opens a newly selected file as a temporary tab', function () {
cy.then(() => selectDoc(DOC_IDS.main))
// The first tab is temporary until a keypress
@@ -346,17 +352,25 @@ describe('File Tabs', function () {
'tab-temporary'
)
})
it('opens a new tab as permanent when previewTabs is disabled', function () {
mountTabs({ userSettings: { previewTabs: false } })
cy.then(() => selectDoc(DOC_IDS.main))
cy.findByRole('tab', { name: /main\.tex/ }).should(
'not.have.class',
'tab-temporary'
)
})
})
describe('Closing tabs', function () {
it('closes a tab via the close button', function () {
// Open main (permanent)
// Open main
cy.then(() => selectDoc(DOC_IDS.main))
cy.get('body').type('a')
// Open intro (permanent)
// Open intro
cy.then(() => selectDoc(DOC_IDS.intro))
cy.get('body').type('a')
cy.findAllByRole('tab').should('have.length', 2)
@@ -384,15 +398,10 @@ describe('File Tabs', function () {
})
it('switches to an adjacent tab when closing the currently active tab', function () {
// Open three permanent tabs
// Open three tabs
cy.then(() => selectDoc(DOC_IDS.main))
cy.get('body').type('a')
cy.then(() => selectDoc(DOC_IDS.intro))
cy.get('body').type('a')
cy.then(() => selectDoc(DOC_IDS.appendix))
cy.get('body').type('a')
cy.findAllByRole('tab').should('have.length', 3)
@@ -408,12 +417,9 @@ describe('File Tabs', function () {
})
it('closes a tab on middle-click', function () {
// Open two permanent tabs
// Open two tabs
cy.then(() => selectDoc(DOC_IDS.main))
cy.get('body').type('a')
cy.then(() => selectDoc(DOC_IDS.intro))
cy.get('body').type('a')
cy.findAllByRole('tab').should('have.length', 2)
@@ -428,12 +434,9 @@ describe('File Tabs', function () {
describe('Tab interaction', function () {
it('calls openDocWithId when clicking a non-selected doc tab', function () {
// Open two permanent tabs
// Open two tabs
cy.then(() => selectDoc(DOC_IDS.main))
cy.get('body').type('a')
cy.then(() => selectDoc(DOC_IDS.intro))
cy.get('body').type('a')
// Click main tab
cy.findByRole('tab', { name: /main\.tex/ }).click()
@@ -450,15 +453,13 @@ describe('File Tabs', function () {
mountTabs({ rootFolder })
// Open main doc (permanent)
// Open main doc
cy.then(() => selectDoc(DOC_IDS.main))
cy.get('body').type('a')
// Open fileRef (permanent)
// Open fileRef
cy.then(() =>
selectEntity(makeFileRefEntity(DOC_IDS.bibFile, 'refs.bib'))
)
cy.get('body').type('a')
// Switch back to main so refs.bib is no longer selected
cy.findByRole('tab', { name: /main\.tex/ }).click()
@@ -472,15 +473,10 @@ describe('File Tabs', function () {
describe('Drag and drop', function () {
it('reorders tabs by dragging to the right of another tab', function () {
// Open three permanent tabs
// Open three tabs
cy.then(() => selectDoc(DOC_IDS.main))
cy.get('body').type('a')
cy.then(() => selectDoc(DOC_IDS.intro))
cy.get('body').type('a')
cy.then(() => selectDoc(DOC_IDS.appendix))
cy.get('body').type('a')
cy.findAllByRole('tab').should('have.length', 3)
@@ -516,15 +512,10 @@ describe('File Tabs', function () {
})
it('reorders tabs by dragging to the left of another tab', function () {
// Open three permanent tabs
// Open three tabs
cy.then(() => selectDoc(DOC_IDS.main))
cy.get('body').type('a')
cy.then(() => selectDoc(DOC_IDS.intro))
cy.get('body').type('a')
cy.then(() => selectDoc(DOC_IDS.appendix))
cy.get('body').type('a')
// Verify initial order
cy.findAllByRole('tab').eq(0).should('contain.text', 'main.tex')
@@ -558,12 +549,9 @@ describe('File Tabs', function () {
})
it('shows a drop indicator when dragging over a tab', function () {
// Open two permanent tabs
// Open two tabs
cy.then(() => selectDoc(DOC_IDS.main))
cy.get('body').type('a')
cy.then(() => selectDoc(DOC_IDS.intro))
cy.get('body').type('a')
const dataTransfer = new DataTransfer()
dataTransfer.setData(TAB_TRANSFER_TYPE, DOC_IDS.intro)
@@ -596,15 +584,10 @@ describe('File Tabs', function () {
})
it('drops onto the tablist to move a tab to the end', function () {
// Open three permanent tabs
// Open three tabs
cy.then(() => selectDoc(DOC_IDS.main))
cy.get('body').type('a')
cy.then(() => selectDoc(DOC_IDS.intro))
cy.get('body').type('a')
cy.then(() => selectDoc(DOC_IDS.appendix))
cy.get('body').type('a')
cy.findAllByRole('tab').eq(0).should('contain.text', 'main.tex')
@@ -624,12 +607,9 @@ describe('File Tabs', function () {
})
it('does nothing when dropping a tab on itself', function () {
// Open two permanent tabs
// Open two tabs
cy.then(() => selectDoc(DOC_IDS.main))
cy.get('body').type('a')
cy.then(() => selectDoc(DOC_IDS.intro))
cy.get('body').type('a')
cy.findAllByRole('tab').eq(0).should('contain.text', 'main.tex')
cy.findAllByRole('tab').eq(1).should('contain.text', 'intro.tex')
@@ -686,19 +666,16 @@ describe('File Tabs', function () {
// Open main.tex (unique name, no disambiguation needed)
cy.then(() => selectDoc(DOC_IDS.main))
cy.get('body').type('a')
// Open intro.tex from chapter-a
cy.then(() =>
selectDoc(DOC_IDS.introA, ['root-folder-id', FOLDER_IDS.chapterA])
)
cy.get('body').type('a')
// Open intro.tex from chapter-b
cy.then(() =>
selectDoc(DOC_IDS.introB, ['root-folder-id', FOLDER_IDS.chapterB])
)
cy.get('body').type('a')
cy.findAllByRole('tab').should('have.length', 3)
@@ -721,7 +698,6 @@ describe('File Tabs', function () {
cy.then(() =>
selectDoc(DOC_IDS.introA, ['root-folder-id', FOLDER_IDS.chapterA])
)
cy.get('body').type('a')
// With only one intro.tex open, no disambiguation is needed
cy.findByRole('tab', { name: /intro\.tex/ }).should('exist')
@@ -744,13 +720,11 @@ describe('File Tabs', function () {
// Open main doc
cy.then(() => selectDoc(DOC_IDS.main))
cy.get('body').type('a')
// Open a fileRef
cy.then(() =>
selectEntity(makeFileRefEntity(DOC_IDS.bibFile, 'refs.bib'))
)
cy.get('body').type('a')
cy.findByRole('tab', { name: /main\.tex/ }).should('exist')
cy.findByRole('tab', { name: /refs\.bib/ }).should('exist')
@@ -769,9 +743,8 @@ describe('File Tabs', function () {
const rootFolder = makeRootFolder(manyDocs)
mountTabs({ rootFolder })
// Open main and make it permanent
// Open main
cy.then(() => selectDoc(DOC_IDS.main))
cy.get('body').type('a')
// Open all chapter tabs first so they push main.tex out of view
for (let i = 1; i <= 10; i++) {
+1
View File
@@ -15,6 +15,7 @@ export type UserSettings = {
autoComplete: boolean
autoPairDelimiters: boolean
syntaxValidation: boolean
previewTabs: boolean
editorTheme: string
editorLightTheme: string
editorDarkTheme: string