feat(ui): add right-click close menu for context panel tabs (#3217)
Resolves openchamber/openchamber#3123. The right panel's tab strip (browser, files, chat, and other multi-instance surfaces) now supports a right-click context menu with Close, Close others, Close to the left, Close to the right, and Close all. These act on the current surface's tabs and reuse the new bulk close action, so closing the active surface's last tab still closes the panel while other surfaces remain. - Add closeContextPanelTabs(directory, ids) to useUIStore - Add opt-in tabContextMenu prop to SortableTabsStrip (no impact on other consumers) - Wire the menu in ContextPanel with full i18n coverage across 11 locales - Cover the bulk close with store tests
This commit is contained in:
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { DiffViewIcon } from '@/components/icons/DiffIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import { PullRequestView } from '@/components/views/PullRequestView';
|
||||
import { TerminalView } from '@/components/views/TerminalView';
|
||||
@@ -979,6 +980,47 @@ export const ContextPanel: React.FC = () => {
|
||||
|
||||
const isFileTabActive = activeTab?.mode === 'file';
|
||||
|
||||
const closeContextPanelTabs = useUIStore((state) => state.closeContextPanelTabs);
|
||||
const renderTabContextMenu = React.useCallback(
|
||||
(args: { id: string; index: number; allIds: string[]; close: () => void }): React.ReactNode => {
|
||||
if (!directoryKey) {
|
||||
return null;
|
||||
}
|
||||
const { id, index, allIds, close } = args;
|
||||
const closeOthers = () => closeContextPanelTabs(directoryKey, allIds.filter((tabId) => tabId !== id));
|
||||
const closeToLeft = () => closeContextPanelTabs(directoryKey, allIds.slice(0, index));
|
||||
const closeToRight = () => closeContextPanelTabs(directoryKey, allIds.slice(index + 1));
|
||||
const closeAll = () => closeContextPanelTabs(directoryKey, allIds);
|
||||
const hasOthers = allIds.length > 1;
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === allIds.length - 1;
|
||||
return (
|
||||
<>
|
||||
<ContextMenuItem onClick={close}>
|
||||
<Icon name="close" className="mr-2 size-4" />
|
||||
{t('contextPanel.tab.menu.close')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={closeOthers} disabled={!hasOthers}>
|
||||
{t('contextPanel.tab.menu.closeOthers')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={closeToLeft} disabled={isFirst}>
|
||||
{t('contextPanel.tab.menu.closeToLeft')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={closeToRight} disabled={isLast}>
|
||||
{t('contextPanel.tab.menu.closeToRight')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={closeAll} disabled={!hasOthers}>
|
||||
<Icon name="close-circle" className="mr-2 size-4" />
|
||||
{t('contextPanel.tab.menu.closeAll')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
);
|
||||
},
|
||||
[closeContextPanelTabs, directoryKey, t],
|
||||
);
|
||||
|
||||
const header = (
|
||||
<header className="flex h-10 items-stretch border-b border-border">
|
||||
{isMultiInstanceMode ? (
|
||||
@@ -1005,6 +1047,7 @@ export const ContextPanel: React.FC = () => {
|
||||
}}
|
||||
layoutMode="scrollable"
|
||||
variant="default"
|
||||
tabContextMenu={renderTabContextMenu}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 px-3">
|
||||
|
||||
@@ -21,6 +21,7 @@ import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||
|
||||
export type SortableTabsStripItem = {
|
||||
id: string;
|
||||
@@ -49,6 +50,15 @@ type SortableTabsStripProps = {
|
||||
(e.g. a sliding mobile drawer): creating a composited layer mid-slide
|
||||
flickers in WKWebView. Tab-switch animation stays (layout transition). */
|
||||
nonCompositedIndicator?: boolean;
|
||||
/** Per-tab right-click context menu. Return the menu items for the given tab,
|
||||
or null/undefined to disable the context menu for that tab. */
|
||||
tabContextMenu?: (args: {
|
||||
id: string;
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
allIds: string[];
|
||||
close: () => void;
|
||||
}) => React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@@ -106,6 +116,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
animateActivePill,
|
||||
activePillLowercase = true,
|
||||
nonCompositedIndicator = false,
|
||||
tabContextMenu,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -445,7 +456,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{items.map((item) => {
|
||||
{items.map((item, index) => {
|
||||
const isActive = item.id === activeId;
|
||||
const showInactiveIconOnly = inactiveTabsIconOnly && usesActivePillIndicator && !isActive && Boolean(item.icon);
|
||||
const shouldShowLabel = !showInactiveIconOnly;
|
||||
@@ -479,9 +490,18 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
return (
|
||||
<Wrapper key={item.id} id={item.id} className={wrapperClassName}>
|
||||
<div
|
||||
const tabMenuItems = !isMobile && tabContextMenu
|
||||
? tabContextMenu({
|
||||
id: item.id,
|
||||
index,
|
||||
isActive,
|
||||
allIds: itemIDs,
|
||||
close: () => onClose?.(item.id),
|
||||
})
|
||||
: null;
|
||||
|
||||
const tabElement = (
|
||||
<div
|
||||
ref={(element) => setTabRef(item.id, element)}
|
||||
onAuxClick={handleAuxClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
@@ -636,6 +656,24 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper key={item.id} id={item.id} className={wrapperClassName}>
|
||||
{tabMenuItems ? (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger
|
||||
render={(triggerProps) => (
|
||||
<div {...triggerProps} className={cn('flex h-full min-w-0', triggerProps.className)}>
|
||||
{tabElement}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<ContextMenuContent className="w-52">{tabMenuItems}</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
) : (
|
||||
tabElement
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user