From b69722121244519ff91a0ea398d07070ac2b482a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Wed, 25 Jun 2025 15:14:25 +0200 Subject: [PATCH 01/11] Add context menu to tab --- .../src/components/workspace-tabs/tab.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/compass-components/src/components/workspace-tabs/tab.tsx b/packages/compass-components/src/components/workspace-tabs/tab.tsx index ac12feafc8b..35440b3be0f 100644 --- a/packages/compass-components/src/components/workspace-tabs/tab.tsx +++ b/packages/compass-components/src/components/workspace-tabs/tab.tsx @@ -7,13 +7,14 @@ import { useSortable } from '@dnd-kit/sortable'; import { CSS as cssDndKit } from '@dnd-kit/utilities'; import { useId } from '@react-aria/utils'; import { useDarkMode } from '../../hooks/use-theme'; -import { Icon, IconButton } from '../leafygreen'; +import { Icon, IconButton, useMergeRefs } from '../leafygreen'; import { mergeProps } from '../../utils/merge-props'; import { useDefaultAction } from '../../hooks/use-default-action'; import { LogoIcon } from '../icons/logo-icon'; import { Tooltip } from '../leafygreen'; import { ServerIcon } from '../icons/server-icon'; import { useTabTheme } from './use-tab-theme'; +import { useContextMenuItems } from '../context-menu'; function focusedChild(className: string) { return `&:hover ${className}, &:focus-visible ${className}, &:focus-within:not(:focus) ${className}`; @@ -234,6 +235,10 @@ function Tab({ return css(tabTheme); }, [tabTheme, darkMode]); + const contextMenuRef = useContextMenuItems(() => [], []); + + const mergedRef = useMergeRefs([setNodeRef, contextMenuRef]); + const style = { transform: cssDndKit.Transform.toString(transform), transition, @@ -251,7 +256,7 @@ function Tab({ justify="start" trigger={
Date: Wed, 25 Jun 2025 15:32:17 +0200 Subject: [PATCH 02/11] Refactor closeTab action --- .../src/stores/workspaces.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/compass-workspaces/src/stores/workspaces.ts b/packages/compass-workspaces/src/stores/workspaces.ts index 32e685d25ec..cbb5cb65bda 100644 --- a/packages/compass-workspaces/src/stores/workspaces.ts +++ b/packages/compass-workspaces/src/stores/workspaces.ts @@ -843,6 +843,17 @@ export const openTabFromCurrent = ( }; }; +async function confirmClosingTabs() { + return await showConfirmation({ + title: 'Are you sure you want to close the tab?', + description: + 'The content of this tab has been modified. You will lose your changes if you close it.', + buttonText: 'Close tab', + variant: 'danger', + 'data-testid': 'confirm-tab-close', + }); +} + type CloseTabAction = { type: WorkspacesActions.CloseTab; atIndex: number }; export const closeTab = ( @@ -850,21 +861,10 @@ export const closeTab = ( ): WorkspacesThunkAction, CloseTabAction> => { return async (dispatch, getState) => { const tab = getState().tabs[atIndex]; - if (!canCloseTab(tab)) { - const confirmClose = await showConfirmation({ - title: 'Are you sure you want to close the tab?', - description: - 'The content of this tab has been modified. You will lose your changes if you close it.', - buttonText: 'Close tab', - variant: 'danger', - 'data-testid': 'confirm-tab-close', - }); - if (!confirmClose) { - return; - } + if (canCloseTab(tab) || (await confirmClosingTabs())) { + dispatch({ type: WorkspacesActions.CloseTab, atIndex }); + cleanupLocalAppRegistryForTab(tab?.id); } - dispatch({ type: WorkspacesActions.CloseTab, atIndex }); - cleanupLocalAppRegistryForTab(tab?.id); }; }; From 452d494a444e7f9ddb42d21f70cd2668402ec240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Wed, 25 Jun 2025 15:23:56 +0200 Subject: [PATCH 03/11] Add menu item to close all other tabs --- .../src/components/workspace-tabs/tab.tsx | 7 +++- .../workspace-tabs/workspace-tabs.tsx | 13 +++++++ .../src/components/workspaces.tsx | 5 +++ .../src/stores/workspaces.ts | 36 +++++++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/compass-components/src/components/workspace-tabs/tab.tsx b/packages/compass-components/src/components/workspace-tabs/tab.tsx index 35440b3be0f..d8fef360f98 100644 --- a/packages/compass-components/src/components/workspace-tabs/tab.tsx +++ b/packages/compass-components/src/components/workspace-tabs/tab.tsx @@ -194,6 +194,7 @@ export type WorkspaceTabCoreProps = { isDragging: boolean; onSelect: () => void; onClose: () => void; + onCloseAllOthers: () => void; tabContentId: string; }; @@ -209,6 +210,7 @@ function Tab({ isDragging, onSelect, onClose, + onCloseAllOthers, tabContentId, iconGlyph, className: tabClassName, @@ -235,7 +237,10 @@ function Tab({ return css(tabTheme); }, [tabTheme, darkMode]); - const contextMenuRef = useContextMenuItems(() => [], []); + const contextMenuRef = useContextMenuItems( + () => [{ label: 'Close all other tabs', onAction: onCloseAllOthers }], + [onCloseAllOthers] + ); const mergedRef = useMergeRefs([setNodeRef, contextMenuRef]); diff --git a/packages/compass-components/src/components/workspace-tabs/workspace-tabs.tsx b/packages/compass-components/src/components/workspace-tabs/workspace-tabs.tsx index 5d48e3f4388..48373ec092f 100644 --- a/packages/compass-components/src/components/workspace-tabs/workspace-tabs.tsx +++ b/packages/compass-components/src/components/workspace-tabs/workspace-tabs.tsx @@ -151,6 +151,7 @@ type SortableItemProps = { activeId: UniqueIdentifier | null; onSelect: (tabIndex: number) => void; onClose: (tabIndex: number) => void; + onCloseAllOthers: (tabIndex: number) => void; }; type SortableListProps = { @@ -159,6 +160,7 @@ type SortableListProps = { onMove: (oldTabIndex: number, newTabIndex: number) => void; onSelect: (tabIndex: number) => void; onClose: (tabIndex: number) => void; + onCloseAllOthers: (tabIndex: number) => void; }; type WorkspaceTabsProps = { @@ -168,6 +170,7 @@ type WorkspaceTabsProps = { onSelectNextTab: () => void; onSelectPrevTab: () => void; onCloseTab: (tabIndex: number) => void; + onCloseAllOtherTabs: (tabIndex: number) => void; onMoveTab: (oldTabIndex: number, newTabIndex: number) => void; tabs: TabItem[]; selectedTabIndex: number; @@ -210,6 +213,7 @@ const SortableList = ({ onSelect, selectedTabIndex, onClose, + onCloseAllOthers, }: SortableListProps) => { const items = tabs.map((tab) => tab.id); const [activeId, setActiveId] = useState(null); @@ -267,6 +271,7 @@ const SortableList = ({ activeId={activeId} onSelect={onSelect} onClose={onClose} + onCloseAllOthers={onCloseAllOthers} selectedTabIndex={selectedTabIndex} /> ))} @@ -283,6 +288,7 @@ const SortableItem = ({ activeId, onSelect, onClose, + onCloseAllOthers, }: SortableItemProps) => { const onTabSelected = useCallback(() => { onSelect(index); @@ -292,6 +298,10 @@ const SortableItem = ({ onClose(index); }, [onClose, index]); + const onAllOthersTabsClosed = useCallback(() => { + onCloseAllOthers(index); + }, [onCloseAllOthers, index]); + const isSelected = useMemo( () => selectedTabIndex === index, [selectedTabIndex, index] @@ -305,6 +315,7 @@ const SortableItem = ({ tabContentId: tabId, onSelect: onTabSelected, onClose: onTabClosed, + onCloseAllOthers: onAllOthersTabsClosed, }); }; @@ -312,6 +323,7 @@ function WorkspaceTabs({ ['aria-label']: ariaLabel, onCreateNewTab, onCloseTab, + onCloseAllOtherTabs, onMoveTab, onSelectTab, onSelectNextTab, @@ -409,6 +421,7 @@ function WorkspaceTabs({ onMove={onMoveTab} onSelect={onSelectTab} onClose={onCloseTab} + onCloseAllOthers={onCloseAllOtherTabs} selectedTabIndex={selectedTabIndex} />
diff --git a/packages/compass-workspaces/src/components/workspaces.tsx b/packages/compass-workspaces/src/components/workspaces.tsx index 61a2e5de1e1..ea2b540d06f 100644 --- a/packages/compass-workspaces/src/components/workspaces.tsx +++ b/packages/compass-workspaces/src/components/workspaces.tsx @@ -16,6 +16,7 @@ import type { } from '../stores/workspaces'; import { closeTab, + closeAllOtherTabs, getActiveTab, moveTab, openFallbackWorkspace, @@ -76,6 +77,7 @@ type CompassWorkspacesProps = { onMoveTab(from: number, to: number): void; onCreateTab(defaultTab?: OpenWorkspaceOptions | null): void; onCloseTab(at: number): void; + onCloseAllOtherTabs(at: number): void; onNamespaceNotFound( tab: Extract, fallbackNamespace: string | null @@ -94,6 +96,7 @@ const CompassWorkspaces: React.FunctionComponent = ({ onMoveTab, onCreateTab, onCloseTab, + onCloseAllOtherTabs, onNamespaceNotFound, }) => { const { log, mongoLogId } = useLogger('COMPASS-WORKSPACES'); @@ -203,6 +206,7 @@ const CompassWorkspaces: React.FunctionComponent = ({ onMoveTab={onMoveTab} onCreateNewTab={onCreateNewTab} onCloseTab={onCloseTab} + onCloseAllOtherTabs={onCloseAllOtherTabs} tabs={workspaceTabs} selectedTabIndex={activeTabIndex} > @@ -235,6 +239,7 @@ export default connect( onMoveTab: moveTab, onCreateTab: openTabFromCurrent, onCloseTab: closeTab, + onCloseAllOtherTabs: closeAllOtherTabs, onNamespaceNotFound: openFallbackWorkspace, } )(CompassWorkspaces); diff --git a/packages/compass-workspaces/src/stores/workspaces.ts b/packages/compass-workspaces/src/stores/workspaces.ts index cbb5cb65bda..2404ac05dc0 100644 --- a/packages/compass-workspaces/src/stores/workspaces.ts +++ b/packages/compass-workspaces/src/stores/workspaces.ts @@ -56,6 +56,7 @@ export enum WorkspacesActions { MoveTab = 'compass-workspaces/MoveTab', OpenTabFromCurrentActive = 'compass-workspaces/OpenTabFromCurrentActive', CloseTab = 'compass-workspaces/CloseTab', + CloseAllOtherTabs = 'compass-workspaces/CloseAllOtherTabs', CollectionRenamed = 'compass-workspaces/CollectionRenamed', CollectionRemoved = 'compass-workspaces/CollectionRemoved', DatabaseRemoved = 'compass-workspaces/DatabaseRemoved', @@ -465,6 +466,20 @@ const reducer: Reducer = ( }); } + if ( + isAction( + action, + WorkspacesActions.CloseAllOtherTabs + ) + ) { + return _bulkTabsClose({ + state, + isToBeClosed: (_tab, index) => { + return index !== action.atIndex; + }, + }); + } + if ( isAction( action, @@ -868,6 +883,27 @@ export const closeTab = ( }; }; +type CloseAllOtherTabsAction = { + type: WorkspacesActions.CloseAllOtherTabs; + atIndex: number; +}; + +export const closeAllOtherTabs = ( + atIndex: number +): WorkspacesThunkAction, CloseAllOtherTabsAction> => { + return async (dispatch, getState) => { + const { tabs } = getState(); + const otherTabs = tabs.filter((_, index) => index !== atIndex); + for (const tab of otherTabs) { + if (!canCloseTab(tab) && !(await confirmClosingTabs())) { + return; // Abort the action + } + } + dispatch({ type: WorkspacesActions.CloseAllOtherTabs, atIndex }); + cleanupLocalAppRegistryForTab(tabs[atIndex].id); + }; +}; + type CollectionRenamedAction = { type: WorkspacesActions.CollectionRenamed; from: string; From 1584df305a76e78224fc02613c53b3e19138a54c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Wed, 25 Jun 2025 15:29:33 +0200 Subject: [PATCH 04/11] Add menu item to duplicate tab --- .../src/components/workspace-tabs/tab.tsx | 9 +++++-- .../workspace-tabs/workspace-tabs.tsx | 13 +++++++++ .../src/components/workspaces.tsx | 5 ++++ .../src/stores/workspaces.ts | 27 +++++++++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/compass-components/src/components/workspace-tabs/tab.tsx b/packages/compass-components/src/components/workspace-tabs/tab.tsx index d8fef360f98..29519be412b 100644 --- a/packages/compass-components/src/components/workspace-tabs/tab.tsx +++ b/packages/compass-components/src/components/workspace-tabs/tab.tsx @@ -193,6 +193,7 @@ export type WorkspaceTabCoreProps = { isSelected: boolean; isDragging: boolean; onSelect: () => void; + onDuplicate: () => void; onClose: () => void; onCloseAllOthers: () => void; tabContentId: string; @@ -209,6 +210,7 @@ function Tab({ isSelected, isDragging, onSelect, + onDuplicate, onClose, onCloseAllOthers, tabContentId, @@ -238,8 +240,11 @@ function Tab({ }, [tabTheme, darkMode]); const contextMenuRef = useContextMenuItems( - () => [{ label: 'Close all other tabs', onAction: onCloseAllOthers }], - [onCloseAllOthers] + () => [ + { label: 'Close all other tabs', onAction: onCloseAllOthers }, + { label: 'Duplicate', onAction: onDuplicate }, + ], + [onCloseAllOthers, onDuplicate] ); const mergedRef = useMergeRefs([setNodeRef, contextMenuRef]); diff --git a/packages/compass-components/src/components/workspace-tabs/workspace-tabs.tsx b/packages/compass-components/src/components/workspace-tabs/workspace-tabs.tsx index 48373ec092f..639f52cb890 100644 --- a/packages/compass-components/src/components/workspace-tabs/workspace-tabs.tsx +++ b/packages/compass-components/src/components/workspace-tabs/workspace-tabs.tsx @@ -150,6 +150,7 @@ type SortableItemProps = { selectedTabIndex: number; activeId: UniqueIdentifier | null; onSelect: (tabIndex: number) => void; + onDuplicate: (tabIndex: number) => void; onClose: (tabIndex: number) => void; onCloseAllOthers: (tabIndex: number) => void; }; @@ -159,6 +160,7 @@ type SortableListProps = { selectedTabIndex: number; onMove: (oldTabIndex: number, newTabIndex: number) => void; onSelect: (tabIndex: number) => void; + onDuplicate: (tabIndex: number) => void; onClose: (tabIndex: number) => void; onCloseAllOthers: (tabIndex: number) => void; }; @@ -169,6 +171,7 @@ type WorkspaceTabsProps = { onSelectTab: (tabIndex: number) => void; onSelectNextTab: () => void; onSelectPrevTab: () => void; + onDuplicateTab: (tabIndex: number) => void; onCloseTab: (tabIndex: number) => void; onCloseAllOtherTabs: (tabIndex: number) => void; onMoveTab: (oldTabIndex: number, newTabIndex: number) => void; @@ -212,6 +215,7 @@ const SortableList = ({ onMove, onSelect, selectedTabIndex, + onDuplicate, onClose, onCloseAllOthers, }: SortableListProps) => { @@ -270,6 +274,7 @@ const SortableList = ({ tab={tab} activeId={activeId} onSelect={onSelect} + onDuplicate={onDuplicate} onClose={onClose} onCloseAllOthers={onCloseAllOthers} selectedTabIndex={selectedTabIndex} @@ -287,6 +292,7 @@ const SortableItem = ({ selectedTabIndex, activeId, onSelect, + onDuplicate, onClose, onCloseAllOthers, }: SortableItemProps) => { @@ -294,6 +300,10 @@ const SortableItem = ({ onSelect(index); }, [onSelect, index]); + const onTabDuplicated = useCallback(() => { + onDuplicate(index); + }, [onDuplicate, index]); + const onTabClosed = useCallback(() => { onClose(index); }, [onClose, index]); @@ -314,6 +324,7 @@ const SortableItem = ({ isDragging, tabContentId: tabId, onSelect: onTabSelected, + onDuplicate: onTabDuplicated, onClose: onTabClosed, onCloseAllOthers: onAllOthersTabsClosed, }); @@ -322,6 +333,7 @@ const SortableItem = ({ function WorkspaceTabs({ ['aria-label']: ariaLabel, onCreateNewTab, + onDuplicateTab, onCloseTab, onCloseAllOtherTabs, onMoveTab, @@ -420,6 +432,7 @@ function WorkspaceTabs({ tabs={tabs} onMove={onMoveTab} onSelect={onSelectTab} + onDuplicate={onDuplicateTab} onClose={onCloseTab} onCloseAllOthers={onCloseAllOtherTabs} selectedTabIndex={selectedTabIndex} diff --git a/packages/compass-workspaces/src/components/workspaces.tsx b/packages/compass-workspaces/src/components/workspaces.tsx index ea2b540d06f..dc3c0734c98 100644 --- a/packages/compass-workspaces/src/components/workspaces.tsx +++ b/packages/compass-workspaces/src/components/workspaces.tsx @@ -21,6 +21,7 @@ import { moveTab, openFallbackWorkspace, openTabFromCurrent, + duplicateTab, selectNextTab, selectPrevTab, selectTab, @@ -76,6 +77,7 @@ type CompassWorkspacesProps = { onSelectPrevTab(): void; onMoveTab(from: number, to: number): void; onCreateTab(defaultTab?: OpenWorkspaceOptions | null): void; + onDuplicateTab(at: number): void; onCloseTab(at: number): void; onCloseAllOtherTabs(at: number): void; onNamespaceNotFound( @@ -95,6 +97,7 @@ const CompassWorkspaces: React.FunctionComponent = ({ onSelectPrevTab, onMoveTab, onCreateTab, + onDuplicateTab, onCloseTab, onCloseAllOtherTabs, onNamespaceNotFound, @@ -205,6 +208,7 @@ const CompassWorkspaces: React.FunctionComponent = ({ onSelectPrevTab={onSelectPrevTab} onMoveTab={onMoveTab} onCreateNewTab={onCreateNewTab} + onDuplicateTab={onDuplicateTab} onCloseTab={onCloseTab} onCloseAllOtherTabs={onCloseAllOtherTabs} tabs={workspaceTabs} @@ -238,6 +242,7 @@ export default connect( onSelectPrevTab: selectPrevTab, onMoveTab: moveTab, onCreateTab: openTabFromCurrent, + onDuplicateTab: duplicateTab, onCloseTab: closeTab, onCloseAllOtherTabs: closeAllOtherTabs, onNamespaceNotFound: openFallbackWorkspace, diff --git a/packages/compass-workspaces/src/stores/workspaces.ts b/packages/compass-workspaces/src/stores/workspaces.ts index 2404ac05dc0..ed8b3594659 100644 --- a/packages/compass-workspaces/src/stores/workspaces.ts +++ b/packages/compass-workspaces/src/stores/workspaces.ts @@ -55,6 +55,7 @@ export enum WorkspacesActions { SelectNextTab = 'compass-workspaces/SelectNextTab', MoveTab = 'compass-workspaces/MoveTab', OpenTabFromCurrentActive = 'compass-workspaces/OpenTabFromCurrentActive', + DuplicateTab = 'compass-workspaces/DuplicateTab', CloseTab = 'compass-workspaces/CloseTab', CloseAllOtherTabs = 'compass-workspaces/CloseAllOtherTabs', CollectionRenamed = 'compass-workspaces/CollectionRenamed', @@ -401,6 +402,20 @@ const reducer: Reducer = ( }; } + if (isAction(action, WorkspacesActions.DuplicateTab)) { + const tabsBefore = state.tabs.slice(0, action.atIndex); + const targetTab = state.tabs[action.atIndex]; + const tabsAfter = state.tabs.slice(action.atIndex + 1); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id: _id, ...tabProps } = targetTab; + const newTab = getInitialTabState(tabProps); + return { + ...state, + tabs: [...tabsBefore, targetTab, newTab, ...tabsAfter], + activeTabId: newTab.id, + }; + } + if (isAction(action, WorkspacesActions.SelectTab)) { if (state.tabs[action.atIndex]?.id === state.activeTabId) { return state; @@ -858,6 +873,18 @@ export const openTabFromCurrent = ( }; }; +type DuplicateTabAction = { + type: WorkspacesActions.DuplicateTab; + atIndex: number; +}; + +export const duplicateTab = (atIndex: number): DuplicateTabAction => { + return { + type: WorkspacesActions.DuplicateTab, + atIndex, + }; +}; + async function confirmClosingTabs() { return await showConfirmation({ title: 'Are you sure you want to close the tab?', From 7c2a5f3ae678ad1c7703d5ab7d02935acb19f905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Fri, 27 Jun 2025 11:27:13 +0200 Subject: [PATCH 05/11] Add unit tests for the store --- .../src/stores/workspaces.spec.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/compass-workspaces/src/stores/workspaces.spec.ts b/packages/compass-workspaces/src/stores/workspaces.spec.ts index 86105e40f28..6f00d536573 100644 --- a/packages/compass-workspaces/src/stores/workspaces.spec.ts +++ b/packages/compass-workspaces/src/stores/workspaces.spec.ts @@ -59,6 +59,8 @@ describe('tabs behavior', function () { collectionSubtabSelected, openFallbackWorkspace: openFallbackTab, getActiveTab, + duplicateTab, + closeAllOtherTabs, } = workspacesSlice; describe('openWorkspace', function () { @@ -503,6 +505,41 @@ describe('tabs behavior', function () { expect(getActiveTab(store.getState())).to.not.have.property('namespace'); }); }); + + describe('duplicateTab', function () { + it('should duplicate tab by index', function () { + const store = configureStore(); + openTabs(store); + const tabCountBefore = store.getState().tabs.length; + + store.dispatch(duplicateTab(1)); + const state = store.getState(); + expect(state) + .to.have.property('tabs') + .have.lengthOf(tabCountBefore + 1); + const { id: existingTabId, ...existingTabState } = state.tabs[1]; + const { id: newTabId, ...newTabState } = state.tabs[2]; + // We expect their ids to differ + expect(existingTabId).to.not.equal(newTabId); + // but other properties should be the same + expect(existingTabState).to.deep.equal(newTabState); + }); + }); + + describe('closeAllOtherTabs', function () { + it('should close all other tabs by index', function () { + const store = configureStore(); + openTabs(store); + const stateBefore = store.getState(); + expect(stateBefore.tabs.length).to.be.greaterThan(1); + + store.dispatch(closeAllOtherTabs(1)); + const state = store.getState(); + expect(state.tabs.length).to.equal(1); + expect(state).to.have.property('activeTabId', stateBefore.tabs[1].id); + expect(state.tabs[0]).deep.equal(stateBefore.tabs[1]); + }); + }); }); describe('_bulkTabsClose', function () { From 94b1cc2434561f3df0e213c499527790116bc3c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Fri, 27 Jun 2025 12:17:35 +0200 Subject: [PATCH 06/11] Add unit tests to components package --- .../components/workspace-tabs/tab.spec.tsx | 52 +++++++++++++++++++ .../workspace-tabs/workspace-tabs.spec.tsx | 18 +++++++ 2 files changed, 70 insertions(+) diff --git a/packages/compass-components/src/components/workspace-tabs/tab.spec.tsx b/packages/compass-components/src/components/workspace-tabs/tab.spec.tsx index e0cd38491bd..4565639260c 100644 --- a/packages/compass-components/src/components/workspace-tabs/tab.spec.tsx +++ b/packages/compass-components/src/components/workspace-tabs/tab.spec.tsx @@ -13,10 +13,14 @@ import { Tab } from './tab'; describe('Tab', function () { let onCloseSpy: sinon.SinonSpy; let onSelectSpy: sinon.SinonSpy; + let onDuplicateSpy: sinon.SinonSpy; + let onCloseAllOthersSpy: sinon.SinonSpy; beforeEach(function () { onCloseSpy = sinon.spy(); onSelectSpy = sinon.spy(); + onDuplicateSpy = sinon.spy(); + onCloseAllOthersSpy = sinon.spy(); }); afterEach(cleanup); @@ -28,6 +32,8 @@ describe('Tab', function () { type="Databases" onClose={onCloseSpy} onSelect={onSelectSpy} + onDuplicate={onDuplicateSpy} + onCloseAllOthers={onCloseAllOthersSpy} title="docs" isSelected isDragging={false} @@ -73,6 +79,8 @@ describe('Tab', function () { type="Databases" onClose={onCloseSpy} onSelect={onSelectSpy} + onDuplicate={onDuplicateSpy} + onCloseAllOthers={onCloseAllOthersSpy} title="docs" isSelected={false} isDragging={false} @@ -98,4 +106,48 @@ describe('Tab', function () { ).to.not.equal('none'); }); }); + + describe('when right-clicking', function () { + beforeEach(function () { + render( + + ); + }); + + describe('clicking menu items', function () { + it('should propagate clicks on "Duplicate"', async function () { + const tab = await screen.findByText('docs'); + userEvent.click(tab, { button: 2 }); + expect(screen.getByTestId('context-menu')).to.be.visible; + + const menuItem = await screen.findByText('Duplicate'); + menuItem.click(); + expect(onDuplicateSpy.callCount).to.equal(1); + expect(onCloseAllOthersSpy.callCount).to.equal(0); + }); + + it('should propagate clicks on "Close all other tabs"', async function () { + const tab = await screen.findByText('docs'); + userEvent.click(tab, { button: 2 }); + expect(screen.getByTestId('context-menu')).to.be.visible; + + const menuItem = await screen.findByText('Close all other tabs'); + menuItem.click(); + expect(onDuplicateSpy.callCount).to.equal(0); + expect(onCloseAllOthersSpy.callCount).to.equal(1); + }); + }); + }); }); diff --git a/packages/compass-components/src/components/workspace-tabs/workspace-tabs.spec.tsx b/packages/compass-components/src/components/workspace-tabs/workspace-tabs.spec.tsx index b882fd1e025..036c88a2015 100644 --- a/packages/compass-components/src/components/workspace-tabs/workspace-tabs.spec.tsx +++ b/packages/compass-components/src/components/workspace-tabs/workspace-tabs.spec.tsx @@ -36,6 +36,8 @@ describe('WorkspaceTabs', function () { let onSelectNextSpy: sinon.SinonSpy; let onSelectPrevSpy: sinon.SinonSpy; let onMoveTabSpy: sinon.SinonSpy; + let onDuplicateSpy: sinon.SinonSpy; + let onCloseAllOthersSpy: sinon.SinonSpy; beforeEach(function () { onCreateNewTabSpy = sinon.spy(); @@ -44,6 +46,8 @@ describe('WorkspaceTabs', function () { onSelectNextSpy = sinon.spy(); onSelectPrevSpy = sinon.spy(); onMoveTabSpy = sinon.spy(); + onDuplicateSpy = sinon.spy(); + onCloseAllOthersSpy = sinon.spy(); }); afterEach(cleanup); @@ -59,6 +63,8 @@ describe('WorkspaceTabs', function () { onSelectNextTab={onSelectNextSpy} onSelectPrevTab={onSelectPrevSpy} onMoveTab={onMoveTabSpy} + onDuplicateTab={onDuplicateSpy} + onCloseAllOtherTabs={onCloseAllOthersSpy} tabs={[]} selectedTabIndex={0} /> @@ -87,7 +93,11 @@ describe('WorkspaceTabs', function () { onCreateNewTab={onCreateNewTabSpy} onCloseTab={onCloseTabSpy} onSelectTab={onSelectSpy} + onSelectNextTab={onSelectNextSpy} + onSelectPrevTab={onSelectPrevSpy} onMoveTab={onMoveTabSpy} + onDuplicateTab={onDuplicateSpy} + onCloseAllOtherTabs={onCloseAllOthersSpy} tabs={[1, 2, 3].map((tabId) => mockTab(tabId))} selectedTabIndex={1} /> @@ -156,7 +166,11 @@ describe('WorkspaceTabs', function () { onCreateNewTab={onCreateNewTabSpy} onCloseTab={onCloseTabSpy} onSelectTab={onSelectSpy} + onSelectNextTab={onSelectNextSpy} + onSelectPrevTab={onSelectPrevSpy} onMoveTab={onMoveTabSpy} + onDuplicateTab={onDuplicateSpy} + onCloseAllOtherTabs={onCloseAllOthersSpy} tabs={[1, 2].map((tabId) => mockTab(tabId))} selectedTabIndex={0} /> @@ -182,7 +196,11 @@ describe('WorkspaceTabs', function () { onCreateNewTab={onCreateNewTabSpy} onCloseTab={onCloseTabSpy} onSelectTab={onSelectSpy} + onSelectNextTab={onSelectNextSpy} + onSelectPrevTab={onSelectPrevSpy} onMoveTab={onMoveTabSpy} + onDuplicateTab={onDuplicateSpy} + onCloseAllOtherTabs={onCloseAllOthersSpy} tabs={[1, 2].map((tabId) => mockTab(tabId))} selectedTabIndex={1} /> From e86d0e9f110cda564db1eb01a52f1810d648caef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Mon, 30 Jun 2025 13:56:24 +0200 Subject: [PATCH 07/11] Call cleanupRemovedTabs --- packages/compass-workspaces/src/stores/workspaces.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compass-workspaces/src/stores/workspaces.ts b/packages/compass-workspaces/src/stores/workspaces.ts index ed8b3594659..6cb70a706c8 100644 --- a/packages/compass-workspaces/src/stores/workspaces.ts +++ b/packages/compass-workspaces/src/stores/workspaces.ts @@ -927,7 +927,7 @@ export const closeAllOtherTabs = ( } } dispatch({ type: WorkspacesActions.CloseAllOtherTabs, atIndex }); - cleanupLocalAppRegistryForTab(tabs[atIndex].id); + cleanupRemovedTabs(tabs, getState().tabs); }; }; From fcd3d18ca3ff4325c283f1dfd02961d9e5d7f52a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Mon, 30 Jun 2025 15:08:29 +0200 Subject: [PATCH 08/11] Merge CloseTab and CloseAllOtherTabs actions into CloseTabs --- .../src/stores/workspaces.ts | 33 +++++-------------- 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/packages/compass-workspaces/src/stores/workspaces.ts b/packages/compass-workspaces/src/stores/workspaces.ts index 6cb70a706c8..5ddd2ddbc53 100644 --- a/packages/compass-workspaces/src/stores/workspaces.ts +++ b/packages/compass-workspaces/src/stores/workspaces.ts @@ -56,8 +56,7 @@ export enum WorkspacesActions { MoveTab = 'compass-workspaces/MoveTab', OpenTabFromCurrentActive = 'compass-workspaces/OpenTabFromCurrentActive', DuplicateTab = 'compass-workspaces/DuplicateTab', - CloseTab = 'compass-workspaces/CloseTab', - CloseAllOtherTabs = 'compass-workspaces/CloseAllOtherTabs', + CloseTabs = 'compass-workspaces/CloseTabs', CollectionRenamed = 'compass-workspaces/CollectionRenamed', CollectionRemoved = 'compass-workspaces/CollectionRemoved', DatabaseRemoved = 'compass-workspaces/DatabaseRemoved', @@ -472,25 +471,11 @@ const reducer: Reducer = ( }; } - if (isAction(action, WorkspacesActions.CloseTab)) { + if (isAction(action, WorkspacesActions.CloseTabs)) { return _bulkTabsClose({ state, - isToBeClosed: (_tab, index) => { - return index === action.atIndex; - }, - }); - } - - if ( - isAction( - action, - WorkspacesActions.CloseAllOtherTabs - ) - ) { - return _bulkTabsClose({ - state, - isToBeClosed: (_tab, index) => { - return index !== action.atIndex; + isToBeClosed: (tab) => { + return action.tabIds.includes(tab.id); }, }); } @@ -885,7 +870,7 @@ export const duplicateTab = (atIndex: number): DuplicateTabAction => { }; }; -async function confirmClosingTabs() { +async function confirmClosingTab() { return await showConfirmation({ title: 'Are you sure you want to close the tab?', description: @@ -896,15 +881,15 @@ async function confirmClosingTabs() { }); } -type CloseTabAction = { type: WorkspacesActions.CloseTab; atIndex: number }; +type CloseTabsAction = { type: WorkspacesActions.CloseTabs; tabIds: string[] }; export const closeTab = ( atIndex: number -): WorkspacesThunkAction, CloseTabAction> => { +): WorkspacesThunkAction, CloseTabsAction> => { return async (dispatch, getState) => { const tab = getState().tabs[atIndex]; - if (canCloseTab(tab) || (await confirmClosingTabs())) { - dispatch({ type: WorkspacesActions.CloseTab, atIndex }); + if (canCloseTab(tab) || (await confirmClosingTab())) { + dispatch({ type: WorkspacesActions.CloseTabs, tabIds: [tab.id] }); cleanupLocalAppRegistryForTab(tab?.id); } }; From ae192e4e8e15d8944e70d06a320731d19d902728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Mon, 30 Jun 2025 15:09:15 +0200 Subject: [PATCH 09/11] Make closeAllOtherTabs confirm and close tabs one-by-one --- .../src/stores/workspaces.spec.ts | 4 +-- .../src/stores/workspaces.ts | 35 ++++++++++++------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/packages/compass-workspaces/src/stores/workspaces.spec.ts b/packages/compass-workspaces/src/stores/workspaces.spec.ts index 6f00d536573..a3dcce9d23b 100644 --- a/packages/compass-workspaces/src/stores/workspaces.spec.ts +++ b/packages/compass-workspaces/src/stores/workspaces.spec.ts @@ -527,13 +527,13 @@ describe('tabs behavior', function () { }); describe('closeAllOtherTabs', function () { - it('should close all other tabs by index', function () { + it('should close all other tabs by index', async function () { const store = configureStore(); openTabs(store); const stateBefore = store.getState(); expect(stateBefore.tabs.length).to.be.greaterThan(1); - store.dispatch(closeAllOtherTabs(1)); + await store.dispatch(closeAllOtherTabs(1)); const state = store.getState(); expect(state.tabs.length).to.equal(1); expect(state).to.have.property('activeTabId', stateBefore.tabs[1].id); diff --git a/packages/compass-workspaces/src/stores/workspaces.ts b/packages/compass-workspaces/src/stores/workspaces.ts index 5ddd2ddbc53..882a5eb5aaf 100644 --- a/packages/compass-workspaces/src/stores/workspaces.ts +++ b/packages/compass-workspaces/src/stores/workspaces.ts @@ -895,23 +895,32 @@ export const closeTab = ( }; }; -type CloseAllOtherTabsAction = { - type: WorkspacesActions.CloseAllOtherTabs; - atIndex: number; -}; - export const closeAllOtherTabs = ( atIndex: number -): WorkspacesThunkAction, CloseAllOtherTabsAction> => { +): WorkspacesThunkAction, CloseTabsAction | SelectTabAction> => { return async (dispatch, getState) => { const { tabs } = getState(); - const otherTabs = tabs.filter((_, index) => index !== atIndex); - for (const tab of otherTabs) { - if (!canCloseTab(tab) && !(await confirmClosingTabs())) { - return; // Abort the action - } - } - dispatch({ type: WorkspacesActions.CloseAllOtherTabs, atIndex }); + const tabsToClose = await tabs.reduce( + async (prev: Promise, tab, tabIndex) => { + const tabsToClose = await prev; + if (tabIndex === atIndex) { + return tabsToClose; // Skip the tab which is not being closed + } + if (!canCloseTab(tab)) { + // Select the closing tab - to show the confirmation dialog in context + dispatch({ type: WorkspacesActions.SelectTab, atIndex: tabIndex }); + if (!(await confirmClosingTab())) { + return tabsToClose; // Skip this tab + } + } + return [...tabsToClose, tab]; + }, + Promise.resolve([]) + ); + dispatch({ + type: WorkspacesActions.CloseTabs, + tabIds: tabsToClose.map((tab) => tab.id), + }); cleanupRemovedTabs(tabs, getState().tabs); }; }; From 9a3b8b8374f17256383d81d7310a514082c58b91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Mon, 30 Jun 2025 15:47:01 +0200 Subject: [PATCH 10/11] Use cleanupRemovedTabs in closeTab too --- packages/compass-workspaces/src/stores/workspaces.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/compass-workspaces/src/stores/workspaces.ts b/packages/compass-workspaces/src/stores/workspaces.ts index 882a5eb5aaf..ab23fd81ec6 100644 --- a/packages/compass-workspaces/src/stores/workspaces.ts +++ b/packages/compass-workspaces/src/stores/workspaces.ts @@ -887,10 +887,11 @@ export const closeTab = ( atIndex: number ): WorkspacesThunkAction, CloseTabsAction> => { return async (dispatch, getState) => { - const tab = getState().tabs[atIndex]; + const { tabs } = getState(); + const tab = tabs[atIndex]; if (canCloseTab(tab) || (await confirmClosingTab())) { dispatch({ type: WorkspacesActions.CloseTabs, tabIds: [tab.id] }); - cleanupLocalAppRegistryForTab(tab?.id); + cleanupRemovedTabs(tabs, getState().tabs); } }; }; From 6a77ddfcee515704bd2e595e818f88dc6b6f82bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Wed, 2 Jul 2025 09:05:35 +0200 Subject: [PATCH 11/11] Incorporate feedback from review --- .../src/stores/workspaces.ts | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/packages/compass-workspaces/src/stores/workspaces.ts b/packages/compass-workspaces/src/stores/workspaces.ts index ab23fd81ec6..06444ee81fe 100644 --- a/packages/compass-workspaces/src/stores/workspaces.ts +++ b/packages/compass-workspaces/src/stores/workspaces.ts @@ -472,12 +472,16 @@ const reducer: Reducer = ( } if (isAction(action, WorkspacesActions.CloseTabs)) { - return _bulkTabsClose({ + const newState = _bulkTabsClose({ state, isToBeClosed: (tab) => { return action.tabIds.includes(tab.id); }, }); + // Add the updated active tab if needed + return action.activeTabId + ? { ...newState, activeTabId: action.activeTabId } + : newState; } if ( @@ -881,7 +885,11 @@ async function confirmClosingTab() { }); } -type CloseTabsAction = { type: WorkspacesActions.CloseTabs; tabIds: string[] }; +type CloseTabsAction = { + type: WorkspacesActions.CloseTabs; + tabIds: string[]; + activeTabId?: string; +}; export const closeTab = ( atIndex: number @@ -901,26 +909,25 @@ export const closeAllOtherTabs = ( ): WorkspacesThunkAction, CloseTabsAction | SelectTabAction> => { return async (dispatch, getState) => { const { tabs } = getState(); - const tabsToClose = await tabs.reduce( - async (prev: Promise, tab, tabIndex) => { - const tabsToClose = await prev; - if (tabIndex === atIndex) { - return tabsToClose; // Skip the tab which is not being closed - } - if (!canCloseTab(tab)) { - // Select the closing tab - to show the confirmation dialog in context - dispatch({ type: WorkspacesActions.SelectTab, atIndex: tabIndex }); - if (!(await confirmClosingTab())) { - return tabsToClose; // Skip this tab - } + const remainingTab = tabs[atIndex]; + const tabsToClose = []; + for (const [tabIndex, tab] of tabs.entries()) { + if (tabIndex === atIndex) { + continue; // Skip the tab which is not being closed + } + if (!canCloseTab(tab)) { + // Select the closing tab - to show the confirmation dialog in context + dispatch({ type: WorkspacesActions.SelectTab, atIndex: tabIndex }); + if (!(await confirmClosingTab())) { + continue; // Skip this tab } - return [...tabsToClose, tab]; - }, - Promise.resolve([]) - ); + } + tabsToClose.push(tab); + } dispatch({ type: WorkspacesActions.CloseTabs, tabIds: tabsToClose.map((tab) => tab.id), + activeTabId: remainingTab.id, }); cleanupRemovedTabs(tabs, getState().tabs); };