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:
Angel Davila
2026-08-29 00:35:24 +03:00
committed by GitHub
parent cbe908009b
commit a78ecf8a7c
18 changed files with 251 additions and 23 deletions
@@ -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>
);
})}
+5
View File
@@ -1115,6 +1115,11 @@ export const dict = {
'contextPanel.browser.annotate.submit': 'Anhängen',
'contextPanel.browser.trustNotice': 'Seiten, die hier geöffnet werden, laufen mit vollständigem Zugriff auf OpenChamber — erforderlich für Inspect und Screenshots. Öffnen Sie nur Seiten, denen Sie vertrauen: Eine bösartige Seite könnte Ihre Daten lesen oder in Ihrem Namen handeln.',
'contextPanel.tab.closeTabAria': '{label}-Registerkarte schließen',
'contextPanel.tab.menu.close': 'Schließen',
'contextPanel.tab.menu.closeOthers': 'Andere schließen',
'contextPanel.tab.menu.closeToLeft': 'Tabs links daneben schließen',
'contextPanel.tab.menu.closeToRight': 'Tabs rechts daneben schließen',
'contextPanel.tab.menu.closeAll': 'Alle Tabs schließen',
'contextPanel.actions.collapsePanel': 'Panel einklappen',
'contextPanel.actions.expandPanel': 'Panel ausklappen',
'contextPanel.actions.closePanel': 'Panel schließen',
+5
View File
@@ -1308,6 +1308,11 @@ export const dict = {
'contextPanel.browser.annotate.submit': 'Attach',
'contextPanel.browser.trustNotice': 'Pages opened here run with full access to OpenChamber — needed for inspect and screenshots. Only open sites you trust: a malicious page could read your data or act on your behalf.',
'contextPanel.tab.closeTabAria': 'Close {label} tab',
'contextPanel.tab.menu.close': 'Close',
'contextPanel.tab.menu.closeOthers': 'Close others',
'contextPanel.tab.menu.closeToLeft': 'Close tabs to the left',
'contextPanel.tab.menu.closeToRight': 'Close tabs to the right',
'contextPanel.tab.menu.closeAll': 'Close all tabs',
'contextPanel.actions.collapsePanel': 'Collapse panel',
'contextPanel.actions.expandPanel': 'Expand panel',
'contextPanel.actions.closePanel': 'Close panel',
+5
View File
@@ -1309,6 +1309,11 @@ export const dict: Record<I18nKey, string> = {
"contextPanel.browser.annotate.submit": "Adjuntar",
"contextPanel.browser.trustNotice": "Las páginas que abras aquí se ejecutan con acceso completo a OpenChamber: necesario para la inspección y las capturas. Abre solo sitios de confianza: una página maliciosa podría leer tus datos o actuar en tu nombre.",
"contextPanel.tab.closeTabAria": "Cerrar pestaña {label}",
"contextPanel.tab.menu.close": "Cerrar",
"contextPanel.tab.menu.closeOthers": "Cerrar otras",
"contextPanel.tab.menu.closeToLeft": "Cerrar pestañas a la izquierda",
"contextPanel.tab.menu.closeToRight": "Cerrar pestañas a la derecha",
"contextPanel.tab.menu.closeAll": "Cerrar todas las pestañas",
"contextPanel.actions.collapsePanel": "Colapsar panel",
"contextPanel.actions.expandPanel": "Expandir panel",
"contextPanel.actions.closePanel": "Cerrar panel",
+5
View File
@@ -1075,6 +1075,11 @@ export const dict = {
'contextPanel.browser.empty': 'Navigateur Internet',
'contextPanel.browser.emptyHint': 'Entrez une adresse ci-dessus pour commencer à naviguer sur le Web',
'contextPanel.tab.closeTabAria': 'Fermer l\'onglet {label}',
'contextPanel.tab.menu.close': 'Fermer',
'contextPanel.tab.menu.closeOthers': 'Fermer les autres',
'contextPanel.tab.menu.closeToLeft': 'Fermer les onglets à gauche',
'contextPanel.tab.menu.closeToRight': 'Fermer les onglets à droite',
'contextPanel.tab.menu.closeAll': 'Fermer tous les onglets',
'contextPanel.actions.collapsePanel': 'Réduire le panneau',
'contextPanel.actions.expandPanel': 'Agrandir le panneau',
'contextPanel.actions.closePanel': 'Fermer le panneau',
+5
View File
@@ -1305,6 +1305,11 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.browser.annotate.submit': '添付',
'contextPanel.browser.trustNotice': 'ここで開かれたページはOpenChamberへの完全なアクセス権を持ちます — 検査とスクリーンショットに必要です。信頼できるサイトのみを開いてください: 悪意のあるページがデータを読み取ったりあなたの代わりに行動したりする可能性があります。',
'contextPanel.tab.closeTabAria': '{label}タブを閉じる',
'contextPanel.tab.menu.close': '閉じる',
'contextPanel.tab.menu.closeOthers': '他を閉じる',
'contextPanel.tab.menu.closeToLeft': '左のタブを閉じる',
'contextPanel.tab.menu.closeToRight': '右のタブを閉じる',
'contextPanel.tab.menu.closeAll': 'すべてのタブを閉じる',
'contextPanel.actions.collapsePanel': 'パネルを折りたたむ',
'contextPanel.actions.expandPanel': 'パネルを展開',
'contextPanel.actions.closePanel': 'パネルを閉じる',
+5
View File
@@ -1358,6 +1358,11 @@ export const dict: Record<I18nKey, string> = {
'chat.messageBody.actions.openPreviewAria': '미리보기 열기',
'chat.messageBody.actions.openPreview': '미리보기 열기',
'contextPanel.tab.closeTabAria': '{label} 탭 닫기',
'contextPanel.tab.menu.close': '닫기',
'contextPanel.tab.menu.closeOthers': '다른 탭 닫기',
'contextPanel.tab.menu.closeToLeft': '왼쪽 탭 닫기',
'contextPanel.tab.menu.closeToRight': '오른쪽 탭 닫기',
'contextPanel.tab.menu.closeAll': '모든 탭 닫기',
'contextPanel.actions.collapsePanel': '접기 패널',
'contextPanel.actions.expandPanel': '펼치기 패널',
'contextPanel.actions.closePanel': '패널 닫기',
+5
View File
@@ -1696,6 +1696,11 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.preview.upstreamUnreachable': 'Serwer deweloperski nie odpowiada.',
'contextPanel.preview.upstreamUnreachableHint': 'Upewnij się, że serwer deweloperski nadal działa, a następnie ponów próbę.',
'contextPanel.tab.closeTabAria': 'Zamknij kartę {label}',
'contextPanel.tab.menu.close': 'Zamknij',
'contextPanel.tab.menu.closeOthers': 'Zamknij pozostałe',
'contextPanel.tab.menu.closeToLeft': 'Zamknij karty po lewej',
'contextPanel.tab.menu.closeToRight': 'Zamknij karty po prawej',
'contextPanel.tab.menu.closeAll': 'Zamknij wszystkie karty',
'contextSidebar.actions.copied': 'Skopiowano',
'contextSidebar.actions.copy': 'Kopiuj',
'contextSidebar.actions.copyJson': 'Kopiuj JSON',
@@ -1309,6 +1309,11 @@ export const dict: Record<I18nKey, string> = {
"contextPanel.browser.annotate.submit": "Anexar",
"contextPanel.browser.trustNotice": "As páginas abertas aqui são executadas com acesso total ao OpenChamber — necessário para inspeção e capturas de tela. Abra apenas sites confiáveis: uma página maliciosa pode ler seus dados ou agir em seu nome.",
"contextPanel.tab.closeTabAria": "Fechar aba {label}",
"contextPanel.tab.menu.close": "Fechar",
"contextPanel.tab.menu.closeOthers": "Fechar outras",
"contextPanel.tab.menu.closeToLeft": "Fechar abas à esquerda",
"contextPanel.tab.menu.closeToRight": "Fechar abas à direita",
"contextPanel.tab.menu.closeAll": "Fechar todas as abas",
"contextPanel.actions.collapsePanel": "Recolher painel",
"contextPanel.actions.expandPanel": "Expandir painel",
"contextPanel.actions.closePanel": "Fechar painel",
+5
View File
@@ -1284,6 +1284,11 @@ export const dict = {
'contextPanel.browser.annotate.submit': 'Ekle',
'contextPanel.browser.trustNotice': 'Burada açılan sayfalar OpenChamber\'a tam erişimle çalışır — inceleme ve ekran görüntüleri için gereklidir. Yalnızca güvendiğiniz siteleri açın: kötü niyetli bir sayfa verilerinizi okuyabilir veya sizin adınıza hareket edebilir.',
'contextPanel.tab.closeTabAria': '{label} sekmesini kapat',
'contextPanel.tab.menu.close': 'Kapat',
'contextPanel.tab.menu.closeOthers': 'Diğerlerini kapat',
'contextPanel.tab.menu.closeToLeft': 'Soldaki sekmeleri kapat',
'contextPanel.tab.menu.closeToRight': 'Sağdaki sekmeleri kapat',
'contextPanel.tab.menu.closeAll': 'Tüm sekmeleri kapat',
'contextPanel.actions.collapsePanel': 'Paneli daralt',
'contextPanel.actions.expandPanel': 'Paneli genişlet',
'contextPanel.actions.closePanel': 'Paneli kapat',
+5
View File
@@ -1309,6 +1309,11 @@ export const dict: Record<I18nKey, string> = {
"contextPanel.browser.annotate.submit": "Додати",
"contextPanel.browser.trustNotice": "Сторінки, відкриті тут, працюють із повним доступом до OpenChamber — це потрібно для inspect і скріншотів. Відкривайте лише сайти, яким довіряєте: шкідлива сторінка може прочитати ваші дані чи діяти від вашого імені.",
"contextPanel.tab.closeTabAria": "Закрити вкладку {label}",
"contextPanel.tab.menu.close": "Закрити",
"contextPanel.tab.menu.closeOthers": "Закрити інші",
"contextPanel.tab.menu.closeToLeft": "Закрити вкладки ліворуч",
"contextPanel.tab.menu.closeToRight": "Закрити вкладки праворуч",
"contextPanel.tab.menu.closeAll": "Закрити всі вкладки",
"contextPanel.actions.collapsePanel": "Згорнути панель",
"contextPanel.actions.expandPanel": "Розгорнути панель",
"contextPanel.actions.closePanel": "Закрити панель",
@@ -1309,6 +1309,11 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.browser.annotate.submit': '附加',
'contextPanel.browser.trustNotice': '在此打开的页面以对 OpenChamber 的完全访问权限运行 — 检查和截图需要此权限。仅打开你信任的站点:恶意页面可能读取你的数据或以你的身份执行操作。',
'contextPanel.tab.closeTabAria': '关闭 {label} 标签',
'contextPanel.tab.menu.close': '关闭',
'contextPanel.tab.menu.closeOthers': '关闭其他',
'contextPanel.tab.menu.closeToLeft': '关闭左侧标签',
'contextPanel.tab.menu.closeToRight': '关闭右侧标签',
'contextPanel.tab.menu.closeAll': '关闭所有标签',
'contextPanel.actions.collapsePanel': '折叠面板',
'contextPanel.actions.expandPanel': '展开面板',
'contextPanel.actions.closePanel': '关闭面板',
@@ -1321,6 +1321,11 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.browser.annotate.submit': '附加',
'contextPanel.browser.trustNotice': '在此開啟的頁面以對 OpenChamber 的完整存取權限執行 — 檢查與截圖需要此權限。僅開啟你信任的網站:惡意頁面可能讀取你的資料或以你的身分執行操作。',
'contextPanel.tab.closeTabAria': '關閉 {label} 分頁',
'contextPanel.tab.menu.close': '關閉',
'contextPanel.tab.menu.closeOthers': '關閉其他',
'contextPanel.tab.menu.closeToLeft': '關閉左側分頁',
'contextPanel.tab.menu.closeToRight': '關閉右側分頁',
'contextPanel.tab.menu.closeAll': '關閉所有分頁',
'contextPanel.actions.collapsePanel': '摺疊面板',
'contextPanel.actions.expandPanel': '展開面板',
'contextPanel.actions.closePanel': '關閉面板',
@@ -319,6 +319,75 @@ describe('useUIStore closeContextPanelTab surface stability', () => {
});
});
describe('useUIStore closeContextPanelTabs bulk', () => {
const directory = '/repo';
test('closing every tab of the only surface closes the panel', () => {
useUIStore.getState().openContextBrowser(directory, 'https://a.test');
useUIStore.getState().openContextBrowser(directory, 'https://b.test');
useUIStore.getState().openContextBrowser(directory, 'https://c.test');
const state0 = useUIStore.getState().contextPanelByDirectory[directory];
const ids = state0?.tabs.map((tab) => tab.id) ?? [];
useUIStore.getState().closeContextPanelTabs(directory, ids);
const state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.tabs).toHaveLength(0);
expect(state?.isOpen).toBe(false);
});
test('closing all tabs of the active surface closes the panel but keeps other surfaces in state', () => {
useUIStore.getState().openContextPanelTab(directory, { mode: 'terminal' });
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
useUIStore.getState().openContextFile(directory, '/repo/b.ts');
const state0 = useUIStore.getState().contextPanelByDirectory[directory];
const fileIds = state0?.tabs.filter((tab) => tab.mode === 'file').map((tab) => tab.id) ?? [];
useUIStore.getState().closeContextPanelTabs(directory, fileIds);
const state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.tabs.map((tab) => tab.mode)).toEqual(['terminal']);
expect(state?.activeTabId).toBe('terminal');
// Matches the single-close rule: emptying the active surface closes the panel.
expect(state?.isOpen).toBe(false);
});
test('closing only inactive-mode tabs leaves the active tab and panel intact', () => {
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
useUIStore.getState().openContextPanelTab(directory, { mode: 'terminal' });
const state0 = useUIStore.getState().contextPanelByDirectory[directory];
const fileTab = state0?.tabs.find((tab) => tab.mode === 'file');
useUIStore.getState().closeContextPanelTabs(directory, [fileTab?.id as string]);
const state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.activeTabId).toBe('terminal');
expect(state?.isOpen).toBe(true);
});
test('closing a subset of the active surface including the active tab keeps a remaining same-mode tab', () => {
useUIStore.getState().openContextPanelTab(directory, { mode: 'terminal' });
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
useUIStore.getState().openContextFile(directory, '/repo/b.ts');
useUIStore.getState().openContextFile(directory, '/repo/c.ts');
const state0 = useUIStore.getState().contextPanelByDirectory[directory];
const fileTabs = state0?.tabs.filter((tab) => tab.mode === 'file') ?? [];
const keptFile = fileTabs.find((tab) => tab.targetPath === '/repo/a.ts');
const closedIds = fileTabs.filter((tab) => tab.id !== keptFile?.id).map((tab) => tab.id);
expect(state0?.tabs.find((tab) => tab.id === state0.activeTabId)?.targetPath).toBe('/repo/c.ts');
useUIStore.getState().closeContextPanelTabs(directory, closedIds);
const state = useUIStore.getState().contextPanelByDirectory[directory];
const activeTab = state?.tabs.find((tab) => tab.id === state.activeTabId);
expect(activeTab?.mode).toBe('file');
expect(activeTab?.targetPath).toBe('/repo/a.ts');
expect(state?.isOpen).toBe(true);
expect(state?.tabs.some((tab) => tab.mode === 'terminal')).toBe(true);
});
});
describe('useUIStore per-surface panel widths', () => {
const directory = '/repo';
+35 -19
View File
@@ -482,14 +482,19 @@ const upsertContextPanelTab = (
};
};
const closeContextPanelTab = (
const closeContextPanelTabs = (
current: ContextPanelDirectoryState,
tabID: string,
tabIds: readonly string[],
): ContextPanelDirectoryState => {
const closedTab = current.tabs.find((tab) => tab.id === tabID) ?? null;
const nextTabs = current.tabs.filter((tab) => tab.id !== tabID);
const closed = new Set(tabIds);
const closedTabs = current.tabs.filter((tab) => closed.has(tab.id));
const nextTabs = current.tabs.filter((tab) => !closed.has(tab.id));
if (nextTabs.length === current.tabs.length) {
return current;
}
if (current.activeTabId !== tabID) {
const activeClosed = current.activeTabId ? closed.has(current.activeTabId) : false;
if (!activeClosed) {
return {
...current,
tabs: nextTabs,
@@ -499,10 +504,11 @@ const closeContextPanelTab = (
};
}
// Closing the active tab stays inside the active surface: activate the most
// recent remaining tab of the same mode, and when it was the last one just
// close the panel instead of jumping to another surface.
const sameModeTabs = closedTab ? nextTabs.filter((tab) => tab.mode === closedTab.mode) : [];
// Closing the active tab stays inside its surface: activate the most recent
// remaining tab of the same mode, and when none remain just close the panel
// instead of jumping to another surface.
const activeMode = closedTabs.find((tab) => tab.id === current.activeTabId)?.mode ?? null;
const sameModeTabs = activeMode ? nextTabs.filter((tab) => tab.mode === activeMode) : [];
const nextSameModeTab = sameModeTabs.length > 0
? sameModeTabs.reduce((best, tab) => (tab.touchedAt >= best.touchedAt ? tab : best))
: null;
@@ -875,6 +881,7 @@ interface UIStore {
setActiveContextPanelTab: (directory: string, tabID: string) => void;
reorderContextPanelTabs: (directory: string, activeTabID: string, overTabID: string) => void;
closeContextPanelTab: (directory: string, tabID: string) => void;
closeContextPanelTabs: (directory: string, tabIds: readonly string[]) => void;
closeContextPanel: (directory: string) => void;
toggleContextPanelExpanded: (directory: string) => void;
setContextPanelWidth: (directory: string, mode: ContextPanelMode, width: number) => void;
@@ -1493,34 +1500,43 @@ export const useUIStore = create<UIStore>()(
},
closeContextPanelTab: (directory, tabID) => {
get().closeContextPanelTabs(directory, [tabID]);
},
closeContextPanelTabs: (directory, tabIds) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
const normalizedTabID = (tabID || '').trim();
if (!normalizedDirectory || !normalizedTabID) {
const normalizedTabIds = (tabIds ?? [])
.map((id) => (id || '').trim())
.filter((id) => id.length > 0);
if (!normalizedDirectory || normalizedTabIds.length === 0) {
return;
}
const closingTab = get().contextPanelByDirectory[normalizedDirectory]?.tabs
.find((tab) => tab.id === normalizedTabID);
const closedTabs = normalizedTabIds
.map((id) => get().contextPanelByDirectory[normalizedDirectory]?.tabs.find((tab) => tab.id === id))
.filter((tab): tab is ContextPanelTab => Boolean(tab));
set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory];
const current = touchContextPanelState(prev);
if (!current.tabs.some((tab) => tab.id === normalizedTabID)) {
if (!current.tabs.some((tab) => normalizedTabIds.includes(tab.id))) {
return state;
}
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: closeContextPanelTab(current, normalizedTabID),
[normalizedDirectory]: closeContextPanelTabs(current, normalizedTabIds),
};
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
});
// Keep the editor's own open-file state in sync so a reopened
// editor surface does not resurrect the closed file.
if (closingTab?.mode === 'file' && closingTab.targetPath) {
useFilesViewTabsStore.getState().removeOpenPath(normalizedDirectory, closingTab.targetPath);
// Keep the editor's own open-file state in sync so closed files do not
// resurrect when the editor surface reopens.
for (const tab of closedTabs) {
if (tab.mode === 'file' && tab.targetPath) {
useFilesViewTabsStore.getState().removeOpenPath(normalizedDirectory, tab.targetPath);
}
}
},
+1
View File
@@ -16,6 +16,7 @@
- Updating OpenCode no longer fails with a bare "Bad Request": the extension names the release to install and shows OpenCode's own reason when an update is refused (thanks to @mdatsev and @yulia-ivashko).
- "Add Project" now adds the chosen folder to the workspace instead of failing (thanks to @bashrusakh), and the extension starts in the current workspace folder instead of one restored from storage (thanks to @makeittech).
- Multi-Run groups can now contain more than five models (thanks to @tomzx).
- Right-click an editor, chat, or browser tab in the context panel to close it, close others, close left/right, or close all (thanks to @adavila0703).
- Sidebar: pending permission and question badges are no longer covered by the hover actions (thanks to @makeittech); worktree branch search hides non-matching branches (thanks to @bashrusakh).
- Settings: number fields and selects no longer clip at large font sizes (thanks to @makeittech), and Windows skill paths are classified correctly, so disabled and duplicate skills are hidden as intended (thanks to @Ttungx).
- Windows: closing VS Code now stops the managed OpenCode process instead of leaving it running (thanks to @a0000001).