diff --git a/services/web/app/src/Features/Project/UserSettingsHelper.mjs b/services/web/app/src/Features/Project/UserSettingsHelper.mjs index 74c10a2c7f..3b78eb9ae7 100644 --- a/services/web/app/src/Features/Project/UserSettingsHelper.mjs +++ b/services/web/app/src/Features/Project/UserSettingsHelper.mjs @@ -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), diff --git a/services/web/app/src/Features/User/UserController.mjs b/services/web/app/src/Features/User/UserController.mjs index 644eb3889d..97dbaab941 100644 --- a/services/web/app/src/Features/User/UserController.mjs +++ b/services/web/app/src/Features/User/UserController.mjs @@ -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 } diff --git a/services/web/app/src/models/User.mjs b/services/web/app/src/models/User.mjs index 42db5b822a..134bb72b00 100644 --- a/services/web/app/src/models/User.mjs +++ b/services/web/app/src/models/User.mjs @@ -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 }, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 0fca97aa8e..6dbdc904b5 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx b/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx index b456d596c2..cf1109c33f 100644 --- a/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx +++ b/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx @@ -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 = ({ setAutoPairDelimiters, syntaxValidation, setSyntaxValidation, + previewTabs, + setPreviewTabs, editorTheme, setEditorTheme, editorLightTheme, @@ -117,6 +120,8 @@ export const ProjectSettingsProvider: FC = ({ setAutoPairDelimiters, syntaxValidation, setSyntaxValidation, + previewTabs, + setPreviewTabs, editorTheme, setEditorTheme, editorLightTheme, @@ -163,6 +168,8 @@ export const ProjectSettingsProvider: FC = ({ setAutoPairDelimiters, syntaxValidation, setSyntaxValidation, + previewTabs, + setPreviewTabs, editorTheme, setEditorTheme, editorLightTheme, diff --git a/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx b/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx index 1d6849b0ea..5b49066b3b 100644 --- a/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx +++ b/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx @@ -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, diff --git a/services/web/frontend/js/features/ide-react/context/tabs-context.tsx b/services/web/frontend/js/features/ide-react/context/tabs-context.tsx index c5d2e307a3..9568393823 100644 --- a/services/web/frontend/js/features/ide-react/context/tabs-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/tabs-context.tsx @@ -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 = ({ children }) => { const { openEntity } = useFileTreeOpenContext() const { openDocWithId, openFileWithId } = useEditorManagerContext() const tabsEnabled = isSplitTestEnabled('editor-tabs') + const { userSettings } = useUserSettingsContext() + const { previewTabs } = userSettings const [openTabs, setOpenTabs] = usePersistedState( `open-tabs:${projectId}`, @@ -183,10 +186,13 @@ export const TabsProvider: FC = ({ 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 }), diff --git a/services/web/frontend/js/features/settings/components/editor-settings/preview-tabs-setting.tsx b/services/web/frontend/js/features/settings/components/editor-settings/preview-tabs-setting.tsx new file mode 100644 index 0000000000..c4ef544c0a --- /dev/null +++ b/services/web/frontend/js/features/settings/components/editor-settings/preview-tabs-setting.tsx @@ -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 ( + + ) +} diff --git a/services/web/frontend/js/features/settings/context/settings-modal-context.tsx b/services/web/frontend/js/features/settings/context/settings-modal-context.tsx index 9b6b3a480e..d9539e3e7f 100644 --- a/services/web/frontend/js/features/settings/context/settings-modal-context.tsx +++ b/services/web/frontend/js/features/settings/context/settings-modal-context.tsx @@ -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 = ({ 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 = ({ key: 'syntaxValidation', component: , }, + { + key: 'previewTabs', + component: , + hidden: !hasEditorTabs, + }, { key: 'mode', component: , @@ -264,7 +271,7 @@ export const SettingsModalProvider: FC = ({ hidden: !isOverleaf, }, ], - [t, overallTheme, hasEmailNotifications, isOverleaf] + [t, overallTheme, hasEmailNotifications, isOverleaf, hasEditorTabs] ) const settingsTabs = useMemo( diff --git a/services/web/frontend/js/shared/context/user-settings-context.tsx b/services/web/frontend/js/shared/context/user-settings-context.tsx index 2d12ccd874..3431134eef 100644 --- a/services/web/frontend/js/shared/context/user-settings-context.tsx +++ b/services/web/frontend/js/shared/context/user-settings-context.tsx @@ -19,6 +19,7 @@ export const defaultSettings: UserSettings = { autoComplete: true, autoPairDelimiters: true, syntaxValidation: false, + previewTabs: false, editorTheme: 'textmate', editorDarkTheme: 'overleaf_dark', editorLightTheme: 'textmate', diff --git a/services/web/locales/en.json b/services/web/locales/en.json index f51b0b1818..466abd828b 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1800,6 +1800,7 @@ "premium_plan_label": "You’re using Overleaf Premium", "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", diff --git a/services/web/test/frontend/features/settings-modal/settings/preview-tabs-setting.test.tsx b/services/web/test/frontend/features/settings-modal/settings/preview-tabs-setting.test.tsx new file mode 100644 index 0000000000..d70d1acc34 --- /dev/null +++ b/services/web/test/frontend/features/settings-modal/settings/preview-tabs-setting.test.tsx @@ -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('', function () { + afterEach(function () { + fetchMock.removeRoutes().clearHistory() + }) + + it('can toggle', async function () { + render( + + + + + + ) + + 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 + }) +}) diff --git a/services/web/test/frontend/features/source-editor/components/tabs.spec.tsx b/services/web/test/frontend/features/source-editor/components/tabs.spec.tsx index 708450b41e..e47b46ea34 100644 --- a/services/web/test/frontend/features/source-editor/components/tabs.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/tabs.spec.tsx @@ -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( 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++) { diff --git a/services/web/types/user-settings.ts b/services/web/types/user-settings.ts index 75373dfa5b..3a53d69e3d 100644 --- a/services/web/types/user-settings.ts +++ b/services/web/types/user-settings.ts @@ -15,6 +15,7 @@ export type UserSettings = { autoComplete: boolean autoPairDelimiters: boolean syntaxValidation: boolean + previewTabs: boolean editorTheme: string editorLightTheme: string editorDarkTheme: string