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:
parent
248c0d9cda
commit
6577b79e2e
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
]
|
||||
}
|
||||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
) ?? [])
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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: () => {},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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': [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: () => {},
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13744,7 +13744,16 @@
|
|||
"bar": {
|
||||
"SortableTabContextMenu": {
|
||||
"switchToTerminalView": "terminal ビューに切り替える",
|
||||
"switchToChatView": "チャットビューに切り替える"
|
||||
"switchToChatView": "チャットビューに切り替える",
|
||||
"closeTabsToLeft": "左側のタブを閉じる"
|
||||
},
|
||||
"BrowserTab": {
|
||||
"closeOthers": "その他を閉じる",
|
||||
"closeTabsToLeft": "左側のタブを閉じる"
|
||||
},
|
||||
"EditorFileTabContextMenu": {
|
||||
"closeOthers": "その他を閉じる",
|
||||
"closeTabsToLeft": "左側のタブを閉じる"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13744,7 +13744,16 @@
|
|||
"bar": {
|
||||
"SortableTabContextMenu": {
|
||||
"switchToTerminalView": "terminal 보기로 전환",
|
||||
"switchToChatView": "채팅 보기로 전환"
|
||||
"switchToChatView": "채팅 보기로 전환",
|
||||
"closeTabsToLeft": "왼쪽으로 탭 닫기"
|
||||
},
|
||||
"BrowserTab": {
|
||||
"closeOthers": "다른 탭 닫기",
|
||||
"closeTabsToLeft": "왼쪽으로 탭 닫기"
|
||||
},
|
||||
"EditorFileTabContextMenu": {
|
||||
"closeOthers": "다른 탭 닫기",
|
||||
"closeTabsToLeft": "왼쪽으로 탭 닫기"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13744,7 +13744,16 @@
|
|||
"bar": {
|
||||
"SortableTabContextMenu": {
|
||||
"switchToTerminalView": "切换到终端视图",
|
||||
"switchToChatView": "切换到聊天视图"
|
||||
"switchToChatView": "切换到聊天视图",
|
||||
"closeTabsToLeft": "关闭左侧的选项卡"
|
||||
},
|
||||
"BrowserTab": {
|
||||
"closeOthers": "关闭其他",
|
||||
"closeTabsToLeft": "关闭左侧的选项卡"
|
||||
},
|
||||
"EditorFileTabContextMenu": {
|
||||
"closeOthers": "关闭其他",
|
||||
"closeTabsToLeft": "关闭左侧的选项卡"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue