From ddfff06ef8fe5e1634dd459d3a4383ccdfcedf74 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Fri, 5 Jun 2026 11:44:34 +0100 Subject: [PATCH] Merge pull request #34162 from overleaf/mj-tabs-close-to-right [web] Context menu updates for tabs GitOrigin-RevId: 09dbecaf119237adfb9b779a46bf76103c12de8e --- .../web/frontend/extracted-translations.json | 5 +- .../ide-react/context/tabs-context.tsx | 29 ++++ .../settings/hooks/use-focus-on-setting.tsx | 4 + .../source-editor/components/tabs/tab.tsx | 2 +- .../components/tabs/tabs-context-menu.tsx | 35 ++++- .../stylesheets/pages/editor/settings.scss | 12 +- services/web/locales/en.json | 5 +- .../source-editor/components/tabs.spec.tsx | 140 ++++++++++++++++-- 8 files changed, 208 insertions(+), 24 deletions(-) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 24c8e0a40b..c053303570 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", 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 57cd1bb299..e5cfcbde2b 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 @@ -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 = ({ 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 = ({ children }) => { makeTabPermanent, contextMenuTarget, setContextMenuTarget, + closeToRight, }), [ tabs, @@ -279,6 +307,7 @@ export const TabsProvider: FC = ({ children }) => { makeTabPermanent, contextMenuTarget, setContextMenuTarget, + closeToRight, ] ) diff --git a/services/web/frontend/js/features/settings/hooks/use-focus-on-setting.tsx b/services/web/frontend/js/features/settings/hooks/use-focus-on-setting.tsx index 023d447118..bd8676eaaf 100644 --- a/services/web/frontend/js/features/settings/hooks/use-focus-on-setting.tsx +++ b/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 }) diff --git a/services/web/frontend/js/features/source-editor/components/tabs/tab.tsx b/services/web/frontend/js/features/source-editor/components/tabs/tab.tsx index f5a1aeedd7..fa9e15045c 100644 --- a/services/web/frontend/js/features/source-editor/components/tabs/tab.tsx +++ b/services/web/frontend/js/features/source-editor/components/tabs/tab.tsx @@ -231,7 +231,7 @@ export const Tab = memo(function Tab({ diff --git a/services/web/frontend/js/features/source-editor/components/tabs/tabs-context-menu.tsx b/services/web/frontend/js/features/source-editor/components/tabs/tabs-context-menu.tsx index b2095bea4e..774c78c360 100644 --- a/services/web/frontend/js/features/source-editor/components/tabs/tabs-context-menu.tsx +++ b/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')} - {t('close_others')} + {t('close_other_tabs')} + + { + closeToRight(contextMenuTarget.tabId) + close() + }} + > + {t('close_tabs_to_the_right')} + + + { + 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')} diff --git a/services/web/frontend/stylesheets/pages/editor/settings.scss b/services/web/frontend/stylesheets/pages/editor/settings.scss index 317d71c131..efa77c715d 100644 --- a/services/web/frontend/stylesheets/pages/editor/settings.scss +++ b/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%); +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index df5e4d37ae..40ce28eb1f 100644 --- a/services/web/locales/en.json +++ b/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", 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 f9e69313c2..11e150ea73 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 @@ -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,26 +555,132 @@ 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('disables both items when only one tab is open', function () { + 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('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 others' }).should( + cy.findByRole('menuitem', { name: 'Close other tabs' }).should( 'have.attr', 'aria-disabled', 'true' ) + 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 () { @@ -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)