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:
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user