diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 8b236bfea..5eba95a61 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -72,6 +72,10 @@ import { shouldShowSessionHeaderChecksAction, panelRouteDescriptor } from '../../../../src/session/session-panel-host' +import { + createBulkCloseSheetActions, + createCloseWithBulkActions +} from '../../../../src/session/mobile-bulk-close-sheet-actions' import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context' import { isFloatingWorkspaceWorktreeId } from '../../../../src/session/floating-workspace' import { SessionDockColumn } from '../../../../src/session/SessionDockColumn' @@ -4092,7 +4096,10 @@ export default function SessionScreen() { setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id)) // Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab. closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000) - if (activeSessionTabId === tab.id) { + // Why: bulk close re-activates the anchor before awaiting each close; + // the render-synced ref sees that switch while this closure would not, + // so comparing against the ref keeps the anchor from being nulled out. + if (activeSessionTabIdRef.current === tab.id) { activeSessionTabTypeRef.current = null setActiveSessionTabId(null) activeHandleRef.current = null @@ -4104,6 +4111,15 @@ export default function SessionScreen() { } } + const bulkCloseActions = createBulkCloseSheetActions({ + sessionTabsRef, + markdownDocs, + activeSessionTabIdRef, + switchSessionTab, + closeSessionTab: handleCloseSessionTab + }) + const closeWithBulkActions = createCloseWithBulkActions(handleCloseSessionTab, bulkCloseActions) + const isPhoneMode = (handle: string | null): boolean => { if (!handle) { return false @@ -5157,7 +5173,8 @@ export default function SessionScreen() { onToggleDisplayMode: (handle) => void toggleDisplayMode(handle), onRename: setRenameTarget, onClear: (target) => void handleClearTerminal(target), - onClose: (target) => void handleCloseTerminal(target) + onClose: (target) => void handleCloseTerminal(target), + bulkCloseActions })} onClose={() => setActionTarget(null)} /> @@ -5190,17 +5207,7 @@ export default function SessionScreen() { } } }, - { - label: 'Close', - destructive: true, - onPress: () => { - const target = markdownActionTarget - setMarkdownActionTarget(null) - if (target) { - void handleCloseSessionTab(target) - } - } - } + ...closeWithBulkActions(markdownActionTarget, () => setMarkdownActionTarget(null)) ]} onClose={() => setMarkdownActionTarget(null)} /> @@ -5219,17 +5226,7 @@ export default function SessionScreen() { } } }, - { - label: 'Close', - destructive: true, - onPress: () => { - const target = fileActionTarget - setFileActionTarget(null) - if (target) { - void handleCloseSessionTab(target) - } - } - } + ...closeWithBulkActions(fileActionTarget, () => setFileActionTarget(null)) ]} onClose={() => setFileActionTarget(null)} /> @@ -5238,6 +5235,7 @@ export default function SessionScreen() { onClose={() => setBrowserActionTarget(null)} onNavigate={handleBrowserNavigationCommand} onCloseTab={handleCloseSessionTab} + bulkCloseActions={bulkCloseActions} /> @@ -13,8 +13,11 @@ export function MobileBrowserTabActionSheet(props: { onClose: () => void onNavigate: (target: BrowserTab, method: MobileBrowserNavigationMethod) => void onCloseTab: (target: BrowserTab) => void + /** Rendered after Close — receives the open tab's id so the session route's + * bulk-close builder can resolve the anchor itself. */ + bulkCloseActions?: (anchorTabId: string | undefined, dismiss: () => void) => ActionSheetAction[] }): React.JSX.Element { - const { target, onClose, onNavigate, onCloseTab } = props + const { target, onClose, onNavigate, onCloseTab, bulkCloseActions } = props return ( diff --git a/mobile/src/session/mobile-bulk-close-sheet-actions.ts b/mobile/src/session/mobile-bulk-close-sheet-actions.ts new file mode 100644 index 000000000..8565575fe --- /dev/null +++ b/mobile/src/session/mobile-bulk-close-sheet-actions.ts @@ -0,0 +1,96 @@ +import type { + MarkdownDocState, + MobileSessionTab +} from '../../app/h/[hostId]/session/mobile-session-route-types' +import type { ActionSheetAction } from '../components/ActionSheetModal' +import { + BULK_TAB_CLOSE_ACTIONS, + selectBulkCloseTabs, + type BulkTabCloseMode +} from './mobile-tab-close-selection' + +/** Session-route state the bulk close orchestration reads and drives. */ +type BulkCloseSheetDeps = { + sessionTabsRef: { readonly current: readonly MobileSessionTab[] } + markdownDocs: ReadonlyMap + activeSessionTabIdRef: { readonly current: string | null } + switchSessionTab: (tab: MobileSessionTab) => void + closeSessionTab: (tab: MobileSessionTab) => Promise +} + +/** + * Builds the long-press bulk-close entries (Close Others / Left / Right) shared + * by every session tab sheet. Lives outside the session route to keep the + * orchestration out of its max-lines budget; anchors are passed by tab id so + * sheets never need the full tab object. + */ +export function createBulkCloseSheetActions(deps: BulkCloseSheetDeps) { + const selectClosable = (anchorTabId: string, mode: BulkTabCloseMode) => + selectBulkCloseTabs(deps.sessionTabsRef.current, anchorTabId, mode).filter((candidate) => { + if (candidate.type !== 'markdown') { + return true + } + // Why: the tab list's isDirty can lag behind a phone draft; the local + // markdown doc state is the authority on unsaved edits. + const doc = deps.markdownDocs.get(candidate.id) + return !(doc?.status === 'ready' && doc.isDirty) + }) + + const bulkClose = async (anchor: MobileSessionTab, mode: BulkTabCloseMode) => { + const targets = selectClosable(anchor.id, mode) + const activeWasTargeted = targets.some( + (candidate) => candidate.id === deps.activeSessionTabIdRef.current + ) + // Why: activate the anchor before the per-tab close round-trips so the user + // never sits on a dying tab or an empty pane while the loop runs. + if (activeWasTargeted) { + deps.switchSessionTab(anchor) + } + for (const target of targets) { + await deps.closeSessionTab(target) + } + } + + return (anchorTabId: string | null | undefined, dismiss: () => void): ActionSheetAction[] => { + const anchor = + anchorTabId == null + ? undefined + : deps.sessionTabsRef.current.find((candidate) => candidate.id === anchorTabId) + if (!anchor) { + return [] + } + return BULK_TAB_CLOSE_ACTIONS.filter( + ({ mode }) => selectClosable(anchor.id, mode).length > 0 + ).map(({ mode, label }) => ({ + label, + destructive: true, + onPress: () => { + dismiss() + void bulkClose(anchor, mode) + } + })) + } +} + +/** + * Builds the destructive Close entry followed by the bulk-close entries, so + * per-tab-type sheets in the session route stay at one spread per call site. + */ +export function createCloseWithBulkActions( + closeSessionTab: (tab: MobileSessionTab) => Promise, + bulkActions: ReturnType +) { + return (target: MobileSessionTab | null, dismiss: () => void): ActionSheetAction[] => [ + { + label: 'Close', + destructive: true, + onPress: () => { + dismiss() + if (target) { + void closeSessionTab(target) + } + } + }, + ...bulkActions(target?.id, dismiss) + ] +} diff --git a/mobile/src/session/mobile-tab-close-selection.test.ts b/mobile/src/session/mobile-tab-close-selection.test.ts new file mode 100644 index 000000000..df47a6d65 --- /dev/null +++ b/mobile/src/session/mobile-tab-close-selection.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { selectBulkCloseTabs } from './mobile-tab-close-selection' + +const tab = (id: string, isDirty?: boolean, isPinned?: boolean) => ({ + id, + ...(isDirty === undefined ? {} : { isDirty }), + ...(isPinned === undefined ? {} : { isPinned }) +}) + +describe('selectBulkCloseTabs', () => { + const tabs = [tab('a'), tab('b'), tab('c'), tab('d')] + + it('selects every tab except the anchor for mode "others"', () => { + expect(selectBulkCloseTabs(tabs, 'b', 'others').map((t) => t.id)).toEqual(['a', 'c', 'd']) + }) + + it('selects tabs before the anchor for mode "left"', () => { + expect(selectBulkCloseTabs(tabs, 'c', 'left').map((t) => t.id)).toEqual(['a', 'b']) + }) + + it('selects tabs after the anchor for mode "right"', () => { + expect(selectBulkCloseTabs(tabs, 'b', 'right').map((t) => t.id)).toEqual(['c', 'd']) + }) + + it('returns empty when the anchor is at the edge', () => { + expect(selectBulkCloseTabs(tabs, 'a', 'left')).toEqual([]) + expect(selectBulkCloseTabs(tabs, 'd', 'right')).toEqual([]) + }) + + it('returns empty when the anchor is not in the list', () => { + expect(selectBulkCloseTabs(tabs, 'missing', 'others')).toEqual([]) + }) + + it('skips dirty tabs so unsaved edits survive a bulk close', () => { + const withDirty = [tab('a', true), tab('b'), tab('c', false), tab('d')] + expect(selectBulkCloseTabs(withDirty, 'd', 'left').map((t) => t.id)).toEqual(['b', 'c']) + expect(selectBulkCloseTabs(withDirty, 'b', 'others').map((t) => t.id)).toEqual(['c', 'd']) + }) + + it('skips pinned tabs', () => { + const withPinned = [tab('a', undefined, true), tab('b'), tab('c', undefined, true), tab('d')] + expect(selectBulkCloseTabs(withPinned, 'd', 'left').map((t) => t.id)).toEqual(['b']) + expect(selectBulkCloseTabs(withPinned, 'b', 'others').map((t) => t.id)).toEqual(['d']) + }) +}) diff --git a/mobile/src/session/mobile-tab-close-selection.ts b/mobile/src/session/mobile-tab-close-selection.ts new file mode 100644 index 000000000..1362173be --- /dev/null +++ b/mobile/src/session/mobile-tab-close-selection.ts @@ -0,0 +1,38 @@ +export type BulkTabCloseMode = 'others' | 'left' | 'right' + +/** Long-press sheet entries, in display order. */ +export const BULK_TAB_CLOSE_ACTIONS: { mode: BulkTabCloseMode; label: string }[] = [ + { mode: 'others', label: 'Close Other Tabs' }, + { mode: 'left', label: 'Close Tabs to the Left' }, + { mode: 'right', label: 'Close Tabs to the Right' } +] + +type BulkClosableTab = { + id: string + isDirty?: boolean + isPinned?: boolean +} + +/** + * Pick the tabs a long-press bulk close ("Close Other Tabs" / "Close Tabs to + * the Left/Right") should target, in strip order relative to the pressed tab. + * Dirty documents are skipped — mobile has no save prompt on close, so bulk + * closing must never silently discard unsaved edits. + */ +export function selectBulkCloseTabs( + tabs: readonly T[], + anchorTabId: string, + mode: BulkTabCloseMode +): T[] { + const anchorIndex = tabs.findIndex((tab) => tab.id === anchorTabId) + if (anchorIndex === -1) { + return [] + } + const candidates = + mode === 'others' + ? tabs.filter((_, index) => index !== anchorIndex) + : mode === 'left' + ? tabs.slice(0, anchorIndex) + : tabs.slice(anchorIndex + 1) + return candidates.filter((tab) => tab.isDirty !== true && tab.isPinned !== true) +} diff --git a/mobile/src/session/mobile-terminal-action-sheet-actions.ts b/mobile/src/session/mobile-terminal-action-sheet-actions.ts index f21468ff4..fd5ef5a55 100644 --- a/mobile/src/session/mobile-terminal-action-sheet-actions.ts +++ b/mobile/src/session/mobile-terminal-action-sheet-actions.ts @@ -19,6 +19,9 @@ export function getMobileTerminalActionSheetActions void onClear: (target: Target) => void onClose: (target: Target) => void + /** Appended after Close; receives the pressed tab's id so the session route's + * bulk-close builder can resolve the anchor itself. */ + bulkCloseActions?: (anchorTabId: string | undefined, dismiss: () => void) => ActionSheetAction[] }): ActionSheetAction[] { const { target } = args if (!target) { @@ -64,6 +67,10 @@ export function getMobileTerminalActionSheetActions tab.terminal === target.handle)?.id, + args.onDismiss + ) ?? []) ] } diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index ad71a216b..b25d74b5b 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1414,18 +1414,17 @@ function Terminal(): React.JSX.Element | null { [consumeSuppressedPtyExit] ) - const handleCloseOthers = useCallback( - (tabId: string) => { + // Bulk-close for the tab bar: unlike closeUnifiedTab it must route each id to + // its backend (web-runtime sessions, terminals, editor files, browser tabs), + // skip pinned tabs, and defer dirty editor files to the confirm flow. + const closeTabBarTabs = useCallback( + (tabIds: string[]) => { if (!activeWorktreeId) { return } const state = useAppStore.getState() - const order = state.tabBarOrderByWorktree[activeWorktreeId] ?? [] const dirtyFileIds: string[] = [] - for (const id of order) { - if (id === tabId) { - continue - } + for (const id of tabIds) { const unifiedTab = (state.unifiedTabsByWorktree[activeWorktreeId] ?? []).find( (candidate) => candidate.id === id || candidate.entityId === id ) @@ -1468,6 +1467,10 @@ function Terminal(): React.JSX.Element | null { ) { destroyWorkspaceWebviews(state.browserPagesByWorkspace, id) closeBrowserTab(id) + } else if (unifiedTab?.contentType === 'simulator') { + // Why: simulator tabs live only in the unified-tab store, so the + // entity-store checks above never match them. + state.closeUnifiedTab(unifiedTab.id) } } if (dirtyFileIds.length > 0) { @@ -1477,69 +1480,45 @@ function Terminal(): React.JSX.Element | null { [activeWorktreeId, closeBrowserTab, closeFile, closeTab, queueEditorCloseRequests] ) + const handleCloseOthers = useCallback( + (tabId: string) => { + if (!activeWorktreeId) { + return + } + const order = useAppStore.getState().tabBarOrderByWorktree[activeWorktreeId] ?? [] + closeTabBarTabs(order.filter((id) => id !== tabId)) + }, + [activeWorktreeId, closeTabBarTabs] + ) + const handleCloseTabsToRight = useCallback( (tabId: string) => { if (!activeWorktreeId) { return } - const state = useAppStore.getState() - const currentOrder = state.tabBarOrderByWorktree[activeWorktreeId] ?? [] + const currentOrder = useAppStore.getState().tabBarOrderByWorktree[activeWorktreeId] ?? [] const index = currentOrder.indexOf(tabId) if (index === -1) { return } - const rightIds = currentOrder.slice(index + 1) - const dirtyFileIds: string[] = [] - for (const id of rightIds) { - const unifiedTab = (state.unifiedTabsByWorktree[activeWorktreeId] ?? []).find( - (candidate) => candidate.id === id || candidate.entityId === id - ) - if (unifiedTab?.isPinned) { - continue - } - const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) - if ( - isWebRuntimeSessionActive(runtimeEnvironmentId) && - (unifiedTab?.contentType === 'terminal' || - (unifiedTab?.contentType === 'browser' && - browserWorkspaceHasRemoteOwner(state, unifiedTab.entityId, runtimeEnvironmentId))) - ) { - if (unifiedTab.contentType === 'terminal') { - // Why: route terminal close through the destructive local lifecycle boundary before the paired-host RPC. - closeTerminalTab(unifiedTab.entityId) - } else { - void closeWebRuntimeSessionTab({ - worktreeId: activeWorktreeId, - tabId: unifiedTab.id, - environmentId: runtimeEnvironmentId, - reason: 'user' - }) - } - continue - } - if ((state.tabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id)) { - closeTab(id) - } else if ( - state.openFiles.some((file) => file.worktreeId === activeWorktreeId && file.id === id) - ) { - const file = state.openFiles.find((candidate) => candidate.id === id) - if (file?.isDirty) { - dirtyFileIds.push(id) - continue - } - closeFile(id) - } else if ( - (state.browserTabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id) - ) { - destroyWorkspaceWebviews(state.browserPagesByWorkspace, id) - closeBrowserTab(id) - } - } - if (dirtyFileIds.length > 0) { - queueEditorCloseRequests(dirtyFileIds) - } + closeTabBarTabs(currentOrder.slice(index + 1)) }, - [activeWorktreeId, closeBrowserTab, closeFile, closeTab, queueEditorCloseRequests] + [activeWorktreeId, closeTabBarTabs] + ) + + const handleCloseTabsToLeft = useCallback( + (tabId: string) => { + if (!activeWorktreeId) { + return + } + const currentOrder = useAppStore.getState().tabBarOrderByWorktree[activeWorktreeId] ?? [] + const index = currentOrder.indexOf(tabId) + if (index === -1) { + return + } + closeTabBarTabs(currentOrder.slice(0, index)) + }, + [activeWorktreeId, closeTabBarTabs] ) const handleCloseAllFiles = useCallback(() => { @@ -2035,6 +2014,7 @@ function Terminal(): React.JSX.Element | null { onClose={handleCloseTab} onCloseOthers={handleCloseOthers} onCloseToRight={handleCloseTabsToRight} + onCloseToLeft={handleCloseTabsToLeft} onNewTerminalTab={() => handleNewTab()} onNewTerminalWithShell={handleNewTab} onNewBrowserTab={handleNewBrowserTab} diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index b391bf4a4..fcb722021 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -805,8 +805,8 @@ export function FloatingTerminalPanel({ [activeGroup, closeFloatingItems] ) - const closeToRight = useCallback( - (visibleId: string) => { + const closeToSide = useCallback( + (visibleId: string, side: 'left' | 'right') => { const state = useAppStore.getState() const currentGroup = activeGroup ? state.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.find( @@ -826,9 +826,13 @@ export function FloatingTerminalPanel({ if (index === -1) { return } + const sideIds = + side === 'right' + ? currentGroup.tabOrder.slice(index + 1) + : currentGroup.tabOrder.slice(0, index) const tabById = new Map(currentGroupTabs.map((tab) => [tab.id, tab])) closeFloatingItems( - currentGroup.tabOrder.slice(index + 1).filter((tabId) => { + sideIds.filter((tabId) => { const tab = tabById.get(tabId) return tab ? !tab.isPinned : false }) @@ -837,6 +841,16 @@ export function FloatingTerminalPanel({ [activeGroup, closeFloatingItems] ) + const closeToRight = useCallback( + (visibleId: string) => closeToSide(visibleId, 'right'), + [closeToSide] + ) + + const closeToLeft = useCallback( + (visibleId: string) => closeToSide(visibleId, 'left'), + [closeToSide] + ) + const closeAllFiles = useCallback(() => { const state = useAppStore.getState() const currentGroupTabs = activeGroup @@ -1509,6 +1523,7 @@ export function FloatingTerminalPanel({ onClose={closeFloatingItem} onCloseOthers={closeOthers} onCloseToRight={closeToRight} + onCloseToLeft={closeToLeft} onNewTerminalTab={() => createFloatingTerminalTab()} onNewTerminalWithShell={createFloatingTerminalTab} onNewBrowserTab={createFloatingBrowserTab} diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index 2dfd84d87..6c8a4dc05 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -165,9 +165,13 @@ async function renderBrowserTab(tab: BrowserTabState): Promise { isActive: true, isPinned: false, hasTabsToRight: false, + hasTabsToLeft: false, + tabCount: 1, onActivate: () => {}, onClose: () => {}, + onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onDuplicate: () => {}, onTogglePin: () => {}, dragData: { diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 45bc82e79..185057c5d 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -106,9 +106,13 @@ export default function BrowserTab({ isActive, isPinned, hasTabsToRight, + hasTabsToLeft, + tabCount, onActivate, onClose, + onCloseOthers, onCloseToRight, + onCloseToLeft, onDuplicate, onTogglePin, dragData, @@ -119,9 +123,13 @@ export default function BrowserTab({ isActive: boolean isPinned: boolean hasTabsToRight: boolean + hasTabsToLeft: boolean + tabCount: number onActivate: () => void onClose: () => void + onCloseOthers: () => void onCloseToRight: () => void + onCloseToLeft: () => void onDuplicate: () => void onTogglePin: () => void dragData: TabDragItemData @@ -300,10 +308,16 @@ export default function BrowserTab({ {translate('auto.components.tab.bar.BrowserTab.1611a1324b', 'Close')} + + {translate('components.tab.bar.BrowserTab.closeOthers', 'Close Others')} + {translate('auto.components.tab.bar.BrowserTab.9dd880bd56', 'Close Tabs To The Right')} + + {translate('components.tab.bar.BrowserTab.closeTabsToLeft', 'Close Tabs To The Left')} + void window.api.shell.openUrl(openInBrowserUrl)} disabled={!isHttpUrl} diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 66071a7da..0a3d266e1 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -248,10 +248,14 @@ async function renderEditorFileTab( isActive: true, isPinned: false, hasTabsToRight: false, + hasTabsToLeft: false, + tabCount: 1, statusByRelativePath: new Map(), onActivate, onClose: () => {}, + onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onCloseAll: () => {}, onMakePermanent, onTogglePin: () => {}, diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 13efe982d..4af4e4fcf 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -37,10 +37,14 @@ export default function EditorFileTab({ isActive, isPinned, hasTabsToRight, + hasTabsToLeft, + tabCount, statusByRelativePath, onActivate, onClose, + onCloseOthers, onCloseToRight, + onCloseToLeft, onCloseAll, onMakePermanent, onTogglePin, @@ -52,10 +56,14 @@ export default function EditorFileTab({ isActive: boolean isPinned: boolean hasTabsToRight: boolean + hasTabsToLeft: boolean + tabCount: number statusByRelativePath: Map onActivate: () => void onClose: () => void + onCloseOthers: () => void onCloseToRight: () => void + onCloseToLeft: () => void onCloseAll: () => void onMakePermanent?: () => void onTogglePin: () => void @@ -404,6 +412,8 @@ export default function EditorFileTab({ isPinned={isPinned} isRenaming={isRenaming} hasTabsToRight={hasTabsToRight} + hasTabsToLeft={hasTabsToLeft} + tabCount={tabCount} canRename={canRename} canShowMarkdownPreview={canShowMarkdownPreview} resolvedLanguage={resolvedLanguage} @@ -414,8 +424,10 @@ export default function EditorFileTab({ onOpenRenameInput={openRenameInput} onTogglePin={onTogglePin} onClose={onClose} + onCloseOthers={onCloseOthers} onCloseAll={onCloseAll} onCloseToRight={onCloseToRight} + onCloseToLeft={onCloseToLeft} onOpenMarkdownPreview={openMarkdownPreview} /> diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx index 092574986..eaff4b93b 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx @@ -216,6 +216,8 @@ async function renderMenu(): Promise { isPinned: false, isRenaming: false, hasTabsToRight: false, + hasTabsToLeft: false, + tabCount: 1, canRename: true, canShowMarkdownPreview: false, resolvedLanguage: 'typescript', @@ -226,8 +228,10 @@ async function renderMenu(): Promise { onOpenRenameInput: vi.fn(), onTogglePin: vi.fn(), onClose: vi.fn(), + onCloseOthers: vi.fn(), onCloseAll: vi.fn(), onCloseToRight: vi.fn(), + onCloseToLeft: vi.fn(), onOpenMarkdownPreview: vi.fn() }) } @@ -285,6 +289,17 @@ describe('EditorFileTabContextMenu close-all shortcut', () => { expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(3) }) + it('renders Close Others and both directional close items', async () => { + const tree = expandNode(await renderMenu()) + const labels = findElementsByType(tree, 'DropdownMenuItem').map((item) => + extractText(item.props.children) + ) + + expect(labels).toContain('Close Others') + expect(labels.some((label) => label.includes('Close Tabs To The Right'))).toBe(true) + expect(labels.some((label) => label.includes('Close Tabs To The Left'))).toBe(true) + }) + it('hides the shortcut chip when close-all is unassigned', async () => { shortcutLabelMock.mockReturnValue(null) diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx index bbf9a75d7..1e386f4c0 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx @@ -44,6 +44,8 @@ type EditorFileTabContextMenuProps = { isPinned: boolean isRenaming: boolean hasTabsToRight: boolean + hasTabsToLeft: boolean + tabCount: number canRename: boolean canShowMarkdownPreview: boolean resolvedLanguage: string @@ -54,8 +56,10 @@ type EditorFileTabContextMenuProps = { onOpenRenameInput: () => void onTogglePin: () => void onClose: () => void + onCloseOthers: () => void onCloseAll: () => void onCloseToRight: () => void + onCloseToLeft: () => void onOpenMarkdownPreview: ( file: { filePath: string @@ -77,6 +81,8 @@ export function EditorFileTabContextMenu({ isPinned, isRenaming, hasTabsToRight, + hasTabsToLeft, + tabCount, canRename, canShowMarkdownPreview, resolvedLanguage, @@ -87,8 +93,10 @@ export function EditorFileTabContextMenu({ onOpenRenameInput, onTogglePin, onClose, + onCloseOthers, onCloseAll, onCloseToRight, + onCloseToLeft, onOpenMarkdownPreview }: EditorFileTabContextMenuProps): React.JSX.Element { const renameShortcut = useOptionalShortcutLabel('tab.rename') @@ -147,6 +155,9 @@ export function EditorFileTabContextMenu({ {translate('auto.components.tab.bar.EditorFileTabContextMenu.1ba8492c5b', 'Close')} {closeShortcut ? {closeShortcut} : null} + + {translate('components.tab.bar.EditorFileTabContextMenu.closeOthers', 'Close Others')} + {translate( @@ -164,6 +175,12 @@ export function EditorFileTabContextMenu({ 'Close Tabs To The Right' )} + + {translate( + 'components.tab.bar.EditorFileTabContextMenu.closeTabsToLeft', + 'Close Tabs To The Left' + )} + {canShowMarkdownPreview ? ( <> diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index d183891b6..eca3a27c5 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -245,6 +245,7 @@ async function renderSortableTab({ groupId: 'group-1', tabCount: 1, hasTabsToRight: false, + hasTabsToLeft: false, isActive: true, isPinned: false, isExpanded: false, @@ -252,6 +253,7 @@ async function renderSortableTab({ onClose: vi.fn(), onCloseOthers: vi.fn(), onCloseToRight: vi.fn(), + onCloseToLeft: vi.fn(), onSetCustomTitle, onSetTabColor: vi.fn(), onTogglePin: vi.fn(), diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 075bfcbff..b60473193 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -35,6 +35,7 @@ type SortableTabProps = { groupId: string tabCount: number hasTabsToRight: boolean + hasTabsToLeft: boolean isActive: boolean isPinned: boolean isExpanded: boolean @@ -42,6 +43,7 @@ type SortableTabProps = { onClose: (tabId: string) => void onCloseOthers: (tabId: string) => void onCloseToRight: (tabId: string) => void + onCloseToLeft: (tabId: string) => void onSetCustomTitle: (tabId: string, title: string | null) => void onSetTabColor: (tabId: string, color: string | null) => void onTogglePin: () => void @@ -65,6 +67,7 @@ export default function SortableTab({ groupId, tabCount, hasTabsToRight, + hasTabsToLeft, isActive, isPinned, isExpanded, @@ -72,6 +75,7 @@ export default function SortableTab({ onClose, onCloseOthers, onCloseToRight, + onCloseToLeft, onSetCustomTitle, onSetTabColor, onTogglePin, @@ -418,12 +422,14 @@ export default function SortableTab({ point={menuPoint} tabCount={tabCount} hasTabsToRight={hasTabsToRight} + hasTabsToLeft={hasTabsToLeft} isPinned={isPinned} onOpenChange={setMenuOpen} onActivate={onActivate} onClose={onClose} onCloseOthers={onCloseOthers} onCloseToRight={onCloseToRight} + onCloseToLeft={onCloseToLeft} onRenameOpen={handleRenameOpen} onSetTabColor={onSetTabColor} onTogglePin={onTogglePin} diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index 6fb148212..1022ccc7d 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -110,12 +110,14 @@ function renderMenu(overrides: Partial { }) }) + it('routes the directional close actions to their handlers with the tab id', () => { + const onCloseOthers = vi.fn() + const onCloseToRight = vi.fn() + const onCloseToLeft = vi.fn() + const { container } = renderMenu({ onCloseOthers, onCloseToRight, onCloseToLeft }) + + act(() => getButton(container, 'Close Others').click()) + expect(onCloseOthers).toHaveBeenCalledWith('term-1') + + act(() => getButton(container, 'Close Tabs To The Right').click()) + expect(onCloseToRight).toHaveBeenCalledWith('term-1') + + act(() => getButton(container, 'Close Tabs To The Left').click()) + expect(onCloseToLeft).toHaveBeenCalledWith('term-1') + }) + + it('disables directional closes when no tabs exist on that side', () => { + const { container } = renderMenu({ hasTabsToLeft: false, hasTabsToRight: false }) + + expect(getButton(container, 'Close Tabs To The Left').disabled).toBe(true) + expect(getButton(container, 'Close Tabs To The Right').disabled).toBe(true) + }) + it('hides move-tab split actions for a single-tab group', () => { storeMock.state = { ...storeMock.state, diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 469c4f715..40f250285 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -94,12 +94,14 @@ type SortableTabContextMenuProps = { point: { x: number; y: number } tabCount: number hasTabsToRight: boolean + hasTabsToLeft: boolean isPinned: boolean onOpenChange: (open: boolean) => void onActivate: (tabId: string) => void onClose: (tabId: string) => void onCloseOthers: (tabId: string) => void onCloseToRight: (tabId: string) => void + onCloseToLeft: (tabId: string) => void onRenameOpen: () => void onSetTabColor: (tabId: string, color: string | null) => void onTogglePin: () => void @@ -122,12 +124,14 @@ export function SortableTabContextMenu({ point, tabCount, hasTabsToRight, + hasTabsToLeft, isPinned, onOpenChange, onActivate, onClose, onCloseOthers, onCloseToRight, + onCloseToLeft, onRenameOpen, onSetTabColor, onTogglePin, @@ -211,6 +215,12 @@ export function SortableTabContextMenu({ 'Close Tabs To The Right' )} + onCloseToLeft(tab.id)} disabled={!hasTabsToLeft}> + {translate( + 'components.tab.bar.SortableTabContextMenu.closeTabsToLeft', + 'Close Tabs To The Left' + )} + diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index 70dabed9b..ecc0af555 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -273,6 +273,7 @@ async function renderTabBar(props: Record): Promise { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewBrowserTab: () => {}, onSetCustomTitle: () => {}, @@ -399,6 +400,33 @@ describe('TabBar context menu wiring', () => { expect(onCloseToRight).toHaveBeenCalledWith('unified-editor-1') }) + it('wires onCloseToLeft/onCloseOthers and hasTabsToLeft by strip position', async () => { + const onCloseToLeft = vi.fn() + const onCloseOthers = vi.fn() + const element = await renderTabBar({ + tabs: [TERMINAL_TAB], + editorFiles: [EDITOR_FILE], + browserTabs: [], + tabBarOrder: ['term-1', 'unified-editor-1'], + onCloseToLeft, + onCloseOthers + }) + + const sortable = findChildrenByType(element, 'SortableTab') + expect(sortable).toHaveLength(1) + // First tab in the strip: nothing to its left. + expect(sortable[0].props.hasTabsToLeft).toBe(false) + + const editorTabs = findChildrenByType(element, 'EditorFileTab') + expect(editorTabs).toHaveLength(1) + expect(editorTabs[0].props.hasTabsToLeft).toBe(true) + expect(editorTabs[0].props.tabCount).toBe(2) + ;(editorTabs[0].props.onCloseToLeft as () => void)() + expect(onCloseToLeft).toHaveBeenCalledWith('unified-editor-1') + ;(editorTabs[0].props.onCloseOthers as () => void)() + expect(onCloseOthers).toHaveBeenCalledWith('unified-editor-1') + }) + it('passes pinned state and toggles unpin through the unified tab id', async () => { appStoreSnapshot.unifiedTabsByWorktree = { 'wt-1': [ diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 95de615d4..e9eb7a96f 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -112,6 +112,7 @@ type TabBarProps = { onClose: (tabId: string) => void onCloseOthers: (tabId: string) => void onCloseToRight: (tabId: string) => void + onCloseToLeft: (tabId: string) => void onNewTerminalTab: () => void /** On Windows, opens a new terminal with a specific shell instead of the default. */ onNewTerminalWithShell?: (shell: string) => void @@ -238,6 +239,7 @@ function TabBarInner({ onClose, onCloseOthers, onCloseToRight, + onCloseToLeft, onNewTerminalTab, onNewTerminalWithShell, onNewBrowserTab, @@ -1071,6 +1073,7 @@ function TabBarInner({ unifiedTabForItem ? () => toggleTabViewMode(unifiedTabForItem.id) : undefined } hasTabsToRight={index < orderedItems.length - 1} + hasTabsToLeft={index > 0} isActive={ (activeTabType === 'terminal' || activeTabType === 'simulator') && item.id === activeTabId @@ -1081,6 +1084,7 @@ function TabBarInner({ onClose={onClose} onCloseOthers={onCloseOthers} onCloseToRight={onCloseToRight} + onCloseToLeft={onCloseToLeft} onSetCustomTitle={onSetCustomTitle} onSetTabColor={onSetTabColor} onTogglePin={() => togglePinned(item)} @@ -1099,9 +1103,13 @@ function TabBarInner({ isActive={activeTabType === 'browser' && activeBrowserTabId === item.id} isPinned={item.isPinned} hasTabsToRight={index < orderedItems.length - 1} + hasTabsToLeft={index > 0} + tabCount={orderedItems.length} onActivate={() => onActivateBrowserTab?.(item.id)} onClose={() => onCloseBrowserTab?.(item.id)} + onCloseOthers={() => onCloseOthers(item.id)} onCloseToRight={() => onCloseToRight(item.id)} + onCloseToLeft={() => onCloseToLeft(item.id)} onDuplicate={() => onDuplicateBrowserTab?.(item.id)} onTogglePin={() => togglePinned(item)} dragData={dragData} @@ -1130,10 +1138,14 @@ function TabBarInner({ isActive={activeTabType === 'simulator' && item.id === activeSimulatorTabId} isPinned={item.isPinned} hasTabsToRight={index < orderedItems.length - 1} + hasTabsToLeft={index > 0} + tabCount={orderedItems.length} statusByRelativePath={statusByRelativePath} onActivate={() => onActivateFile?.(item.id)} onClose={() => onCloseFile?.(item.id)} + onCloseOthers={() => onCloseOthers(item.id)} onCloseToRight={() => onCloseToRight(item.id)} + onCloseToLeft={() => onCloseToLeft(item.id)} onCloseAll={() => onCloseAllFiles?.()} onMakePermanent={() => {}} onTogglePin={() => togglePinned(item)} @@ -1153,10 +1165,14 @@ function TabBarInner({ } isPinned={item.isPinned} hasTabsToRight={index < orderedItems.length - 1} + hasTabsToLeft={index > 0} + tabCount={orderedItems.length} statusByRelativePath={statusByRelativePath} onActivate={() => onActivateFile?.(item.id)} onClose={() => onCloseFile?.(item.id)} + onCloseOthers={() => onCloseOthers(item.id)} onCloseToRight={() => onCloseToRight(item.id)} + onCloseToLeft={() => onCloseToLeft(item.id)} onCloseAll={() => onCloseAllFiles?.()} onMakePermanent={() => onMakePreviewFilePermanent?.(item.data.id, item.data.tabId) diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index 58cd811e4..5e439f4dd 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -403,6 +403,7 @@ describe('TabBar PowerShell launch wiring', () => { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewTerminalWithShell, onNewBrowserTab: () => {}, @@ -474,6 +475,7 @@ describe('TabBar PowerShell launch wiring', () => { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewTerminalWithShell: () => {}, onNewBrowserTab: () => {}, @@ -541,6 +543,7 @@ describe('TabBar PowerShell launch wiring', () => { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewTerminalWithShell: () => {}, onNewBrowserTab: () => {}, @@ -604,6 +607,7 @@ describe('TabBar PowerShell launch wiring', () => { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewTerminalWithShell: () => {}, onNewBrowserTab: () => {}, @@ -671,6 +675,7 @@ describe('TabBar PowerShell launch wiring', () => { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewTerminalWithShell: () => {}, onNewBrowserTab: () => {}, @@ -723,6 +728,7 @@ describe('TabBar PowerShell launch wiring', () => { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewTerminalWithShell, onNewBrowserTab: () => {}, @@ -786,6 +792,7 @@ describe('TabBar PowerShell launch wiring', () => { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewTerminalWithShell, onNewBrowserTab: () => {}, @@ -855,6 +862,7 @@ describe('TabBar PowerShell launch wiring', () => { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewTerminalWithShell: vi.fn(), onNewBrowserTab: () => {}, @@ -926,6 +934,7 @@ describe('TabBar PowerShell launch wiring', () => { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewTerminalWithShell: vi.fn(), onNewBrowserTab: () => {}, diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index 975adc0e5..e69e9ea18 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -262,6 +262,7 @@ describe('tab title tooltips', () => { groupId="group-1" tabCount={1} hasTabsToRight={false} + hasTabsToLeft={false} isActive={true} isPinned={false} isExpanded={false} @@ -269,6 +270,7 @@ describe('tab title tooltips', () => { onClose={vi.fn()} onCloseOthers={vi.fn()} onCloseToRight={vi.fn()} + onCloseToLeft={vi.fn()} onSetCustomTitle={vi.fn()} onSetTabColor={vi.fn()} onTogglePin={vi.fn()} @@ -295,6 +297,7 @@ describe('tab title tooltips', () => { groupId="group-1" tabCount={1} hasTabsToRight={false} + hasTabsToLeft={false} isActive={true} isPinned={false} isExpanded={false} @@ -302,6 +305,7 @@ describe('tab title tooltips', () => { onClose={vi.fn()} onCloseOthers={vi.fn()} onCloseToRight={vi.fn()} + onCloseToLeft={vi.fn()} onSetCustomTitle={vi.fn()} onSetTabColor={vi.fn()} onTogglePin={vi.fn()} @@ -325,9 +329,13 @@ describe('tab title tooltips', () => { isActive={false} isPinned={false} hasTabsToRight={false} + hasTabsToLeft={false} + tabCount={1} onActivate={vi.fn()} onClose={vi.fn()} + onCloseOthers={vi.fn()} onCloseToRight={vi.fn()} + onCloseToLeft={vi.fn()} onDuplicate={vi.fn()} onTogglePin={vi.fn()} dragData={makeDragData('browser', 'browser-1')} @@ -351,10 +359,14 @@ describe('tab title tooltips', () => { isActive={false} isPinned={false} hasTabsToRight={false} + hasTabsToLeft={false} + tabCount={1} statusByRelativePath={new Map()} onActivate={vi.fn()} onClose={vi.fn()} + onCloseOthers={vi.fn()} onCloseToRight={vi.fn()} + onCloseToLeft={vi.fn()} onCloseAll={vi.fn()} onMakePermanent={vi.fn()} onTogglePin={vi.fn()} diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index a9f7f2ac5..07d8a86cc 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -105,6 +105,12 @@ export default function TabGroupPanel({ commands.closeToRight(item.id) } }} + onCloseToLeft={(visibleId) => { + const item = resolveGroupTabFromVisibleId(model.groupTabs, visibleId) + if (item) { + commands.closeToLeft(item.id) + } + }} onNewTerminalTab={commands.newTerminalTab} onNewTerminalWithShell={commands.newTerminalWithShell} onNewBrowserTab={commands.newBrowserTab} diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index c3e4cfcc6..4bb234b97 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -525,6 +525,25 @@ export function useTabGroupWorkspaceModel({ [closeMany, group, groupTabs] ) + const closeToLeft = useCallback( + (itemId: string) => { + // Why: see closeToRight — walk tabOrder locally and route through the + // dirty-aware closeMany path instead of the store helper. + const order = group?.tabOrder ?? [] + const index = order.indexOf(itemId) + if (index === -1) { + return + } + const tabById = new Map(groupTabs.map((candidate) => [candidate.id, candidate])) + const leftIds = order.slice(0, index).filter((id) => { + const candidate = tabById.get(id) + return candidate ? !candidate.isPinned : false + }) + closeMany(leftIds) + }, + [closeMany, group, groupTabs] + ) + const tabBarOrder = useMemo( () => (group?.tabOrder ?? []).map((itemId) => { @@ -560,6 +579,7 @@ export function useTabGroupWorkspaceModel({ closeItem, closeOthers, closeToRight, + closeToLeft, createSplitGroup, newBrowserTab: () => { void openNewBrowserTabInActiveWorkspace(groupId) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a904c79bb..c0f5c1446 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13767,7 +13767,16 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "Switch to terminal view", - "switchToChatView": "Switch to chat view" + "switchToChatView": "Switch to chat view", + "closeTabsToLeft": "Close Tabs To The Left" + }, + "BrowserTab": { + "closeOthers": "Close Others", + "closeTabsToLeft": "Close Tabs To The Left" + }, + "EditorFileTabContextMenu": { + "closeOthers": "Close Others", + "closeTabsToLeft": "Close Tabs To The Left" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ed53674bb..01f938d15 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13744,7 +13744,16 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "Cambiar a la vista de terminal", - "switchToChatView": "Cambiar a vista de chat" + "switchToChatView": "Cambiar a vista de chat", + "closeTabsToLeft": "Cerrar pestañas a la izquierda" + }, + "BrowserTab": { + "closeOthers": "Cerrar otras", + "closeTabsToLeft": "Cerrar pestañas a la izquierda" + }, + "EditorFileTabContextMenu": { + "closeOthers": "Cerrar otras", + "closeTabsToLeft": "Cerrar pestañas a la izquierda" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b151276ec..8c6cf1073 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13744,7 +13744,16 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "terminal ビューに切り替える", - "switchToChatView": "チャットビューに切り替える" + "switchToChatView": "チャットビューに切り替える", + "closeTabsToLeft": "左側のタブを閉じる" + }, + "BrowserTab": { + "closeOthers": "その他を閉じる", + "closeTabsToLeft": "左側のタブを閉じる" + }, + "EditorFileTabContextMenu": { + "closeOthers": "その他を閉じる", + "closeTabsToLeft": "左側のタブを閉じる" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 65d811b84..dd754de94 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13744,7 +13744,16 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "terminal 보기로 전환", - "switchToChatView": "채팅 보기로 전환" + "switchToChatView": "채팅 보기로 전환", + "closeTabsToLeft": "왼쪽으로 탭 닫기" + }, + "BrowserTab": { + "closeOthers": "다른 탭 닫기", + "closeTabsToLeft": "왼쪽으로 탭 닫기" + }, + "EditorFileTabContextMenu": { + "closeOthers": "다른 탭 닫기", + "closeTabsToLeft": "왼쪽으로 탭 닫기" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index deb949c95..11b6e0fe7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13744,7 +13744,16 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "切换到终端视图", - "switchToChatView": "切换到聊天视图" + "switchToChatView": "切换到聊天视图", + "closeTabsToLeft": "关闭左侧的选项卡" + }, + "BrowserTab": { + "closeOthers": "关闭其他", + "closeTabsToLeft": "关闭左侧的选项卡" + }, + "EditorFileTabContextMenu": { + "closeOthers": "关闭其他", + "closeTabsToLeft": "关闭左侧的选项卡" } } }, diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index fce096e98..2d521a43b 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -1401,6 +1401,46 @@ describe('TabsSlice', () => { }) }) + // ─── closeTabsToLeft ────────────────────────────────────────────── + + describe('closeTabsToLeft', () => { + it('closes unpinned tabs to the left of target', () => { + const t1 = store.getState().createUnifiedTab(WT, 'terminal') + const t2 = store.getState().createUnifiedTab(WT, 'terminal') + const t3 = store.getState().createUnifiedTab(WT, 'terminal') + const t4 = store.getState().createUnifiedTab(WT, 'terminal') + + store.getState().pinTab(t2.id) + + const closed = store.getState().closeTabsToLeft(t4.id) + + expect(closed).toEqual([t1.id, t3.id]) + const tabs = store.getState().unifiedTabsByWorktree[WT] + expect(tabs.map((t) => t.id)).toEqual([t2.id, t4.id]) + }) + + it('returns empty when target is the leftmost tab', () => { + const t1 = store.getState().createUnifiedTab(WT, 'terminal') + store.getState().createUnifiedTab(WT, 'terminal') + + const closed = store.getState().closeTabsToLeft(t1.id) + + expect(closed).toEqual([]) + expect(store.getState().unifiedTabsByWorktree[WT]).toHaveLength(2) + }) + + it('activates target if active tab was closed', () => { + store.getState().createUnifiedTab(WT, 'terminal') + const t2 = store.getState().createUnifiedTab(WT, 'terminal') + const t3 = store.getState().createUnifiedTab(WT, 'terminal') + store.getState().activateTab(t2.id) + + store.getState().closeTabsToLeft(t3.id) + + expect(store.getState().groupsByWorktree[WT][0].activeTabId).toBe(t3.id) + }) + }) + // ─── getActiveTab / getTab ──────────────────────────────────────── describe('getActiveTab / getTab', () => { diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index fefbecd53..3c070f107 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -135,6 +135,7 @@ export type TabsSlice = { unpinTab: (tabId: string) => void closeOtherTabs: (tabId: string) => string[] closeTabsToRight: (tabId: string) => string[] + closeTabsToLeft: (tabId: string) => string[] ensureWorktreeRootGroup: (worktreeId: string) => string focusGroup: (worktreeId: string, groupId: string) => void closeEmptyGroup: (worktreeId: string, groupId: string) => boolean @@ -1231,6 +1232,34 @@ export const createTabsSlice: StateCreator = (set, return closableIds }, + closeTabsToLeft: (tabId) => { + const state = get() + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return [] + } + const { tab, worktreeId } = found + const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) + if (!group) { + return [] + } + const index = group.tabOrder.indexOf(tabId) + if (index === -1) { + return [] + } + const closableIds = group.tabOrder + .slice(0, index) + .filter( + (id) => + !(state.unifiedTabsByWorktree[worktreeId] ?? []).find((candidate) => candidate.id === id) + ?.isPinned + ) + for (const id of closableIds) { + get().closeUnifiedTab(id) + } + return closableIds + }, + ensureWorktreeRootGroup: (worktreeId) => { const existingGroups = get().groupsByWorktree[worktreeId] ?? [] if (existingGroups.length > 0) {