diff --git a/config/scripts/check-focus-read-ratchet.mjs b/config/scripts/check-focus-read-ratchet.mjs index e65494f0827..83179dc4ccc 100644 --- a/config/scripts/check-focus-read-ratchet.mjs +++ b/config/scripts/check-focus-read-ratchet.mjs @@ -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 } } diff --git a/config/scripts/check-focus-read-ratchet.test.mjs b/config/scripts/check-focus-read-ratchet.test.mjs index 586e85cc654..13d0943b72f 100644 --- a/config/scripts/check-focus-read-ratchet.test.mjs +++ b/config/scripts/check-focus-read-ratchet.test.mjs @@ -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) }) diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx b/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx index 28988ed23f9..dc535979f8a 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx @@ -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) diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.ts index f3e3abc9149..9084f11e1b9 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.ts @@ -20,8 +20,6 @@ export function useFloatingTerminalCloseActions({ const scope = useTabGroupCloseScopeCommands({ groupId: activeGroup?.id ?? '', worktreeId, - group: activeGroup, - groupTabs, closeItem, closeMany }) diff --git a/src/renderer/src/components/tab-group/useTabGroupCloseScopeCommands.test.ts b/src/renderer/src/components/tab-group/useTabGroupCloseScopeCommands.test.ts index a42873ff709..a25e25d2732 100644 --- a/src/renderer/src/components/tab-group/useTabGroupCloseScopeCommands.test.ts +++ b/src/renderer/src/components/tab-group/useTabGroupCloseScopeCommands.test.ts @@ -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 + unifiedTabsByWorktree: Record + } = { 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', () => { diff --git a/src/renderer/src/components/tab-group/useTabGroupCloseScopeCommands.test.tsx b/src/renderer/src/components/tab-group/useTabGroupCloseScopeCommands.test.tsx new file mode 100644 index 00000000000..071cb884f08 --- /dev/null +++ b/src/renderer/src/components/tab-group/useTabGroupCloseScopeCommands.test.tsx @@ -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>()) +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() + }) +}) diff --git a/src/renderer/src/components/tab-group/useTabGroupCloseScopeCommands.ts b/src/renderer/src/components/tab-group/useTabGroupCloseScopeCommands.ts index a6d09a0e206..000b75213c6 100644 --- a/src/renderer/src/components/tab-group/useTabGroupCloseScopeCommands.ts +++ b/src/renderer/src/components/tab-group/useTabGroupCloseScopeCommands.ts @@ -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( diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts index 3a507283f91..6849c422fa9 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts @@ -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', diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index 3264c5af813..662721214e4 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -62,8 +62,6 @@ export function useTabGroupWorkspaceModel({ useTabGroupCloseScopeCommands({ groupId, worktreeId, - group, - groupTabs, closeItem, closeMany }) diff --git a/src/renderer/src/components/terminal-workspace-keydown.test.ts b/src/renderer/src/components/terminal-workspace-keydown.test.ts index 09167292355..fdebeabb2b7 100644 --- a/src/renderer/src/components/terminal-workspace-keydown.test.ts +++ b/src/renderer/src/components/terminal-workspace-keydown.test.ts @@ -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>()) +vi.mock('./confirmation-dialog-context', () => ({ + useConfirmationDialog: () => confirmBulkClose +})) const mocks = vi.hoisted(() => ({ state: {} as Record, @@ -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() + }) +}) diff --git a/src/renderer/src/components/use-bulk-tab-close-confirmation.ts b/src/renderer/src/components/use-bulk-tab-close-confirmation.ts new file mode 100644 index 00000000000..bae7f8b71e3 --- /dev/null +++ b/src/renderer/src/components/use-bulk-tab-close-confirmation.ts @@ -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] + ) +} diff --git a/src/renderer/src/components/use-terminal-bulk-close-actions.ts b/src/renderer/src/components/use-terminal-bulk-close-actions.ts index 9554e9793b6..922c678ca18 100644 --- a/src/renderer/src/components/use-terminal-bulk-close-actions.ts +++ b/src/renderer/src/components/use-terminal-bulk-close-actions.ts @@ -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, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index bb460b5ba0d..1198ca56306 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 089da9f8916..bec08a137b9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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" } } }, diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 5e7994b4737..b5cc69e89bf 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3c0675ee201..44be86df3f2 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18823,6 +18823,14 @@ "copied": "タブ ID をコピーしました", "failed": "タブ ID をコピーできませんでした", "copy": "タブ ID をコピー" + }, + "bulkClose": { + "others": "他のタブを閉じますか?", + "right": "右側のタブを閉じますか?", + "left": "左側のタブを閉じますか?", + "editors": "すべてのエディタータブを閉じますか?", + "description": "選択したタブを閉じます。ピン留めしたタブは開いたままになります。", + "confirm": "タブを閉じる" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a710e5c2751..ce7e3fd94f0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18823,6 +18823,14 @@ "copied": "탭 ID 복사됨", "failed": "탭 ID를 복사할 수 없습니다", "copy": "탭 ID 복사" + }, + "bulkClose": { + "others": "다른 탭을 닫을까요?", + "right": "오른쪽 탭을 닫을까요?", + "left": "왼쪽 탭을 닫을까요?", + "editors": "모든 편집기 탭을 닫을까요?", + "description": "선택한 탭을 닫습니다. 고정된 탭은 열린 상태로 유지됩니다.", + "confirm": "탭 닫기" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6f9b51f6e05..f120f72449a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18788,6 +18788,14 @@ "copied": "已复制标签页 ID", "failed": "无法复制标签页 ID", "copy": "复制标签页 ID" + }, + "bulkClose": { + "others": "关闭其他标签页?", + "right": "关闭右侧标签页?", + "left": "关闭左侧标签页?", + "editors": "关闭所有编辑器标签页?", + "description": "选中的标签页将关闭。固定的标签页将保持打开。", + "confirm": "关闭标签页" } } }, diff --git a/src/renderer/src/runtime/antigravity-chat-sign-in-status.ts b/src/renderer/src/runtime/antigravity-chat-sign-in-status.ts index 8be8b2efe0b..b2df5ff2be3 100644 --- a/src/renderer/src/runtime/antigravity-chat-sign-in-status.ts +++ b/src/renderer/src/runtime/antigravity-chat-sign-in-status.ts @@ -125,8 +125,8 @@ const ownFlowId = (owner: RuntimeClientTarget): string | null => flowOf(owner)?. export async function cancelAntigravityChatSignIn(owner: RuntimeClientTarget): Promise { 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) }) ) } }