mirror of
https://github.com/stablyai/orca.git
synced 2026-11-05 03:27:44 +00:00
Confirm before bulk-closing tabs (#30551)
* Confirm bulk tab closes before closing tabs Close Others, Close Tabs to the Left/Right, and Close All Editor Tabs now show a confirmation dialog first, so one misclick or shortcut no longer closes a group of tabs at once. Pinned tabs are still skipped. Tabs are selected again after the user confirms. If a tab was pinned, moved, or removed while the dialog was open, it is left alone. Tabs created after the request are not closed. The shared confirmation lives in use-bulk-tab-close-confirmation.ts and is used by both the tab bar and the tab group. The tab group hook now reads the store directly instead of taking group and groupTabs as props. * Confirm bulk tab closes before closing tabs Close Others, Close Tabs to the Left/Right, and Close All Editor Tabs now show a confirmation dialog first, so one misclick or shortcut no longer closes a group of tabs at once. Pinned tabs are still skipped. Tabs are selected again after the user confirms. If a tab was pinned, moved, or removed while the dialog was open, it is left alone. Tabs created after the request are not closed. The shared confirmation lives in use-bulk-tab-close-confirmation.ts and is used by both the tab bar and the tab group. The tab group hook now reads the store directly instead of taking group and groupTabs as props.
This commit is contained in:
@@ -112,7 +112,7 @@ function statementsFrom(body, start) {
|
||||
break
|
||||
}
|
||||
depth -= 1
|
||||
} else if (char === '\n' && depth === 0) {
|
||||
} else if (depth === 0 && (char === '\n' || (char === ',' && match[0].startsWith('=>')))) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,36 @@ describe('countFocusSettingReads', () => {
|
||||
expect(readers.namesFor('src/renderer/src/hook.ts').has('sideEffectOnly')).toBe(false)
|
||||
})
|
||||
|
||||
it('stops an arrow result before the next argument without losing nested reader results', () => {
|
||||
const readers = discoverFocusReaders(
|
||||
new Map([
|
||||
[
|
||||
'src/renderer/src/focus.ts',
|
||||
'export function focusedId(s) {\n return s.activeRuntimeEnvironmentId\n}\n'
|
||||
],
|
||||
[
|
||||
'src/renderer/src/hook.ts',
|
||||
[
|
||||
"import { focusedId } from './focus'",
|
||||
'export function useCloseActions(s) {',
|
||||
' const closeTabs = () => { focusedId(s) }',
|
||||
' const closeOthers = () => {',
|
||||
' confirmClose(() => selectIds(s), closeTabs)',
|
||||
' }',
|
||||
' return { closeOthers }',
|
||||
'}',
|
||||
'export const wrappedReader = (s) => forward(null, focusedId(s))',
|
||||
'export const commaReader = (s) => (null, focusedId(s))'
|
||||
].join('\n')
|
||||
]
|
||||
])
|
||||
)
|
||||
const names = readers.namesFor('src/renderer/src/hook.ts')
|
||||
expect(names.has('useCloseActions')).toBe(false)
|
||||
expect(names.has('wrappedReader')).toBe(true)
|
||||
expect(names.has('commaReader')).toBe(true)
|
||||
})
|
||||
|
||||
it('counts the default scope source as a read', () => {
|
||||
expect(countFocusSettingReads('linearConnect(defaultScopeSource(get().settings), key)')).toBe(1)
|
||||
})
|
||||
|
||||
+7
-2
@@ -11,6 +11,9 @@ import {
|
||||
import { useFloatingTerminalCloseActions } from './use-floating-terminal-close-actions'
|
||||
|
||||
const requestEditorFileClose = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../confirmation-dialog-context', () => ({
|
||||
useConfirmationDialog: () => async () => true
|
||||
}))
|
||||
vi.mock('@/components/editor/editor-autosave', () => ({ requestEditorFileClose }))
|
||||
|
||||
const worktreeId = FLOATING_TERMINAL_WORKTREE_ID
|
||||
@@ -89,8 +92,9 @@ describe('floating titlebar close actions', () => {
|
||||
expect(closeUnifiedTab).toHaveBeenCalledWith(editor.id)
|
||||
})
|
||||
|
||||
it('closes editor tabs without closing structured chats from Close All Editor Tabs', () => {
|
||||
it('closes editor tabs without closing structured chats from Close All Editor Tabs', async () => {
|
||||
actions().closeAllFiles()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(closeUnifiedTab).toHaveBeenCalledWith(editor.id)
|
||||
expect(closeUnifiedTab).not.toHaveBeenCalledWith(chat.id)
|
||||
@@ -99,7 +103,7 @@ describe('floating titlebar close actions', () => {
|
||||
|
||||
it.each([true, false])(
|
||||
'retains pinned editors without prompting during Close All Editor Tabs (confirmation %s)',
|
||||
(confirmClosePinnedTab) => {
|
||||
async (confirmClosePinnedTab) => {
|
||||
setPinnedConfirmation(confirmClosePinnedTab)
|
||||
useAppStore.setState({
|
||||
unifiedTabsByWorktree: { [worktreeId]: [editor, pinnedEditor, chat] },
|
||||
@@ -110,6 +114,7 @@ describe('floating titlebar close actions', () => {
|
||||
})
|
||||
|
||||
actions([editor, pinnedEditor, chat]).closeAllFiles()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(closeUnifiedTab).toHaveBeenCalledWith(editor.id)
|
||||
expect(closeUnifiedTab).not.toHaveBeenCalledWith(pinnedEditor.id)
|
||||
|
||||
@@ -20,8 +20,6 @@ export function useFloatingTerminalCloseActions({
|
||||
const scope = useTabGroupCloseScopeCommands({
|
||||
groupId: activeGroup?.id ?? '',
|
||||
worktreeId,
|
||||
group: activeGroup,
|
||||
groupTabs,
|
||||
closeItem,
|
||||
closeMany
|
||||
})
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import type { Tab, TabContentType, TabGroup } from '../../../../shared/tab-types'
|
||||
import { useTabGroupCloseScopeCommands } from './useTabGroupCloseScopeCommands'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
track: vi.fn(),
|
||||
closeEmptyGroup: vi.fn()
|
||||
}))
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state: {
|
||||
groupsByWorktree: Record<string, TabGroup[]>
|
||||
unifiedTabsByWorktree: Record<string, Tab[]>
|
||||
} = { groupsByWorktree: {}, unifiedTabsByWorktree: {} }
|
||||
return { track: vi.fn(), closeEmptyGroup: vi.fn(), state }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/telemetry', () => ({ track: mocks.track }))
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: (selector: (state: { closeEmptyGroup: () => void }) => unknown) => selector(mocks)
|
||||
useAppStore: Object.assign(
|
||||
(selector: (state: { closeEmptyGroup: () => void }) => unknown) => selector(mocks),
|
||||
{ getState: () => mocks.state }
|
||||
)
|
||||
}))
|
||||
vi.mock('../confirmation-dialog-context', () => ({
|
||||
useConfirmationDialog: () => async () => true
|
||||
}))
|
||||
vi.mock('./workspace-emptied-reaction', () => ({
|
||||
captureWorkspaceEmptiedReaction: vi.fn(() => vi.fn())
|
||||
@@ -58,12 +67,14 @@ function commands({
|
||||
tab('right'),
|
||||
tab('left')
|
||||
]
|
||||
mocks.state = {
|
||||
groupsByWorktree: { [worktreeId]: [group] },
|
||||
unifiedTabsByWorktree: { [worktreeId]: groupTabs }
|
||||
}
|
||||
const hook = renderHook(() =>
|
||||
useTabGroupCloseScopeCommands({
|
||||
groupId: group.id,
|
||||
worktreeId,
|
||||
group,
|
||||
groupTabs,
|
||||
closeItem: vi.fn(),
|
||||
closeMany
|
||||
})
|
||||
@@ -76,7 +87,7 @@ beforeEach(() => vi.clearAllMocks())
|
||||
describe('directional tab close telemetry', () => {
|
||||
it.each(['left', 'right'] as const)(
|
||||
'counts one %s request before asking to close tabs',
|
||||
(direction) => {
|
||||
async (direction) => {
|
||||
const expectedIds = direction === 'left' ? ['left'] : ['right', 'last']
|
||||
const closeMany = vi.fn((ids: string[]) => {
|
||||
expect(mocks.track).toHaveBeenCalledExactlyOnceWith('tabs_directional_close_requested', {
|
||||
@@ -90,11 +101,13 @@ describe('directional tab close telemetry', () => {
|
||||
})
|
||||
const scope = commands({ closeMany })
|
||||
|
||||
if (direction === 'left') {
|
||||
scope.closeToLeft('selected')
|
||||
} else {
|
||||
scope.closeToRight('selected')
|
||||
}
|
||||
await act(async () => {
|
||||
if (direction === 'left') {
|
||||
scope.closeToLeft('selected')
|
||||
} else {
|
||||
scope.closeToRight('selected')
|
||||
}
|
||||
})
|
||||
|
||||
expect(closeMany).toHaveBeenCalledExactlyOnceWith(expectedIds)
|
||||
}
|
||||
@@ -130,7 +143,7 @@ describe('directional tab close telemetry', () => {
|
||||
surface: 'workspace',
|
||||
eligible_tab_count: 0
|
||||
})
|
||||
expect(scope.closeMany).toHaveBeenCalledExactlyOnceWith([])
|
||||
expect(scope.closeMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not count stale tab ids or unrelated close commands', () => {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { makeTabGroup, makeUnifiedTab } from '../../store/slices/store-session-test-harness'
|
||||
import type { BulkTabCloseScope } from '../use-bulk-tab-close-confirmation'
|
||||
import { useTabGroupCloseScopeCommands } from './useTabGroupCloseScopeCommands'
|
||||
|
||||
const confirm = vi.hoisted(() => vi.fn<() => Promise<boolean>>())
|
||||
vi.mock('../confirmation-dialog-context', () => ({ useConfirmationDialog: () => confirm }))
|
||||
|
||||
const worktreeId = 'wt-1'
|
||||
const groupId = 'group-1'
|
||||
const left = makeUnifiedTab({ id: 'left', worktreeId, groupId, contentType: 'editor' })
|
||||
const anchor = makeUnifiedTab({ id: 'anchor', worktreeId, groupId, contentType: 'terminal' })
|
||||
const right = makeUnifiedTab({ id: 'right', worktreeId, groupId, contentType: 'browser' })
|
||||
const pinned = makeUnifiedTab({
|
||||
id: 'pinned',
|
||||
worktreeId,
|
||||
groupId,
|
||||
contentType: 'editor',
|
||||
isPinned: true
|
||||
})
|
||||
const group = makeTabGroup({
|
||||
id: groupId,
|
||||
worktreeId,
|
||||
activeTabId: anchor.id,
|
||||
tabOrder: [left.id, anchor.id, right.id, pinned.id]
|
||||
})
|
||||
const closeItem = vi.fn()
|
||||
const closeMany = vi.fn()
|
||||
let settle: (confirmed: boolean) => void
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
confirm.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
settle = resolve
|
||||
})
|
||||
)
|
||||
useAppStore.setState({
|
||||
groupsByWorktree: { [worktreeId]: [group] },
|
||||
unifiedTabsByWorktree: { [worktreeId]: [left, anchor, right, pinned] }
|
||||
})
|
||||
})
|
||||
afterEach(cleanup)
|
||||
|
||||
function request(scope: BulkTabCloseScope) {
|
||||
const { result } = renderHook(() =>
|
||||
useTabGroupCloseScopeCommands({
|
||||
worktreeId,
|
||||
groupId,
|
||||
closeItem,
|
||||
closeMany
|
||||
})
|
||||
)
|
||||
const actions = {
|
||||
editors: result.current.closeAllEditorTabsInGroup,
|
||||
others: () => result.current.closeOthers(anchor.id),
|
||||
right: () => result.current.closeToRight(anchor.id),
|
||||
left: () => result.current.closeToLeft(anchor.id)
|
||||
}
|
||||
act(actions[scope])
|
||||
}
|
||||
|
||||
async function confirmRequest(confirmed = true) {
|
||||
await act(async () => {
|
||||
settle(confirmed)
|
||||
})
|
||||
}
|
||||
|
||||
describe('group bulk close confirmation', () => {
|
||||
it.each(['editors', 'others', 'right', 'left'] as const)(
|
||||
'waits for confirmation and leaves tabs untouched on cancelling %s',
|
||||
async (scope) => {
|
||||
request(scope)
|
||||
expect(confirm).toHaveBeenCalledOnce()
|
||||
expect(closeItem).not.toHaveBeenCalled()
|
||||
expect(closeMany).not.toHaveBeenCalled()
|
||||
await confirmRequest(false)
|
||||
expect(closeItem).not.toHaveBeenCalled()
|
||||
expect(closeMany).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['others', ['left', 'right']],
|
||||
['right', ['right']],
|
||||
['left', ['left']]
|
||||
] as const)('closes the unpinned %s selection on confirmation', async (scope, ids) => {
|
||||
request(scope)
|
||||
await confirmRequest()
|
||||
expect(closeMany).toHaveBeenCalledExactlyOnceWith([...ids])
|
||||
expect(closeItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes editor closes through the existing dirty-aware item command', async () => {
|
||||
request('editors')
|
||||
await confirmRequest()
|
||||
expect(closeItem).toHaveBeenCalledExactlyOnceWith(left.id)
|
||||
expect(closeMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['others', 'right', 'left'] as const)(
|
||||
'abandons %s when the anchor is removed while the dialog is open',
|
||||
async (scope) => {
|
||||
request(scope)
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
unifiedTabsByWorktree: {
|
||||
[worktreeId]: [left, right, pinned]
|
||||
}
|
||||
})
|
||||
)
|
||||
await confirmRequest()
|
||||
expect(closeMany).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['editors', 'others', 'right', 'left'] as const)(
|
||||
'abandons %s when the original group disappears',
|
||||
async (scope) => {
|
||||
request(scope)
|
||||
act(() => useAppStore.setState({ groupsByWorktree: {} }))
|
||||
await confirmRequest()
|
||||
expect(closeMany).not.toHaveBeenCalled()
|
||||
expect(closeItem).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('skips editors pinned while confirmation is pending', async () => {
|
||||
request('editors')
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
unifiedTabsByWorktree: {
|
||||
[worktreeId]: [{ ...left, isPinned: true }, anchor, right, pinned]
|
||||
}
|
||||
})
|
||||
)
|
||||
await confirmRequest()
|
||||
expect(closeItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('excludes moved tabs and tabs created after the request', async () => {
|
||||
request('others')
|
||||
const added = makeUnifiedTab({ id: 'added', worktreeId, groupId })
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
unifiedTabsByWorktree: {
|
||||
[worktreeId]: [{ ...left, groupId: 'other-group' }, anchor, right, pinned, added]
|
||||
}
|
||||
})
|
||||
)
|
||||
await confirmRequest()
|
||||
expect(closeMany).toHaveBeenCalledExactlyOnceWith([right.id])
|
||||
})
|
||||
|
||||
it.each(['right', 'left'] as const)(
|
||||
'excludes tabs that cross the %s boundary while confirmation is pending',
|
||||
async (scope) => {
|
||||
request(scope)
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
groupsByWorktree: {
|
||||
[worktreeId]: [{ ...group, tabOrder: [right.id, anchor.id, left.id, pinned.id] }]
|
||||
}
|
||||
})
|
||||
)
|
||||
await confirmRequest()
|
||||
expect(closeMany).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the original workspace and handler when the hook changes scope', async () => {
|
||||
const otherCloseMany = vi.fn()
|
||||
const { result, rerender } = renderHook(
|
||||
(props) => useTabGroupCloseScopeCommands({ groupId, closeItem, ...props }),
|
||||
{ initialProps: { worktreeId, closeMany } }
|
||||
)
|
||||
act(() => result.current.closeOthers(anchor.id))
|
||||
rerender({ worktreeId: 'wt-2', closeMany: otherCloseMany })
|
||||
await confirmRequest()
|
||||
expect(closeMany).toHaveBeenCalledExactlyOnceWith([left.id, right.id])
|
||||
expect(otherCloseMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not prompt when there are no eligible targets', () => {
|
||||
useAppStore.setState({ unifiedTabsByWorktree: { [worktreeId]: [anchor, pinned] } })
|
||||
request('others')
|
||||
expect(confirm).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,28 +1,62 @@
|
||||
import { useCallback } from 'react'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { toVisibleTabType } from '../../../../shared/tab-types'
|
||||
import type { Tab, TabGroup } from '../../../../shared/tab-types'
|
||||
import { track } from '@/lib/telemetry'
|
||||
import { useAppStore } from '../../store'
|
||||
import { captureWorkspaceEmptiedReaction } from './workspace-emptied-reaction'
|
||||
import { track } from '@/lib/telemetry'
|
||||
import { isEditorTabContentType } from '@/store/slices/editor/tabs/editor-tab-content-type'
|
||||
import {
|
||||
useBulkTabCloseConfirmation,
|
||||
type BulkTabCloseScope
|
||||
} from '../use-bulk-tab-close-confirmation'
|
||||
|
||||
export function useTabGroupCloseScopeCommands({
|
||||
groupId,
|
||||
worktreeId,
|
||||
group,
|
||||
groupTabs,
|
||||
closeItem,
|
||||
closeMany
|
||||
}: {
|
||||
groupId: string
|
||||
worktreeId: string
|
||||
group: TabGroup | null
|
||||
groupTabs: Tab[]
|
||||
closeItem: (itemId: string, opts?: { skipEmptyCheck?: boolean }) => void
|
||||
closeMany: (itemIds: string[]) => void
|
||||
}) {
|
||||
const closeEmptyGroup = useAppStore((state) => state.closeEmptyGroup)
|
||||
const confirmBulkClose = useBulkTabCloseConfirmation()
|
||||
const selectTabIds = useCallback(
|
||||
(scope: BulkTabCloseScope, itemId?: string): string[] => {
|
||||
const state = useAppStore.getState()
|
||||
const group = (state.groupsByWorktree[worktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === groupId
|
||||
)
|
||||
if (!group) {
|
||||
return []
|
||||
}
|
||||
const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).filter(
|
||||
(tab) => tab.groupId === groupId
|
||||
)
|
||||
if (scope === 'editors') {
|
||||
return tabs
|
||||
.filter((tab) => isEditorTabContentType(tab.contentType) && !tab.isPinned)
|
||||
.map((tab) => tab.id)
|
||||
}
|
||||
if (itemId === undefined || !tabs.some((tab) => tab.id === itemId)) {
|
||||
return []
|
||||
}
|
||||
if (scope === 'others') {
|
||||
return tabs.filter((tab) => tab.id !== itemId && !tab.isPinned).map((tab) => tab.id)
|
||||
}
|
||||
const index = group.tabOrder.indexOf(itemId)
|
||||
if (index === -1) {
|
||||
return []
|
||||
}
|
||||
const order =
|
||||
scope === 'right' ? group.tabOrder.slice(index + 1) : group.tabOrder.slice(0, index)
|
||||
const closableIds = new Set(tabs.filter((tab) => !tab.isPinned).map((tab) => tab.id))
|
||||
return order.filter((id) => closableIds.has(id))
|
||||
},
|
||||
[groupId, worktreeId]
|
||||
)
|
||||
|
||||
const closeGroup = useCallback(() => {
|
||||
const whenEmptied = captureWorkspaceEmptiedReaction(worktreeId)
|
||||
@@ -38,56 +72,48 @@ export function useTabGroupCloseScopeCommands({
|
||||
}, [closeEmptyGroup, closeItem, groupId, worktreeId])
|
||||
|
||||
const closeAllEditorTabsInGroup = useCallback(() => {
|
||||
for (const item of groupTabs) {
|
||||
if (isEditorTabContentType(item.contentType)) {
|
||||
if (!item.isPinned) {
|
||||
closeItem(item.id)
|
||||
confirmBulkClose(
|
||||
'editors',
|
||||
() => selectTabIds('editors'),
|
||||
(ids) => {
|
||||
for (const id of ids) {
|
||||
closeItem(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [closeItem, groupTabs])
|
||||
)
|
||||
}, [closeItem, confirmBulkClose, selectTabIds])
|
||||
|
||||
const closeOthers = useCallback(
|
||||
(itemId: string) => {
|
||||
const item = groupTabs.find((candidate) => candidate.id === itemId)
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
// Why: store closeOtherTabs strands dirty files if the save dialog is cancelled; route via closeMany to stay dirty-aware.
|
||||
const siblingIds = groupTabs
|
||||
.filter((candidate) => candidate.id !== itemId && !candidate.isPinned)
|
||||
.map((candidate) => candidate.id)
|
||||
closeMany(siblingIds)
|
||||
confirmBulkClose('others', () => selectTabIds('others', itemId), closeMany)
|
||||
},
|
||||
[closeMany, groupTabs]
|
||||
[closeMany, confirmBulkClose, selectTabIds]
|
||||
)
|
||||
|
||||
const closeInDirection = useCallback(
|
||||
(itemId: string, direction: 'left' | 'right') => {
|
||||
// Preserve visible order and save prompts by routing through closeMany.
|
||||
const order = group?.tabOrder ?? []
|
||||
const index = order.indexOf(itemId)
|
||||
if (index === -1) {
|
||||
const state = useAppStore.getState()
|
||||
const group = (state.groupsByWorktree[worktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === groupId
|
||||
)
|
||||
if (!group?.tabOrder.includes(itemId)) {
|
||||
return
|
||||
}
|
||||
const tabById = new Map(groupTabs.map((candidate) => [candidate.id, candidate]))
|
||||
const sideIds = direction === 'left' ? order.slice(0, index) : order.slice(index + 1)
|
||||
const eligibleIds = sideIds.filter((id) => {
|
||||
const candidate = tabById.get(id)
|
||||
return candidate ? !candidate.isPinned : false
|
||||
})
|
||||
const item = tabById.get(itemId)
|
||||
const item = (state.unifiedTabsByWorktree[worktreeId] ?? []).find(
|
||||
(candidate) => candidate.groupId === groupId && candidate.id === itemId
|
||||
)
|
||||
if (item) {
|
||||
track('tabs_directional_close_requested', {
|
||||
direction,
|
||||
tab_type: toVisibleTabType(item.contentType),
|
||||
surface: worktreeId === FLOATING_TERMINAL_WORKTREE_ID ? 'floating' : 'workspace',
|
||||
eligible_tab_count: eligibleIds.length
|
||||
eligible_tab_count: selectTabIds(direction, itemId).length
|
||||
})
|
||||
}
|
||||
closeMany(eligibleIds)
|
||||
confirmBulkClose(direction, () => selectTabIds(direction, itemId), closeMany)
|
||||
},
|
||||
[closeMany, group, groupTabs, worktreeId]
|
||||
[closeMany, confirmBulkClose, groupId, selectTabIds, worktreeId]
|
||||
)
|
||||
|
||||
const closeToRight = useCallback(
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
|
||||
|
||||
vi.mock('../confirmation-dialog-context', () => ({
|
||||
useConfirmationDialog: () => async () => true
|
||||
}))
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
activateTab: vi.fn(),
|
||||
activateWebRuntimeSessionTab: vi.fn(),
|
||||
@@ -361,6 +365,7 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => {
|
||||
const model = useTabGroupWorkspaceModel({ groupId: 'group-1', worktreeId: 'wt-1' })
|
||||
|
||||
model.commands.closeOthers(firstUnified.id)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mocks.closeTab).toHaveBeenCalledWith(
|
||||
'terminal-2',
|
||||
|
||||
@@ -62,8 +62,6 @@ export function useTabGroupWorkspaceModel({
|
||||
useTabGroupCloseScopeCommands({
|
||||
groupId,
|
||||
worktreeId,
|
||||
group,
|
||||
groupTabs,
|
||||
closeItem,
|
||||
closeMany
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { handleSwitchTabAcrossAllTypes } from '../hooks/ipc-tab-switch'
|
||||
import { switchFloatingWorkspaceTab } from '@/lib/floating-workspace-terminal-actions'
|
||||
import { dispatchWorkspaceTabCommand } from '@/lib/workspace-tab-commands'
|
||||
@@ -12,6 +13,13 @@ import {
|
||||
} from './editor/editor-autosave'
|
||||
import { handleTerminalWorkspaceKeyDown } from './terminal-workspace-keydown'
|
||||
import type { TerminalActivationController } from './use-terminal-activation-actions'
|
||||
import { useTerminalBulkCloseActions } from './use-terminal-bulk-close-actions'
|
||||
import { makeOpenFile, makeUnifiedTab } from '../store/slices/store-session-test-harness'
|
||||
|
||||
const confirmBulkClose = vi.hoisted(() => vi.fn<() => Promise<boolean>>())
|
||||
vi.mock('./confirmation-dialog-context', () => ({
|
||||
useConfirmationDialog: () => confirmBulkClose
|
||||
}))
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
state: {} as Record<string, unknown>,
|
||||
@@ -327,3 +335,112 @@ describe('shared tab navigation routing', () => {
|
||||
expect(handleSwitchTabAcrossAllTypes).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab.closeAll confirmation', () => {
|
||||
const worktreeId = controller.activeWorktreeId!
|
||||
const closeFile = vi.fn()
|
||||
const queueEditorCloseRequests = vi.fn()
|
||||
let settle: (confirmed: boolean) => void
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.floatingFocused = false
|
||||
mocks.targetInsideFloatingPanel = false
|
||||
mocks.state = {
|
||||
activeView: 'terminal',
|
||||
activeTabType: 'editor',
|
||||
openFiles: [makeOpenFile({ id: 'file-1', worktreeId })],
|
||||
unifiedTabsByWorktree: { [worktreeId]: [] },
|
||||
getActiveTab: () => null
|
||||
}
|
||||
confirmBulkClose.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
settle = resolve
|
||||
})
|
||||
)
|
||||
})
|
||||
afterEach(cleanup)
|
||||
|
||||
function closeAll(platform: NodeJS.Platform = 'darwin') {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalBulkCloseActions({
|
||||
activeWorktreeId: worktreeId,
|
||||
closeFile,
|
||||
queueEditorCloseRequests,
|
||||
closeTab: vi.fn(),
|
||||
closeBrowserTab: vi.fn()
|
||||
})
|
||||
)
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'w',
|
||||
code: 'KeyW',
|
||||
altKey: true,
|
||||
cancelable: true,
|
||||
metaKey: platform === 'darwin',
|
||||
ctrlKey: platform !== 'darwin'
|
||||
})
|
||||
act(() => handleTerminalWorkspaceKeyDown(event, { ...controller, ...result.current }, platform))
|
||||
return result
|
||||
}
|
||||
|
||||
it.each(['darwin', 'linux', 'win32'] as const)(
|
||||
'waits for confirmation before closing files from the %s shortcut',
|
||||
async (platform) => {
|
||||
closeAll(platform)
|
||||
expect(confirmBulkClose).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({
|
||||
title: 'Close all editor tabs?'
|
||||
})
|
||||
)
|
||||
expect(closeFile).not.toHaveBeenCalled()
|
||||
await act(async () => {
|
||||
settle(true)
|
||||
})
|
||||
expect(closeFile).toHaveBeenCalledExactlyOnceWith('file-1')
|
||||
}
|
||||
)
|
||||
|
||||
it('does not close files when the shortcut confirmation is cancelled', async () => {
|
||||
closeAll()
|
||||
await act(async () => {
|
||||
settle(false)
|
||||
})
|
||||
expect(closeFile).not.toHaveBeenCalled()
|
||||
expect(queueEditorCloseRequests).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queues newly dirty files without closing files created after the request', async () => {
|
||||
closeAll()
|
||||
mocks.state.openFiles = [
|
||||
makeOpenFile({ id: 'file-1', worktreeId, isDirty: true }),
|
||||
makeOpenFile({ id: 'new-file', worktreeId })
|
||||
]
|
||||
await act(async () => {
|
||||
settle(true)
|
||||
})
|
||||
expect(closeFile).not.toHaveBeenCalled()
|
||||
expect(queueEditorCloseRequests).toHaveBeenCalledExactlyOnceWith(['file-1'])
|
||||
})
|
||||
|
||||
it('skips files pinned while shortcut confirmation is pending', async () => {
|
||||
closeAll()
|
||||
mocks.state.unifiedTabsByWorktree = {
|
||||
[worktreeId]: [
|
||||
makeUnifiedTab({
|
||||
id: 'pinned-view',
|
||||
entityId: 'file-1',
|
||||
worktreeId,
|
||||
groupId: 'group',
|
||||
contentType: 'editor',
|
||||
isPinned: true
|
||||
})
|
||||
]
|
||||
}
|
||||
await act(async () => {
|
||||
settle(true)
|
||||
})
|
||||
expect(queueEditorCloseRequests).not.toHaveBeenCalled()
|
||||
expect(closeFile).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useConfirmationDialog } from './confirmation-dialog-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type BulkTabCloseScope = 'others' | 'right' | 'left' | 'editors'
|
||||
|
||||
export function useBulkTabCloseConfirmation() {
|
||||
const confirm = useConfirmationDialog()
|
||||
return useCallback(
|
||||
(
|
||||
scope: BulkTabCloseScope,
|
||||
selectTabIds: () => string[],
|
||||
closeTabs: (tabIds: string[]) => void
|
||||
): void => {
|
||||
const selectedIds = new Set(selectTabIds())
|
||||
if (selectedIds.size === 0) {
|
||||
return
|
||||
}
|
||||
const titles = {
|
||||
others: translate('components.tab.bar.bulkClose.others', 'Close other tabs?'),
|
||||
right: translate('components.tab.bar.bulkClose.right', 'Close tabs to the right?'),
|
||||
left: translate('components.tab.bar.bulkClose.left', 'Close tabs to the left?'),
|
||||
editors: translate('components.tab.bar.bulkClose.editors', 'Close all editor tabs?')
|
||||
}
|
||||
void confirm({
|
||||
title: titles[scope],
|
||||
description: translate(
|
||||
'components.tab.bar.bulkClose.description',
|
||||
'The selected tabs will be closed. Pinned tabs will stay open.'
|
||||
),
|
||||
confirmLabel: translate('components.tab.bar.bulkClose.confirm', 'Close tabs'),
|
||||
cancelVariant: 'ghost',
|
||||
confirmVariant: 'destructive'
|
||||
}).then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
// Remote updates can pin, move, remove or add tabs while the dialog is open.
|
||||
const remainingIds = selectTabIds().filter((id) => selectedIds.has(id))
|
||||
if (remainingIds.length > 0) {
|
||||
closeTabs(remainingIds)
|
||||
}
|
||||
})
|
||||
},
|
||||
[confirm]
|
||||
)
|
||||
}
|
||||
@@ -9,10 +9,17 @@ import {
|
||||
isPinnedEditorFileTab
|
||||
} from './terminal-workspace-model'
|
||||
import type { TerminalCloseController } from './use-terminal-close-actions'
|
||||
import { useBulkTabCloseConfirmation } from './use-bulk-tab-close-confirmation'
|
||||
|
||||
export function useTerminalBulkCloseActions(controller: TerminalCloseController) {
|
||||
export function useTerminalBulkCloseActions(
|
||||
controller: Pick<
|
||||
TerminalCloseController,
|
||||
'activeWorktreeId' | 'closeBrowserTab' | 'closeFile' | 'closeTab' | 'queueEditorCloseRequests'
|
||||
>
|
||||
) {
|
||||
const { activeWorktreeId, closeBrowserTab, closeFile, closeTab, queueEditorCloseRequests } =
|
||||
controller
|
||||
const confirmBulkClose = useBulkTabCloseConfirmation()
|
||||
const closeTabBarTabs = useCallback(
|
||||
(tabIds: string[]) => {
|
||||
if (!activeWorktreeId) {
|
||||
@@ -85,63 +92,78 @@ export function useTerminalBulkCloseActions(controller: TerminalCloseController)
|
||||
[activeWorktreeId, closeBrowserTab, closeFile, closeTab, queueEditorCloseRequests]
|
||||
)
|
||||
|
||||
const selectScopeTabIds = useCallback(
|
||||
(scope: 'others' | 'right' | 'left', tabId: string): string[] => {
|
||||
if (!activeWorktreeId) {
|
||||
return []
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const order = state.tabBarOrderByWorktree[activeWorktreeId] ?? []
|
||||
const index = order.indexOf(tabId)
|
||||
if (index === -1) {
|
||||
return []
|
||||
}
|
||||
let ids = order.filter((id) => id !== tabId)
|
||||
if (scope === 'right') {
|
||||
ids = order.slice(index + 1)
|
||||
} else if (scope === 'left') {
|
||||
ids = order.slice(0, index)
|
||||
}
|
||||
const tabs = state.unifiedTabsByWorktree[activeWorktreeId] ?? []
|
||||
const pinnedIds = new Set(tabs.flatMap((tab) => (tab.isPinned ? [tab.id, tab.entityId] : [])))
|
||||
return ids.filter((id) => !pinnedIds.has(id))
|
||||
},
|
||||
[activeWorktreeId]
|
||||
)
|
||||
const handleCloseOthers = useCallback(
|
||||
(tabId: string) => {
|
||||
if (!activeWorktreeId) {
|
||||
return
|
||||
}
|
||||
const order = useAppStore.getState().tabBarOrderByWorktree[activeWorktreeId] ?? []
|
||||
closeTabBarTabs(order.filter((id) => id !== tabId))
|
||||
confirmBulkClose('others', () => selectScopeTabIds('others', tabId), closeTabBarTabs)
|
||||
},
|
||||
[activeWorktreeId, closeTabBarTabs]
|
||||
[closeTabBarTabs, confirmBulkClose, selectScopeTabIds]
|
||||
)
|
||||
const handleCloseTabsToRight = useCallback(
|
||||
(tabId: string) => {
|
||||
if (!activeWorktreeId) {
|
||||
return
|
||||
}
|
||||
const currentOrder = useAppStore.getState().tabBarOrderByWorktree[activeWorktreeId] ?? []
|
||||
const index = currentOrder.indexOf(tabId)
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
closeTabBarTabs(currentOrder.slice(index + 1))
|
||||
confirmBulkClose('right', () => selectScopeTabIds('right', tabId), closeTabBarTabs)
|
||||
},
|
||||
[activeWorktreeId, closeTabBarTabs]
|
||||
[closeTabBarTabs, confirmBulkClose, selectScopeTabIds]
|
||||
)
|
||||
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))
|
||||
confirmBulkClose('left', () => selectScopeTabIds('left', tabId), closeTabBarTabs)
|
||||
},
|
||||
[activeWorktreeId, closeTabBarTabs]
|
||||
[closeTabBarTabs, confirmBulkClose, selectScopeTabIds]
|
||||
)
|
||||
const handleCloseAllFiles = useCallback(() => {
|
||||
if (!activeWorktreeId) {
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const filesInWorktree = state.openFiles.filter((file) => file.worktreeId === activeWorktreeId)
|
||||
const closableFiles = filesInWorktree.filter(
|
||||
(file) => !isPinnedEditorFileTab(state, activeWorktreeId, file.id)
|
||||
)
|
||||
const dirtyFileIds = closableFiles.filter((file) => file.isDirty).map((file) => file.id)
|
||||
for (const file of closableFiles) {
|
||||
if (!file.isDirty) {
|
||||
closeFile(file.id)
|
||||
confirmBulkClose(
|
||||
'editors',
|
||||
() => {
|
||||
const state = useAppStore.getState()
|
||||
return state.openFiles
|
||||
.filter(
|
||||
(file) =>
|
||||
file.worktreeId === activeWorktreeId &&
|
||||
!isPinnedEditorFileTab(state, activeWorktreeId, file.id)
|
||||
)
|
||||
.map((file) => file.id)
|
||||
},
|
||||
(fileIds) => {
|
||||
const selectedIds = new Set(fileIds)
|
||||
const files = useAppStore.getState().openFiles.filter((file) => selectedIds.has(file.id))
|
||||
const dirtyFileIds = files.filter((file) => file.isDirty).map((file) => file.id)
|
||||
for (const file of files) {
|
||||
if (!file.isDirty) {
|
||||
closeFile(file.id)
|
||||
}
|
||||
}
|
||||
if (dirtyFileIds.length > 0) {
|
||||
queueEditorCloseRequests(dirtyFileIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dirtyFileIds.length > 0) {
|
||||
queueEditorCloseRequests(dirtyFileIds)
|
||||
}
|
||||
}, [activeWorktreeId, closeFile, queueEditorCloseRequests])
|
||||
)
|
||||
}, [activeWorktreeId, closeFile, confirmBulkClose, queueEditorCloseRequests])
|
||||
|
||||
return {
|
||||
closeTabBarTabs,
|
||||
|
||||
@@ -19056,6 +19056,14 @@
|
||||
"copied": "Tab ID copied",
|
||||
"failed": "Could not copy tab ID",
|
||||
"copy": "Copy Tab ID"
|
||||
},
|
||||
"bulkClose": {
|
||||
"others": "Close other tabs?",
|
||||
"right": "Close tabs to the right?",
|
||||
"left": "Close tabs to the left?",
|
||||
"editors": "Close all editor tabs?",
|
||||
"description": "The selected tabs will be closed. Pinned tabs will stay open.",
|
||||
"confirm": "Close tabs"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18783,6 +18783,14 @@
|
||||
"copied": "ID de pestaña copiado",
|
||||
"failed": "No se pudo copiar el ID de pestaña",
|
||||
"copy": "Copiar ID de pestaña"
|
||||
},
|
||||
"bulkClose": {
|
||||
"others": "¿Cerrar las otras pestañas?",
|
||||
"right": "¿Cerrar las pestañas de la derecha?",
|
||||
"left": "¿Cerrar las pestañas de la izquierda?",
|
||||
"editors": "¿Cerrar todas las pestañas del editor?",
|
||||
"description": "Se cerrarán las pestañas seleccionadas. Las pestañas fijadas permanecerán abiertas.",
|
||||
"confirm": "Cerrar pestañas"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18892,6 +18892,14 @@
|
||||
"copied": "ID de l'onglet copié",
|
||||
"failed": "Impossible de copier l'ID de l'onglet",
|
||||
"copy": "Copier l'ID de l'onglet"
|
||||
},
|
||||
"bulkClose": {
|
||||
"others": "Fermer les autres onglets ?",
|
||||
"right": "Fermer les onglets à droite ?",
|
||||
"left": "Fermer les onglets à gauche ?",
|
||||
"editors": "Fermer tous les onglets de l’éditeur ?",
|
||||
"description": "Les onglets sélectionnés seront fermés. Les onglets épinglés resteront ouverts.",
|
||||
"confirm": "Fermer les onglets"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18823,6 +18823,14 @@
|
||||
"copied": "タブ ID をコピーしました",
|
||||
"failed": "タブ ID をコピーできませんでした",
|
||||
"copy": "タブ ID をコピー"
|
||||
},
|
||||
"bulkClose": {
|
||||
"others": "他のタブを閉じますか?",
|
||||
"right": "右側のタブを閉じますか?",
|
||||
"left": "左側のタブを閉じますか?",
|
||||
"editors": "すべてのエディタータブを閉じますか?",
|
||||
"description": "選択したタブを閉じます。ピン留めしたタブは開いたままになります。",
|
||||
"confirm": "タブを閉じる"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18823,6 +18823,14 @@
|
||||
"copied": "탭 ID 복사됨",
|
||||
"failed": "탭 ID를 복사할 수 없습니다",
|
||||
"copy": "탭 ID 복사"
|
||||
},
|
||||
"bulkClose": {
|
||||
"others": "다른 탭을 닫을까요?",
|
||||
"right": "오른쪽 탭을 닫을까요?",
|
||||
"left": "왼쪽 탭을 닫을까요?",
|
||||
"editors": "모든 편집기 탭을 닫을까요?",
|
||||
"description": "선택한 탭을 닫습니다. 고정된 탭은 열린 상태로 유지됩니다.",
|
||||
"confirm": "탭 닫기"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18788,6 +18788,14 @@
|
||||
"copied": "已复制标签页 ID",
|
||||
"failed": "无法复制标签页 ID",
|
||||
"copy": "复制标签页 ID"
|
||||
},
|
||||
"bulkClose": {
|
||||
"others": "关闭其他标签页?",
|
||||
"right": "关闭右侧标签页?",
|
||||
"left": "关闭左侧标签页?",
|
||||
"editors": "关闭所有编辑器标签页?",
|
||||
"description": "选中的标签页将关闭。固定的标签页将保持打开。",
|
||||
"confirm": "关闭标签页"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -125,8 +125,8 @@ const ownFlowId = (owner: RuntimeClientTarget): string | null => flowOf(owner)?.
|
||||
export async function cancelAntigravityChatSignIn(owner: RuntimeClientTarget): Promise<void> {
|
||||
const flowId = ownFlowId(owner)
|
||||
if (flowId) {
|
||||
await callAntigravityChatSignIn(owner, 'Cancel', { flowId }).catch(
|
||||
(cause: unknown) => note(localFor(owner), { error: message(cause) })
|
||||
await callAntigravityChatSignIn(owner, 'Cancel', { flowId }).catch((cause: unknown) =>
|
||||
note(localFor(owner), { error: message(cause) })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user