Add bulk tab closing to mobile long-press sheets (Close Others / Left / Right) and complete the desktop tab context menus (#9323)

* Add Close Tabs to the Left and complete Close Others across tab menus and mobile long-press sheets

* Fold the per-sheet Close action into the bulk-close module (session route max-lines)

* fix(mobile): preserve pinned tabs during bulk close

---------

Co-authored-by: Tom de Bres <tomdebres@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Tom 2026-07-26 10:32:08 +01:00 committed by GitHub
parent 248c0d9cda
commit 6577b79e2e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 593 additions and 96 deletions

View File

@ -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}
/>
<ActionSheetModal
visible={leaveDrafts != null}

View File

@ -1,6 +1,6 @@
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native'
import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types'
import { ActionSheetModal } from '../components/ActionSheetModal'
import { ActionSheetModal, type ActionSheetAction } from '../components/ActionSheetModal'
import { getMobileSessionTabTitle } from './mobile-terminal-tab-agent'
type BrowserTab = Extract<MobileSessionTab, { type: 'browser' }>
@ -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 (
<ActionSheetModal
visible={target != null}
@ -71,7 +74,8 @@ export function MobileBrowserTabActionSheet(props: {
onCloseTab(current)
}
}
}
},
...(bulkCloseActions?.(target?.id, onClose) ?? [])
]}
onClose={onClose}
/>

View File

@ -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<string, MarkdownDocState>
activeSessionTabIdRef: { readonly current: string | null }
switchSessionTab: (tab: MobileSessionTab) => void
closeSessionTab: (tab: MobileSessionTab) => Promise<void>
}
/**
* 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<void>,
bulkActions: ReturnType<typeof createBulkCloseSheetActions>
) {
return (target: MobileSessionTab | null, dismiss: () => void): ActionSheetAction[] => [
{
label: 'Close',
destructive: true,
onPress: () => {
dismiss()
if (target) {
void closeSessionTab(target)
}
}
},
...bulkActions(target?.id, dismiss)
]
}

View File

@ -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'])
})
})

View File

@ -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<T extends BulkClosableTab>(
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)
}

View File

@ -19,6 +19,9 @@ export function getMobileTerminalActionSheetActions<Target extends { handle: str
onRename: (target: Target) => 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<Target extends { handle: str
args.onDismiss()
args.onClose(target)
}
}
},
...(args.bulkCloseActions?.(
args.tabs.find((tab) => tab.terminal === target.handle)?.id,
args.onDismiss
) ?? [])
]
}

View File

@ -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}

View File

@ -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}

View File

@ -165,9 +165,13 @@ async function renderBrowserTab(tab: BrowserTabState): Promise<unknown> {
isActive: true,
isPinned: false,
hasTabsToRight: false,
hasTabsToLeft: false,
tabCount: 1,
onActivate: () => {},
onClose: () => {},
onCloseOthers: () => {},
onCloseToRight: () => {},
onCloseToLeft: () => {},
onDuplicate: () => {},
onTogglePin: () => {},
dragData: {

View File

@ -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({
<X className="size-3.5" />
{translate('auto.components.tab.bar.BrowserTab.1611a1324b', 'Close')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseOthers} disabled={tabCount <= 1}>
{translate('components.tab.bar.BrowserTab.closeOthers', 'Close Others')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseToRight} disabled={!hasTabsToRight}>
<PanelRightClose className="size-3.5" />
{translate('auto.components.tab.bar.BrowserTab.9dd880bd56', 'Close Tabs To The Right')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseToLeft} disabled={!hasTabsToLeft}>
{translate('components.tab.bar.BrowserTab.closeTabsToLeft', 'Close Tabs To The Left')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => void window.api.shell.openUrl(openInBrowserUrl)}
disabled={!isHttpUrl}

View File

@ -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: () => {},

View File

@ -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<string, GitFileStatus>
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}
/>
</>

View File

@ -216,6 +216,8 @@ async function renderMenu(): Promise<unknown> {
isPinned: false,
isRenaming: false,
hasTabsToRight: false,
hasTabsToLeft: false,
tabCount: 1,
canRename: true,
canShowMarkdownPreview: false,
resolvedLanguage: 'typescript',
@ -226,8 +228,10 @@ async function renderMenu(): Promise<unknown> {
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)

View File

@ -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 ? <DropdownMenuShortcut>{closeShortcut}</DropdownMenuShortcut> : null}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseOthers} disabled={tabCount <= 1}>
{translate('components.tab.bar.EditorFileTabContextMenu.closeOthers', 'Close Others')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseAll}>
<ListX className="size-3.5" />
{translate(
@ -164,6 +175,12 @@ export function EditorFileTabContextMenu({
'Close Tabs To The Right'
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseToLeft} disabled={!hasTabsToLeft}>
{translate(
'components.tab.bar.EditorFileTabContextMenu.closeTabsToLeft',
'Close Tabs To The Left'
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
{canShowMarkdownPreview ? (
<>

View File

@ -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(),

View File

@ -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}

View File

@ -110,12 +110,14 @@ function renderMenu(overrides: Partial<ComponentProps<typeof SortableTabContextM
point={{ x: 0, y: 0 }}
tabCount={2}
hasTabsToRight
hasTabsToLeft
isPinned={false}
onOpenChange={vi.fn()}
onActivate={onActivate}
onClose={vi.fn()}
onCloseOthers={vi.fn()}
onCloseToRight={vi.fn()}
onCloseToLeft={vi.fn()}
onRenameOpen={vi.fn()}
onSetTabColor={vi.fn()}
onTogglePin={vi.fn()}
@ -237,6 +239,29 @@ describe('SortableTabContextMenu', () => {
})
})
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,

View File

@ -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'
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onCloseToLeft(tab.id)} disabled={!hasTabsToLeft}>
{translate(
'components.tab.bar.SortableTabContextMenu.closeTabsToLeft',
'Close Tabs To The Left'
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onRenameOpen}>
<Pencil className="size-3.5" />

View File

@ -273,6 +273,7 @@ async function renderTabBar(props: Record<string, unknown>): Promise<unknown> {
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': [

View File

@ -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)

View File

@ -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: () => {},

View File

@ -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<string, GitFileStatus>()}
onActivate={vi.fn()}
onClose={vi.fn()}
onCloseOthers={vi.fn()}
onCloseToRight={vi.fn()}
onCloseToLeft={vi.fn()}
onCloseAll={vi.fn()}
onMakePermanent={vi.fn()}
onTogglePin={vi.fn()}

View File

@ -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}

View File

@ -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)

View File

@ -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"
}
}
},

View File

@ -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"
}
}
},

View File

@ -13744,7 +13744,16 @@
"bar": {
"SortableTabContextMenu": {
"switchToTerminalView": "terminal ビューに切り替える",
"switchToChatView": "チャットビューに切り替える"
"switchToChatView": "チャットビューに切り替える",
"closeTabsToLeft": "左側のタブを閉じる"
},
"BrowserTab": {
"closeOthers": "その他を閉じる",
"closeTabsToLeft": "左側のタブを閉じる"
},
"EditorFileTabContextMenu": {
"closeOthers": "その他を閉じる",
"closeTabsToLeft": "左側のタブを閉じる"
}
}
},

View File

@ -13744,7 +13744,16 @@
"bar": {
"SortableTabContextMenu": {
"switchToTerminalView": "terminal 보기로 전환",
"switchToChatView": "채팅 보기로 전환"
"switchToChatView": "채팅 보기로 전환",
"closeTabsToLeft": "왼쪽으로 탭 닫기"
},
"BrowserTab": {
"closeOthers": "다른 탭 닫기",
"closeTabsToLeft": "왼쪽으로 탭 닫기"
},
"EditorFileTabContextMenu": {
"closeOthers": "다른 탭 닫기",
"closeTabsToLeft": "왼쪽으로 탭 닫기"
}
}
},

View File

@ -13744,7 +13744,16 @@
"bar": {
"SortableTabContextMenu": {
"switchToTerminalView": "切换到终端视图",
"switchToChatView": "切换到聊天视图"
"switchToChatView": "切换到聊天视图",
"closeTabsToLeft": "关闭左侧的选项卡"
},
"BrowserTab": {
"closeOthers": "关闭其他",
"closeTabsToLeft": "关闭左侧的选项卡"
},
"EditorFileTabContextMenu": {
"closeOthers": "关闭其他",
"closeTabsToLeft": "关闭左侧的选项卡"
}
}
},

View File

@ -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', () => {

View File

@ -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<AppState, [], [], TabsSlice> = (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) {