diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 518aa0b107..1925d546a0 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -298,6 +298,7 @@ "clone_with_git": "", "close": "", "close_dialog": "", + "close_others": "", "close_tab": "", "clsi_maintenance": "", "clsi_unavailable": "", 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 6b6865ffb6..b7b95eb92c 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 @@ -2,7 +2,14 @@ import { findInTree } from '@/features/file-tree/util/find-in-tree' import { useFileTreeData } from '@/shared/context/file-tree-data-context' import { useProjectContext } from '@/shared/context/project-context' import usePersistedState from '@/shared/hooks/use-persisted-state' -import React, { FC, useCallback, useContext, useEffect, useMemo } from 'react' +import React, { + FC, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' import { useFileTreeOpenContext } from './file-tree-open-context' import { useEditorManagerContext } from './editor-manager-context' import { debugConsole } from '@/utils/debugging' @@ -28,17 +35,28 @@ export type EditorFileTab = { export const TAB_TRANSFER_TYPE = 'text/x.tab-id' +export type TabsContextMenuTarget = { + top: number + left: number + tabId: string +} + const TabsContext = React.createContext< | { tabs: EditorFileTab[] openTab: (id: string) => void closeTab: (id: string) => void + closeOtherTabs: (id: string) => void makeTabPermanent: (id: string) => void moveTab: ( sourceTabId: string, targetTabId: string, position: 'left' | 'right' ) => void + contextMenuTarget: TabsContextMenuTarget | null + setContextMenuTarget: React.Dispatch< + React.SetStateAction + > } | undefined >(undefined) @@ -58,6 +76,9 @@ export const TabsProvider: FC = ({ children }) => { [] ) + const [contextMenuTarget, setContextMenuTarget] = + useState(null) + const tabs = useMemo(() => { if (!tabsEnabled) { return [] @@ -143,6 +164,16 @@ export const TabsProvider: FC = ({ children }) => { [openTabs, openEntity, setOpenTabs, openTab] ) + const closeOtherTabs = useCallback( + async (id: string) => { + if (id !== openEntity?.entity._id) { + await openTab(id) + } + setOpenTabs(current => current.filter(tab => tab.id === id)) + }, + [openEntity, openTab, setOpenTabs] + ) + const moveTab = useCallback( (sourceTabId: string, targetTabId: string, position: 'left' | 'right') => { debugConsole.log({ sourceTabId, targetTabId, position }) @@ -211,8 +242,26 @@ export const TabsProvider: FC = ({ children }) => { }, [openEntity, previewTabs, setOpenTabs, tabsEnabled]) const value = useMemo( - () => ({ tabs, openTab, closeTab, moveTab, makeTabPermanent }), - [tabs, openTab, closeTab, moveTab, makeTabPermanent] + () => ({ + tabs, + openTab, + closeTab, + closeOtherTabs, + moveTab, + makeTabPermanent, + contextMenuTarget, + setContextMenuTarget, + }), + [ + tabs, + openTab, + closeTab, + closeOtherTabs, + moveTab, + makeTabPermanent, + contextMenuTarget, + setContextMenuTarget, + ] ) return {children} 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 a417f11fa2..cbd9b90c18 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 @@ -23,6 +23,11 @@ type TabProps = { openTab: (id: string) => void closeTab: (id: string) => void makeTabPermanent: (id: string) => void + openContextMenu: ( + coords: { top: number; left: number }, + tabId: string + ) => void + closeContextMenu: () => void isSelected: boolean onTabDrop: ( sourceTabId: string, @@ -48,6 +53,8 @@ export const Tab = memo(function Tab({ openTab, closeTab, makeTabPermanent, + openContextMenu, + closeContextMenu, isSelected, onTabDrop, }: TabProps) { @@ -153,6 +160,18 @@ export const Tab = memo(function Tab({ [closeTab, tab] ) + const onContextMenu = useCallback( + (e: React.MouseEvent) => { + if (e.shiftKey) { + closeContextMenu() + return + } + e.preventDefault() + openContextMenu({ top: e.pageY, left: e.pageX }, tab.id) + }, + [openContextMenu, closeContextMenu, tab] + ) + useLayoutEffect(() => { if (isSelected && tabRef.current) { tabRef.current.scrollIntoView({ @@ -189,6 +208,8 @@ export const Tab = memo(function Tab({ onClick={onClick} onMouseUp={onMouseUp} onKeyDown={onKeyDown} + onContextMenu={onContextMenu} + data-tab-id={tab.id} tabIndex={0} className={classNames('editor-file-tab', { 'tab-selected': isSelected, diff --git a/services/web/frontend/js/features/source-editor/components/tabs/tabs-container.tsx b/services/web/frontend/js/features/source-editor/components/tabs/tabs-container.tsx index 3415027eb7..f83a2a53e3 100644 --- a/services/web/frontend/js/features/source-editor/components/tabs/tabs-container.tsx +++ b/services/web/frontend/js/features/source-editor/components/tabs/tabs-container.tsx @@ -4,6 +4,7 @@ import { useTabsContext, } from '@/features/ide-react/context/tabs-context' import { Tab } from './tab' +import { TabsContextMenu } from './tabs-context-menu' import SplitTestBadge from '@/shared/components/split-test-badge' import { useCallback, useMemo, useState } from 'react' import { throttle } from 'lodash' @@ -11,11 +12,28 @@ import { debugConsole } from '@/utils/debugging' import classNames from 'classnames' export const TabsContainer = () => { - const { tabs, openTab, closeTab, moveTab, makeTabPermanent } = - useTabsContext() + const { + tabs, + openTab, + closeTab, + moveTab, + makeTabPermanent, + setContextMenuTarget, + } = useTabsContext() const { openEntity } = useFileTreeOpenContext() const [hovered, setHovered] = useState(false) + const openContextMenu = useCallback( + (coords: { top: number; left: number }, tabId: string) => { + setContextMenuTarget({ ...coords, tabId }) + }, + [setContextMenuTarget] + ) + + const closeContextMenu = useCallback(() => { + setContextMenuTarget(null) + }, [setContextMenuTarget]) + const throttledOnDragOver = useMemo( () => throttle(() => { @@ -89,6 +107,8 @@ export const TabsContainer = () => { isSelected={openEntity?.entity._id === tab.id} onTabDrop={moveTab} makeTabPermanent={makeTabPermanent} + openContextMenu={openContextMenu} + closeContextMenu={closeContextMenu} /> ))} @@ -98,6 +118,7 @@ export const TabsContainer = () => { displayOnVariants={['enabled']} /> + ) } 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 new file mode 100644 index 0000000000..b2095bea4e --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/tabs/tabs-context-menu.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef } from 'react' +import ReactDOM from 'react-dom' +import { useTranslation } from 'react-i18next' +import { + Dropdown, + DropdownItem, + DropdownMenu, +} from '@/shared/components/dropdown/dropdown-menu' +import { useTabsContext } from '@/features/ide-react/context/tabs-context' + +export function TabsContextMenu() { + const { t } = useTranslation() + const { + tabs, + closeTab, + closeOtherTabs, + contextMenuTarget, + setContextMenuTarget, + } = useTabsContext() + + const menuRef = useRef(null) + + // Close the context menu when opening another one + useEffect(() => { + if (!contextMenuTarget) return + const handler = (event: MouseEvent) => { + if (event.button !== 2) return + const target = event.target as Element | null + // Right-clicking another tab should re-open the menu on that tab; + // its onContextMenu handler will set the new target. + if (target?.closest('.context-menu')) return + if (target?.closest('.editor-file-tab')) return + setContextMenuTarget(null) + } + // Listen on mousedown (not contextmenu) so we still close when another + // component's handler calls preventDefault on the contextmenu event. + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [contextMenuTarget, setContextMenuTarget]) + + // Move focus to the menu on open, and back to the originating tab on close. + const lastTabIdRef = useRef(null) + useEffect(() => { + if (contextMenuTarget) { + lastTabIdRef.current = contextMenuTarget.tabId + menuRef.current?.querySelector('[role="menu"]')?.focus() + } else if (lastTabIdRef.current) { + document + .querySelector(`[data-tab-id="${lastTabIdRef.current}"]`) + ?.focus() + lastTabIdRef.current = null + } + }, [contextMenuTarget]) + + if (!contextMenuTarget) return null + + const close = () => setContextMenuTarget(null) + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Tab' || event.key === 'Escape') { + event.preventDefault() + close() + } + } + + const handleToggle = (wantOpen: boolean) => { + if (!wantOpen) close() + } + + return ReactDOM.createPortal( +
+ + + { + closeTab(contextMenuTarget.tabId) + close() + }} + > + {t('close_tab')} + + { + closeOtherTabs(contextMenuTarget.tabId) + close() + }} + > + {t('close_others')} + + + +
, + document.body + ) +} diff --git a/services/web/frontend/stylesheets/pages/editor/tabs.scss b/services/web/frontend/stylesheets/pages/editor/tabs.scss index 2e1dd7ef3d..cf71a64663 100644 --- a/services/web/frontend/stylesheets/pages/editor/tabs.scss +++ b/services/web/frontend/stylesheets/pages/editor/tabs.scss @@ -50,7 +50,6 @@ box-sizing: border-box; padding: var(--spacing-02) var(--spacing-03); background: var(--tab-bg-color); - outline: none; color: var(--content-primary-themed); border: none; font-size: var(--font-size-02); diff --git a/services/web/locales/en.json b/services/web/locales/en.json index ad1baf1c03..40bde5c909 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -395,6 +395,7 @@ "clone_with_git": "Clone with Git", "close": "Close", "close_dialog": "Close dialog", + "close_others": "Close others", "close_tab": "Close tab", "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.", 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 2075c83244..00aeeae826 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 @@ -432,6 +432,158 @@ describe('File Tabs', function () { }) }) + describe('Context menu', function () { + it('opens the context menu on right-click of a tab', function () { + cy.then(() => selectDoc(DOC_IDS.main)) + cy.then(() => selectDoc(DOC_IDS.intro)) + + 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') + }) + + it('closes the clicked tab via "Close tab"', 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('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 () { + 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 others' }).click() + + cy.findAllByRole('tab').should('have.length', 1) + cy.findByRole('tab', { name: /intro\.tex/ }).should('exist') + }) + + it('navigates to the target tab when closing others from a non-active tab', function () { + cy.then(() => selectDoc(DOC_IDS.main)) + cy.then(() => selectDoc(DOC_IDS.intro)) + cy.then(() => selectDoc(DOC_IDS.appendix)) + + // 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.get('@openDocWithId').should('have.been.calledWith', DOC_IDS.intro) + }) + + it('disables both items 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( + 'have.attr', + 'aria-disabled', + 'true' + ) + cy.findByRole('menuitem', { name: 'Close others' }).should( + 'have.attr', + 'aria-disabled', + 'true' + ) + }) + + it('closes the menu on Escape', function () { + cy.then(() => selectDoc(DOC_IDS.main)) + cy.then(() => selectDoc(DOC_IDS.intro)) + + cy.findByRole('tab', { name: /intro\.tex/ }).rightclick() + cy.findByRole('menu').should('exist') + + cy.findByRole('menu').trigger('keydown', { + key: 'Escape', + }) + + cy.findByRole('menu').should('not.exist') + }) + + it('closes the menu on right-click outside', function () { + cy.then(() => selectDoc(DOC_IDS.main)) + cy.then(() => selectDoc(DOC_IDS.intro)) + + cy.findByRole('tab', { name: /intro\.tex/ }).rightclick() + cy.findByRole('menu').should('exist') + + // Right-click outside any tab + cy.get('.editor-tabs-container').rightclick('right') + + cy.findByRole('menu').should('not.exist') + }) + + it('focuses the menu when opening', function () { + cy.then(() => selectDoc(DOC_IDS.main)) + cy.then(() => selectDoc(DOC_IDS.intro)) + + cy.findByRole('tab', { name: /intro\.tex/ }).rightclick() + + cy.findByRole('menu').should('be.focused') + }) + + it('returns focus to the originating tab when closing', function () { + cy.then(() => selectDoc(DOC_IDS.main)) + cy.then(() => selectDoc(DOC_IDS.intro)) + + cy.findByRole('tab', { name: /intro\.tex/ }).rightclick() + cy.findByRole('menu').trigger('keydown', { key: 'Escape' }) + + cy.findByRole('tab', { name: /intro\.tex/ }).should('be.focused') + }) + + it('moves the menu to another tab on right-click', function () { + cy.then(() => selectDoc(DOC_IDS.main)) + cy.then(() => selectDoc(DOC_IDS.intro)) + cy.then(() => selectDoc(DOC_IDS.appendix)) + + cy.findByRole('tab', { name: /intro\.tex/ }).rightclick() + cy.findByRole('menu').should('exist') + + // Right-click appendix the menu should retarget to that tab + cy.findByRole('tab', { name: /appendix\.tex/ }).rightclick({ + force: true, + }) + cy.findByRole('menuitem', { name: 'Close others' }).click() + + cy.findAllByRole('tab').should('have.length', 1) + cy.findByRole('tab', { name: /appendix\.tex/ }).should('exist') + }) + + it('should not open the context menu if shift is held', function () { + cy.then(() => selectDoc(DOC_IDS.intro)) + cy.findByRole('tab', { name: /intro\.tex/ }).rightclick({ + shiftKey: true, + }) + cy.findByRole('menu').should('not.exist') + }) + + it('should close already open context menu if shift is held', function () { + cy.then(() => selectDoc(DOC_IDS.intro)) + cy.then(() => selectDoc(DOC_IDS.main)) + cy.findByRole('tab', { name: /intro\.tex/ }).rightclick() + cy.findByRole('menu').should('exist') + cy.findByRole('tab', { name: /main\.tex/ }).rightclick({ + force: true, + shiftKey: true, + }) + cy.findByRole('menu').should('not.exist') + }) + }) + describe('Tab interaction', function () { it('calls openDocWithId when clicking a non-selected doc tab', function () { // Open two tabs