فهرست منبع

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

[web] Add setting for temporary tab behaviour

GitOrigin-RevId: efef9e1db55d4498daadf13efad7fe12578cec21
Mathias Jakobsen 6 ماه پیش
والد
کامیت
2d9fc99274

+ 1 - 0
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),

+ 3 - 0
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
   }

+ 1 - 0
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 },

+ 2 - 0
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": "",

+ 7 - 0
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<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,

+ 10 - 0
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,

+ 8 - 2
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<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 }),

+ 18 - 0
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 (
+    <ToggleSetting
+      id="previewTabs"
+      label={t('preview_editor_tabs')}
+      description={t('tabs_open_in_preview_mode_until_you_interact_with_them')}
+      checked={previewTabs}
+      onChange={setPreviewTabs}
+    />
+  )
+}

+ 8 - 1
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<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(

+ 1 - 0
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',

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

@@ -1800,6 +1800,7 @@
   "premium_plan_label": "You’re 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",

+ 51 - 0
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('<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
+  })
+})

+ 31 - 58
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(
       <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 - 0
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