Jelajahi Sumber

Merge pull request #34162 from overleaf/mj-tabs-close-to-right

[web] Context menu updates for tabs

GitOrigin-RevId: 09dbecaf119237adfb9b779a46bf76103c12de8e
Mathias Jakobsen 4 bulan lalu
induk
melakukan
ddfff06ef8

+ 3 - 2
services/web/frontend/extracted-translations.json

@@ -311,8 +311,8 @@
   "clone_with_git": "",
   "close": "",
   "close_dialog": "",
-  "close_others": "",
-  "close_tab": "",
+  "close_other_tabs": "",
+  "close_tabs_to_the_right": "",
   "clsi_maintenance": "",
   "clsi_unavailable": "",
   "code": "",
@@ -2014,6 +2014,7 @@
   "syntax_checks": "",
   "syntax_validation": "",
   "tab_connecting": "",
+  "tab_settings": "",
   "table": "",
   "tabs_open_in_preview_mode_until_you_interact_with_them": "",
   "tag_color": "",

+ 29 - 0
services/web/frontend/js/features/ide-react/context/tabs-context.tsx

@@ -48,6 +48,7 @@ const TabsContext = React.createContext<
       closeTab: (id: string) => void
       closeOtherTabs: (id: string) => void
       makeTabPermanent: (id: string) => void
+      closeToRight: (id: string) => void
       moveTab: (
         sourceTabId: string,
         targetTabId: string,
@@ -174,6 +175,32 @@ export const TabsProvider: FC<React.PropsWithChildren> = ({ children }) => {
     [openEntity, openTab, setOpenTabs]
   )
 
+  const closeToRight = useCallback(
+    async (id: string) => {
+      const tabIndex = openTabs.findIndex(tab => tab.id === id)
+      if (tabIndex === -1) {
+        debugConsole.warn(
+          'Attempting to close to right on tab that is not open'
+        )
+        return
+      }
+      const openTabIndex = openTabs.findIndex(
+        tab => tab.id === openEntity?.entity._id
+      )
+      if (openTabIndex === -1 || openTabIndex > tabIndex) {
+        await openTab(id)
+      }
+      setOpenTabs(current => {
+        const currentIndex = current.findIndex(tab => tab.id === id)
+        if (currentIndex === -1) {
+          return current
+        }
+        return current.slice(0, currentIndex + 1)
+      })
+    },
+    [setOpenTabs, openTabs, openEntity, openTab]
+  )
+
   const moveTab = useCallback(
     (sourceTabId: string, targetTabId: string, position: 'left' | 'right') => {
       debugConsole.log({ sourceTabId, targetTabId, position })
@@ -269,6 +296,7 @@ export const TabsProvider: FC<React.PropsWithChildren> = ({ children }) => {
       makeTabPermanent,
       contextMenuTarget,
       setContextMenuTarget,
+      closeToRight,
     }),
     [
       tabs,
@@ -279,6 +307,7 @@ export const TabsProvider: FC<React.PropsWithChildren> = ({ children }) => {
       makeTabPermanent,
       contextMenuTarget,
       setContextMenuTarget,
+      closeToRight,
     ]
   )
 

+ 4 - 0
services/web/frontend/js/features/settings/hooks/use-focus-on-setting.tsx

@@ -24,6 +24,10 @@ export default function useFocusOnSetting() {
 
       const settingToFocusElt: HTMLElement | null =
         settingElt?.querySelector('input, select, button') ?? settingElt
+      settingElt?.classList.add('ide-setting-highlighted')
+      setTimeout(() => {
+        settingElt?.classList.remove('ide-setting-highlighted')
+      }, 2000)
 
       setActiveTab(newActiveTab)
       setEltToScrollTo({ tab: newActiveTab, element: settingToFocusElt })

+ 1 - 1
services/web/frontend/js/features/source-editor/components/tabs/tab.tsx

@@ -231,7 +231,7 @@ export const Tab = memo(function Tab({
           <button
             onClick={onCloseClick}
             className="editor-file-tab-close-action"
-            aria-label={t('close_tab')}
+            aria-label={t('close')}
           >
             <MaterialIcon type="close" />
           </button>

+ 33 - 2
services/web/frontend/js/features/source-editor/components/tabs/tabs-context-menu.tsx

@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom'
 import { useTranslation } from 'react-i18next'
 import {
   Dropdown,
+  DropdownDivider,
   DropdownItem,
   DropdownMenu,
 } from '@/shared/components/dropdown/dropdown-menu'
@@ -14,6 +15,7 @@ export function TabsContextMenu() {
     tabs,
     closeTab,
     closeOtherTabs,
+    closeToRight,
     contextMenuTarget,
     setContextMenuTarget,
   } = useTabsContext()
@@ -89,7 +91,7 @@ export function TabsContextMenu() {
               close()
             }}
           >
-            {t('close_tab')}
+            {t('close')}
           </DropdownItem>
           <DropdownItem
             disabled={tabs.length <= 1}
@@ -99,7 +101,36 @@ export function TabsContextMenu() {
               close()
             }}
           >
-            {t('close_others')}
+            {t('close_other_tabs')}
+          </DropdownItem>
+          <DropdownItem
+            disabled={tabs[tabs.length - 1]?.id === contextMenuTarget.tabId}
+            as="button"
+            onClick={() => {
+              closeToRight(contextMenuTarget.tabId)
+              close()
+            }}
+          >
+            {t('close_tabs_to_the_right')}
+          </DropdownItem>
+          <DropdownDivider />
+          <DropdownItem
+            as="button"
+            onClick={() => {
+              window.dispatchEvent(
+                new CustomEvent('ui.toggle-left-menu', { detail: true })
+              )
+              // focus the tab setting
+              window.dispatchEvent(
+                new CustomEvent('ui.focus-setting', {
+                  detail: 'previewTabs',
+                })
+              )
+              close()
+            }}
+            leadingIcon="settings"
+          >
+            {t('tab_settings')}
           </DropdownItem>
         </DropdownMenu>
       </Dropdown>

+ 10 - 2
services/web/frontend/stylesheets/pages/editor/settings.scss

@@ -10,7 +10,7 @@
   max-height: 75%;
   height: 500px;
   width: 100%;
-  padding: 0 var(--spacing-06);
+  padding: 0 var(--spacing-04);
   overflow: auto;
 }
 
@@ -65,6 +65,7 @@
   line-height: var(--line-height-01);
   padding-bottom: var(--spacing-06);
   border-bottom: 1px solid var(--border-divider);
+  margin: 0 var(--spacing-04);
 }
 
 .ide-setting {
@@ -72,6 +73,9 @@
   justify-content: space-between;
   align-items: center;
   min-height: 48px;
+  padding: 0 var(--spacing-02);
+  margin: 0 var(--spacing-02);
+  border-radius: var(--border-radius-base);
 }
 
 .ide-dropdown-setting {
@@ -96,7 +100,7 @@
   min-width: 160px;
   display: flex;
   justify-content: flex-end;
-  margin-left: var(--spacing-06);
+  margin-left: var(--spacing-05);
 }
 
 .ide-radio-setting-options {
@@ -116,3 +120,7 @@
   align-items: center;
   gap: var(--spacing-05);
 }
+
+.ide-setting-highlighted {
+  box-shadow: 0 1px 10px 0 rgb(102 175 233 / 80%);
+}

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

@@ -409,8 +409,8 @@
   "clone_with_git": "Clone with Git",
   "close": "Close",
   "close_dialog": "Close dialog",
-  "close_others": "Close others",
-  "close_tab": "Close tab",
+  "close_other_tabs": "Close other tabs",
+  "close_tabs_to_the_right": "Close tabs to the right",
   "clsi_maintenance": "The compile servers are down for maintenance, and will be back shortly.",
   "clsi_unavailable": "Sorry, the compile server for your project was temporarily unavailable. Please try again in a few moments.",
   "cn": "Chinese (Simplified)",
@@ -2602,6 +2602,7 @@
   "syntax_checks": "Syntax checks",
   "syntax_validation": "Code check",
   "tab_connecting": "Connecting with the editor",
+  "tab_settings": "Tab settings…",
   "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",

+ 125 - 15
services/web/test/frontend/features/source-editor/components/tabs.spec.tsx

@@ -450,7 +450,7 @@ describe('File Tabs', function () {
 
       // Close intro
       cy.findByRole('tab', { name: /intro\.tex/ }).within(() => {
-        cy.findByRole('button', { name: 'Close tab' }).click()
+        cy.findByRole('button', { name: 'Close' }).click()
       })
 
       cy.findByRole('tab', { name: /intro\.tex/ }).should('not.exist')
@@ -463,7 +463,7 @@ describe('File Tabs', function () {
 
       // Attempt to close the only tab
       cy.findByRole('tab', { name: /main\.tex/ }).within(() => {
-        cy.findByRole('button', { name: 'Close tab' }).click()
+        cy.findByRole('button', { name: 'Close' }).click()
       })
 
       // Tab must still exist
@@ -481,7 +481,7 @@ describe('File Tabs', function () {
 
       // Close the currently selected tab (appendix — the last one selected)
       cy.findByRole('tab', { name: /appendix\.tex/ }).within(() => {
-        cy.findByRole('button', { name: 'Close tab' }).click()
+        cy.findByRole('button', { name: 'Close' }).click()
       })
 
       cy.findByRole('tab', { name: /appendix\.tex/ }).should('not.exist')
@@ -514,23 +514,27 @@ describe('File Tabs', function () {
       cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
 
       cy.findByRole('menu').should('exist')
-      cy.findByRole('menuitem', { name: 'Close tab' }).should('exist')
-      cy.findByRole('menuitem', { name: 'Close others' }).should('exist')
+      cy.findByRole('menuitem', { name: 'Close' }).should('exist')
+      cy.findByRole('menuitem', { name: 'Close other tabs' }).should('exist')
+      cy.findByRole('menuitem', { name: 'Close tabs to the right' }).should(
+        'exist'
+      )
+      cy.findByRole('menuitem', { name: 'Tab settings…' }).should('exist')
     })
 
-    it('closes the clicked tab via "Close tab"', function () {
+    it('closes the clicked tab via "Close"', function () {
       cy.then(() => selectDoc(DOC_IDS.main))
       cy.then(() => selectDoc(DOC_IDS.intro))
 
       cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
-      cy.findByRole('menuitem', { name: 'Close tab' }).click()
+      cy.findByRole('menuitem', { name: 'Close' }).click()
 
       cy.findByRole('tab', { name: /intro\.tex/ }).should('not.exist')
       cy.findByRole('tab', { name: /main\.tex/ }).should('exist')
       cy.findByRole('menu').should('not.exist')
     })
 
-    it('closes all other tabs via "Close others"', function () {
+    it('closes all other tabs via "Close other tabs"', function () {
       cy.then(() => selectDoc(DOC_IDS.main))
       cy.then(() => selectDoc(DOC_IDS.intro))
       cy.then(() => selectDoc(DOC_IDS.appendix))
@@ -538,7 +542,7 @@ describe('File Tabs', function () {
       cy.findAllByRole('tab').should('have.length', 3)
 
       cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
-      cy.findByRole('menuitem', { name: 'Close others' }).click()
+      cy.findByRole('menuitem', { name: 'Close other tabs' }).click()
 
       cy.findAllByRole('tab').should('have.length', 1)
       cy.findByRole('tab', { name: /intro\.tex/ }).should('exist')
@@ -551,28 +555,134 @@ describe('File Tabs', function () {
 
       // appendix is the currently active tab; close others from intro
       cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
-      cy.findByRole('menuitem', { name: 'Close others' }).click()
+      cy.findByRole('menuitem', { name: 'Close other tabs' }).click()
+
+      cy.get('@openDocWithId').should('have.been.calledWith', DOC_IDS.intro)
+    })
+
+    it('closes tabs to the right of the target via "Close tabs to the right"', function () {
+      cy.then(() => selectDoc(DOC_IDS.main))
+      cy.then(() => selectDoc(DOC_IDS.intro))
+      cy.then(() => selectDoc(DOC_IDS.appendix))
+
+      cy.findAllByRole('tab').should('have.length', 3)
+
+      cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
+      cy.findByRole('menuitem', { name: 'Close tabs to the right' }).click()
+
+      cy.findAllByRole('tab').should('have.length', 2)
+      cy.findByRole('tab', { name: /main\.tex/ }).should('exist')
+      cy.findByRole('tab', { name: /intro\.tex/ }).should('exist')
+      cy.findByRole('tab', { name: /appendix\.tex/ }).should('not.exist')
+    })
+
+    it('navigates to the target tab when the active tab is closed to the right', function () {
+      cy.then(() => selectDoc(DOC_IDS.main))
+      cy.then(() => selectDoc(DOC_IDS.intro))
+      cy.then(() => selectDoc(DOC_IDS.appendix))
+
+      // appendix is the active tab; closing to the right of intro removes it
+      cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
+      cy.findByRole('menuitem', { name: 'Close tabs to the right' }).click()
 
       cy.get('@openDocWithId').should('have.been.calledWith', DOC_IDS.intro)
     })
 
-    it('disables both items when only one tab is open', function () {
+    it('keeps the active tab selected when it is to the left of the target', function () {
+      cy.then(() => selectDoc(DOC_IDS.main))
+      cy.then(() => selectDoc(DOC_IDS.intro))
+      cy.then(() => selectDoc(DOC_IDS.appendix))
+
+      // Re-select main so it becomes the active tab again (still at index 0)
+      cy.then(() => selectDoc(DOC_IDS.main))
+
+      // Closing to the right of intro removes appendix but not the active tab
+      cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
+      cy.findByRole('menuitem', { name: 'Close tabs to the right' }).click()
+
+      cy.findAllByRole('tab').should('have.length', 2)
+      cy.findByRole('tab', { name: /appendix\.tex/ }).should('not.exist')
+      cy.findByRole('tab', { name: /main\.tex/ }).should(
+        'have.attr',
+        'aria-selected',
+        'true'
+      )
+    })
+
+    it('disables the close actions when only one tab is open', function () {
       cy.then(() => selectDoc(DOC_IDS.main))
 
       cy.findByRole('tab', { name: /main\.tex/ }).rightclick()
 
-      cy.findByRole('menuitem', { name: 'Close tab' }).should(
+      cy.findByRole('menuitem', { name: 'Close' }).should(
+        'have.attr',
+        'aria-disabled',
+        'true'
+      )
+      cy.findByRole('menuitem', { name: 'Close other tabs' }).should(
         'have.attr',
         'aria-disabled',
         'true'
       )
-      cy.findByRole('menuitem', { name: 'Close others' }).should(
+      cy.findByRole('menuitem', { name: 'Close tabs to the right' }).should(
         'have.attr',
         'aria-disabled',
         'true'
       )
     })
 
+    it('disables "Close tabs to the right" when the target is the last tab', function () {
+      cy.then(() => selectDoc(DOC_IDS.main))
+      cy.then(() => selectDoc(DOC_IDS.intro))
+      cy.then(() => selectDoc(DOC_IDS.appendix))
+
+      // appendix is the rightmost tab, so there is nothing to close to its right
+      cy.findByRole('tab', { name: /appendix\.tex/ }).rightclick()
+
+      cy.findByRole('menuitem', { name: 'Close tabs to the right' }).should(
+        'have.attr',
+        'aria-disabled',
+        'true'
+      )
+      // The other actions remain enabled with more than one tab open
+      cy.findByRole('menuitem', { name: 'Close' }).should(
+        'not.have.attr',
+        'aria-disabled',
+        'true'
+      )
+      cy.findByRole('menuitem', { name: 'Close other tabs' }).should(
+        'not.have.attr',
+        'aria-disabled',
+        'true'
+      )
+    })
+
+    it('opens tab settings via "Tab settings…"', function () {
+      cy.then(() => selectDoc(DOC_IDS.main))
+      cy.then(() => selectDoc(DOC_IDS.intro))
+
+      cy.window().then(win => {
+        cy.spy(win, 'dispatchEvent').as('dispatchEvent')
+      })
+
+      cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
+      cy.findByRole('menuitem', { name: 'Tab settings…' }).click()
+
+      // opens the left settings menu...
+      cy.get('@dispatchEvent').should(
+        'have.been.calledWithMatch',
+        Cypress.sinon.match({ type: 'ui.toggle-left-menu', detail: true })
+      )
+      // ...and focuses the previewTabs setting
+      cy.get('@dispatchEvent').should(
+        'have.been.calledWithMatch',
+        Cypress.sinon.match({ type: 'ui.focus-setting', detail: 'previewTabs' })
+      )
+
+      // and the context menu closes
+      cy.findByRole('menu').should('not.exist')
+    })
+
     it('closes the menu on Escape', function () {
       cy.then(() => selectDoc(DOC_IDS.main))
       cy.then(() => selectDoc(DOC_IDS.intro))
@@ -631,7 +741,7 @@ describe('File Tabs', function () {
       cy.findByRole('tab', { name: /appendix\.tex/ }).rightclick({
         force: true,
       })
-      cy.findByRole('menuitem', { name: 'Close others' }).click()
+      cy.findByRole('menuitem', { name: 'Close other tabs' }).click()
 
       cy.findAllByRole('tab').should('have.length', 1)
       cy.findByRole('tab', { name: /appendix\.tex/ }).should('exist')
@@ -1040,7 +1150,7 @@ describe('File Tabs', function () {
       cy.findByRole('tab', { name: /appendix\.tex/ }).should('exist')
 
       cy.findByRole('tab', { name: /appendix\.tex/ }).within(() => {
-        cy.findByRole('button', { name: 'Close tab' }).click()
+        cy.findByRole('button', { name: 'Close' }).click()
       })
 
       cy.findAllByRole('tab').should('have.length', 1)