mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(tabs): confirm before closing pinned tabs (#5536)
Add a configurable confirmation before pinned tabs are closed, route terminal/browser/CLI close paths through the guard, and add a one-shot Don't ask again option backed by the existing setting. Verified with focused unit tests, typecheck, lint, Electron validation, and the PR verify workflow.
This commit is contained in:
@@ -125,6 +125,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
geminiCliOAuthEnabled: false,
|
||||
agentCmdOverrides: {},
|
||||
keepComputerAwakeWhileAgentsRun: false,
|
||||
confirmClosePinnedTab: true,
|
||||
terminalMacOptionAsAlt: 'false',
|
||||
terminalMacOptionAsAltMigrated: true,
|
||||
terminalJISYenToBackslash: false,
|
||||
|
||||
@@ -129,6 +129,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
geminiCliOAuthEnabled: false,
|
||||
agentCmdOverrides: {},
|
||||
keepComputerAwakeWhileAgentsRun: false,
|
||||
confirmClosePinnedTab: true,
|
||||
terminalMacOptionAsAlt: 'false',
|
||||
terminalMacOptionAsAltMigrated: true,
|
||||
terminalJISYenToBackslash: false,
|
||||
|
||||
@@ -147,6 +147,7 @@ import { isGitRepoKind } from '../../shared/repo-kind'
|
||||
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
|
||||
import { resolveMountedLazyModalIds, type LazyModalId } from './lazy-modal-mount-state'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const isWindows = !isMac && navigator.userAgent.includes('Windows')
|
||||
@@ -2541,6 +2542,7 @@ function App(): React.JSX.Element {
|
||||
</ConfirmationDialogProvider>
|
||||
</TooltipProvider>
|
||||
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
|
||||
<PinnedTabCloseDialog />
|
||||
{/* Why: rendered last so it sits after all -webkit-app-region:drag elements
|
||||
in DOM order. Electron's hit-test for drag regions is DOM-order-based and
|
||||
ignores z-index — placing WindowControls earlier caused the drag region to
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createHttpProxyUrlDraftState,
|
||||
getDesktopPlatformFromUserAgent,
|
||||
getGeneralPaneSearchEntries,
|
||||
getTabOrderControlSearchKeywords,
|
||||
setHttpProxyUrlDraftErrorState,
|
||||
shouldCommitOpenInApplicationsDraft,
|
||||
updateAutoSaveDelayDraftState,
|
||||
@@ -133,6 +134,18 @@ describe('GeneralPane desktop platform detection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('GeneralPane navigation search keywords', () => {
|
||||
it('keeps pinned-tab keywords out of the Tab Order control', () => {
|
||||
const keywords = getTabOrderControlSearchKeywords()
|
||||
|
||||
expect(keywords).toContain('Tab Order')
|
||||
expect(keywords).toContain('recent')
|
||||
expect(keywords).not.toContain('pinned')
|
||||
expect(keywords).not.toContain('confirm')
|
||||
expect(keywords).not.toContain('close')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GeneralPane search entries', () => {
|
||||
it('includes the default project runtime setting', () => {
|
||||
const entries = getGeneralPaneSearchEntries()
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
import { getGeneralProjectRuntimeSearchEntries } from './general-project-runtime-search'
|
||||
import { RecentTabOrderControl } from './RecentTabOrderControl'
|
||||
import { matchesSettingsSearch } from './settings-search'
|
||||
import { SettingsSubsectionHeader } from './SettingsFormControls'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { DefaultWindowsProjectRuntimeSetting } from './DefaultWindowsProjectRuntimeSetting'
|
||||
|
||||
@@ -43,6 +44,8 @@ export {
|
||||
} from './GeneralNetworkSettingsSection'
|
||||
export { shouldCommitOpenInApplicationsDraft } from './OpenInMenuSetting'
|
||||
|
||||
type GeneralSearchEntry = ReturnType<typeof getGeneralNavigationSearchEntries>[number]
|
||||
|
||||
export function getDesktopPlatformFromUserAgent(userAgent: string): 'darwin' | 'win32' | 'other' {
|
||||
if (userAgent.includes('Mac')) {
|
||||
return 'darwin'
|
||||
@@ -55,6 +58,19 @@ export function getDesktopPlatformFromUserAgent(userAgent: string): 'darwin' | '
|
||||
|
||||
export { getGeneralPaneSearchEntries }
|
||||
|
||||
export function getTabOrderControlSearchKeywords(
|
||||
navigationEntries: GeneralSearchEntry[] = getGeneralNavigationSearchEntries()
|
||||
): string[] {
|
||||
const tabOrderSearchEntry = navigationEntries[0]
|
||||
return tabOrderSearchEntry
|
||||
? [
|
||||
tabOrderSearchEntry.title,
|
||||
tabOrderSearchEntry.description ?? '',
|
||||
...(tabOrderSearchEntry.keywords ?? [])
|
||||
]
|
||||
: []
|
||||
}
|
||||
|
||||
const EMPTY_WSL_DISTROS: string[] = []
|
||||
|
||||
type GeneralPaneProps = {
|
||||
@@ -75,25 +91,49 @@ export function GeneralPane({
|
||||
wslCapabilitiesLoading
|
||||
}: GeneralPaneProps): React.JSX.Element {
|
||||
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
|
||||
const generalNavigationSearchEntries = getGeneralNavigationSearchEntries()
|
||||
const tabOrderKeywords = getTabOrderControlSearchKeywords(generalNavigationSearchEntries)
|
||||
const projectRuntimeSearchEntries = wslSupportedPlatform
|
||||
? getGeneralProjectRuntimeSearchEntries()
|
||||
: []
|
||||
|
||||
const visibleSections = [
|
||||
matchesSettingsSearch(searchQuery, getGeneralNavigationSearchEntries()) ? (
|
||||
matchesSettingsSearch(searchQuery, generalNavigationSearchEntries) ? (
|
||||
<section key="navigation" className="space-y-4">
|
||||
<SettingsSubsectionHeader
|
||||
title={translate('auto.components.settings.GeneralPane.d58fccfd84', 'Navigation')}
|
||||
/>
|
||||
<RecentTabOrderControl
|
||||
ctrlTabOrderMode={settings.ctrlTabOrderMode ?? 'mru'}
|
||||
keywords={getGeneralNavigationSearchEntries().flatMap((entry) => [
|
||||
entry.title,
|
||||
entry.description ?? '',
|
||||
...(entry.keywords ?? [])
|
||||
])}
|
||||
keywords={tabOrderKeywords}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.GeneralPane.5cb5475664',
|
||||
'Confirm before closing pinned tabs'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.GeneralPane.36b2a5dc6d',
|
||||
'Show a confirmation dialog before a pinned tab is closed.'
|
||||
)}
|
||||
keywords={['pinned', 'tab', 'confirm', 'close']}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={translate(
|
||||
'auto.components.settings.GeneralPane.5cb5475664',
|
||||
'Confirm before closing pinned tabs'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.GeneralPane.36b2a5dc6d',
|
||||
'Show a confirmation dialog before a pinned tab is closed.'
|
||||
)}
|
||||
checked={settings.confirmClosePinnedTab ?? true}
|
||||
onChange={() =>
|
||||
updateSettings({ confirmClosePinnedTab: !(settings.confirmClosePinnedTab ?? true) })
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, getGeneralWorkspaceSearchEntries()) ? (
|
||||
|
||||
@@ -148,6 +148,23 @@ export const getGeneralNavigationSearchEntries = createLocalizedCatalog(() => [
|
||||
...translateSearchKeyword('auto.components.settings.general.search.f8f0ac213a', 'sequential'),
|
||||
...translateSearchKeyword('auto.components.settings.general.search.fb84767421', 'switch')
|
||||
]
|
||||
},
|
||||
{
|
||||
title: translate(
|
||||
'auto.components.settings.general.search.161a86a9da',
|
||||
'Confirm before closing pinned tabs'
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.settings.general.search.8e593f04fc',
|
||||
'Show a confirmation dialog before a pinned tab is closed.'
|
||||
),
|
||||
keywords: [
|
||||
...translateSearchKeyword('auto.components.settings.general.search.867dddea41', 'pinned'),
|
||||
...translateSearchKeyword('auto.components.settings.general.search.5250cf0e48', 'pin'),
|
||||
...translateSearchKeyword('auto.components.settings.general.search.2a254b725e', 'tab'),
|
||||
...translateSearchKeyword('auto.components.settings.general.search.9f8558233a', 'confirm'),
|
||||
...translateSearchKeyword('auto.components.settings.general.search.afa37a34e1', 'close')
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import PinnedTabCloseDialog from './PinnedTabCloseDialog'
|
||||
|
||||
const initialState = useAppStore.getInitialState()
|
||||
const mountedRoots: Root[] = []
|
||||
|
||||
async function renderDialog({
|
||||
tabLabel = 'Docs',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
updateSettings
|
||||
}: {
|
||||
tabLabel?: string
|
||||
onConfirm: () => void
|
||||
onCancel?: () => void
|
||||
updateSettings: AppState['updateSettings']
|
||||
}): Promise<void> {
|
||||
useAppStore.setState({
|
||||
settings: { confirmClosePinnedTab: true } as AppState['settings'],
|
||||
updateSettings
|
||||
})
|
||||
useAppStore.getState().requestPinnedTabCloseConfirm({
|
||||
tabLabel,
|
||||
onConfirm,
|
||||
...(onCancel ? { onCancel } : {})
|
||||
})
|
||||
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
mountedRoots.push(root)
|
||||
|
||||
await act(async () => {
|
||||
root.render(<PinnedTabCloseDialog />)
|
||||
})
|
||||
}
|
||||
|
||||
function getButton(label: string): HTMLButtonElement {
|
||||
const button = [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(candidate) => candidate.textContent === label
|
||||
)
|
||||
if (!button) {
|
||||
throw new Error(`Button not found: ${label}`)
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
function getCheckbox(): HTMLButtonElement {
|
||||
const checkbox = document.body.querySelector<HTMLButtonElement>('[role="checkbox"]')
|
||||
if (!checkbox) {
|
||||
throw new Error('Checkbox not found')
|
||||
}
|
||||
return checkbox
|
||||
}
|
||||
|
||||
describe('PinnedTabCloseDialog', () => {
|
||||
beforeEach(() => {
|
||||
useAppStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
for (const root of mountedRoots.splice(0)) {
|
||||
root.unmount()
|
||||
}
|
||||
})
|
||||
document.body.innerHTML = ''
|
||||
useAppStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
it('confirms without changing the preference by default', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await renderDialog({ onConfirm, updateSettings })
|
||||
|
||||
expect(document.body.textContent).toContain('Close pinned tab?')
|
||||
expect(document.body.textContent).toContain("Don't ask again for pinned tabs")
|
||||
|
||||
await act(async () => {
|
||||
getButton('Close').click()
|
||||
})
|
||||
|
||||
expect(updateSettings).not.toHaveBeenCalled()
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('turns off future pinned-tab confirmations when checked and confirmed', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await renderDialog({ onConfirm, updateSettings })
|
||||
|
||||
await act(async () => {
|
||||
getCheckbox().click()
|
||||
})
|
||||
await act(async () => {
|
||||
getButton('Close').click()
|
||||
})
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({ confirmClosePinnedTab: false })
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not persist the checkbox on cancel and resets it for the next request', async () => {
|
||||
const firstOnConfirm = vi.fn()
|
||||
const secondOnConfirm = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await renderDialog({ onConfirm: firstOnConfirm, onCancel, updateSettings })
|
||||
|
||||
await act(async () => {
|
||||
getCheckbox().click()
|
||||
})
|
||||
await act(async () => {
|
||||
getButton('Cancel').click()
|
||||
})
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
expect(firstOnConfirm).not.toHaveBeenCalled()
|
||||
expect(updateSettings).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
useAppStore.getState().requestPinnedTabCloseConfirm({
|
||||
tabLabel: 'Console',
|
||||
onConfirm: secondOnConfirm
|
||||
})
|
||||
})
|
||||
|
||||
expect(getCheckbox().getAttribute('aria-checked')).toBe('false')
|
||||
|
||||
await act(async () => {
|
||||
getButton('Close').click()
|
||||
})
|
||||
|
||||
expect(updateSettings).not.toHaveBeenCalled()
|
||||
expect(secondOnConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
/** Confirmation prompt shown when a pinned tab is about to be closed. Driven by
|
||||
* store state so every close path (keyboard, native menu, CLI) can route a
|
||||
* pinned tab through it without threading React context. */
|
||||
export default function PinnedTabCloseDialog(): React.JSX.Element {
|
||||
const checkboxId = useId()
|
||||
const request = useAppStore((state) => state.pinnedTabCloseConfirm)
|
||||
const confirmPinnedTabClose = useAppStore((state) => state.confirmPinnedTabClose)
|
||||
const dismissPinnedTabClose = useAppStore((state) => state.dismissPinnedTabClose)
|
||||
const updateSettings = useAppStore((state) => state.updateSettings)
|
||||
const [dontAskAgain, setDontAskAgain] = useState(false)
|
||||
|
||||
const tabLabel = request?.tabLabel.trim()
|
||||
|
||||
useEffect(() => {
|
||||
if (request !== null) {
|
||||
setDontAskAgain(false)
|
||||
}
|
||||
}, [request])
|
||||
|
||||
const handleConfirm = (): void => {
|
||||
if (dontAskAgain) {
|
||||
void updateSettings({ confirmClosePinnedTab: false })
|
||||
}
|
||||
confirmPinnedTabClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={request !== null}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
dismissPinnedTabClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">
|
||||
{translate(
|
||||
'auto.components.terminal.pane.PinnedTabCloseDialog.6c190f295a',
|
||||
'Close pinned tab?'
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{translate(
|
||||
'auto.components.terminal.pane.PinnedTabCloseDialog.0d1963f4a6',
|
||||
'This tab is pinned. Are you sure you want to close it?'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{tabLabel ? (
|
||||
<p className="truncate text-xs font-medium text-foreground" title={tabLabel}>
|
||||
{tabLabel}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={dontAskAgain}
|
||||
onCheckedChange={(checked) => setDontAskAgain(checked === true)}
|
||||
/>
|
||||
<Label htmlFor={checkboxId} className="text-xs font-normal text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.terminal.pane.PinnedTabCloseDialog.dont_ask_again',
|
||||
"Don't ask again for pinned tabs"
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={dismissPinnedTabClose}>
|
||||
{translate('auto.components.terminal.pane.PinnedTabCloseDialog.0b38ee2f86', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" size="sm" autoFocus onClick={handleConfirm}>
|
||||
{translate('auto.components.terminal.pane.PinnedTabCloseDialog.c337c9d75c', 'Close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { CSSProperties } from 'react'
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
import { X } from 'lucide-react'
|
||||
import { useAppStore } from '../../store'
|
||||
import { isUnifiedTabPinned } from '@/store/pinned-tab-close-guard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useLinkRoutingPreferenceDialog } from '@/components/link-routing-preference-dialog'
|
||||
@@ -828,6 +829,19 @@ export default function TerminalPane({
|
||||
|
||||
const handleRequestClosePane = useCallback(
|
||||
(paneId: number) => {
|
||||
// Why: when closing the last pane of a pinned tab, the pin confirmation
|
||||
// takes precedence over the running-process prompt — let executeClosePane
|
||||
// fall through to closeTerminalTab, which raises the single pin dialog
|
||||
// (confirming it kills the process). Non-pinned tabs keep the process prompt.
|
||||
const isLastPane = (managerRef.current?.getPanes().length ?? 0) <= 1
|
||||
if (isLastPane) {
|
||||
const state = useAppStore.getState()
|
||||
const confirmPinned = state.settings?.confirmClosePinnedTab ?? true
|
||||
if (confirmPinned && isUnifiedTabPinned(state, worktreeId, tabId)) {
|
||||
executeClosePane(paneId)
|
||||
return
|
||||
}
|
||||
}
|
||||
const transport = paneTransportsRef.current.get(paneId)
|
||||
const ptyId = transport?.getPtyId()
|
||||
if (!ptyId) {
|
||||
@@ -849,7 +863,7 @@ export default function TerminalPane({
|
||||
// had a child process. Matches the semantics of the !ptyId branch above.
|
||||
.catch(() => executeClosePane(paneId))
|
||||
},
|
||||
[executeClosePane, getCloseDialogCopyKind]
|
||||
[executeClosePane, tabId, worktreeId, getCloseDialogCopyKind]
|
||||
)
|
||||
|
||||
const handleSearchSelectedText = useCallback((selectedText: string): void => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ActivityTerminalPortalTarget
|
||||
} from '../activity/activity-terminal-portal'
|
||||
import TerminalPane from './TerminalPane'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
|
||||
type TerminalOverlayAssignment = {
|
||||
groupId: string
|
||||
@@ -210,7 +211,10 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
|
||||
leaveWorktreeIfEmpty()
|
||||
}}
|
||||
onCloseTab={() => {
|
||||
closeTab(terminalTabId)
|
||||
// Why: route through closeTerminalTab (not the raw store closeTab) so a
|
||||
// pinned tab hits the confirmation guard. The overlay's direct
|
||||
// store.closeTab was the path that closed pinned terminals silently.
|
||||
closeTerminalTab(terminalTabId)
|
||||
leaveWorktreeIfEmpty()
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -79,6 +79,7 @@ import {
|
||||
} from '@/constants/terminal'
|
||||
import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry'
|
||||
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
import { seedStartupSessionRestoredBanner } from './session-restored-banner-pane-state'
|
||||
|
||||
export function recordRuntimeCreatedTerminalPaneSplit(
|
||||
@@ -1270,7 +1271,10 @@ export function useTerminalPaneLifecycle({
|
||||
return
|
||||
}
|
||||
if (mgr.getPanes().length <= 1) {
|
||||
useAppStore.getState().closeTab(tabId)
|
||||
// Why: route through closeTerminalTab (not the raw store closeTab) so a
|
||||
// pinned tab hits the confirmation guard. Closing the last pane here was
|
||||
// the one path that silently dropped pinned tabs.
|
||||
closeTerminalTab(tabId)
|
||||
} else {
|
||||
mgr.closePane(detail.paneRuntimeId)
|
||||
scheduleRuntimeGraphSync()
|
||||
|
||||
@@ -267,6 +267,119 @@ describe('closeTerminalTab', () => {
|
||||
expect(closeWebRuntimeSessionTabMock).not.toHaveBeenCalled()
|
||||
expect(closeTab).toHaveBeenCalledWith('local-agent-tab')
|
||||
})
|
||||
|
||||
function makePinnedTabState(
|
||||
overrides: { confirmClosePinnedTab: boolean } & Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const { confirmClosePinnedTab, ...rest } = overrides
|
||||
return {
|
||||
settings: { activeRuntimeEnvironmentId: null, confirmClosePinnedTab },
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'unified-pinned-1',
|
||||
entityId: 'pinned-entity-1',
|
||||
contentType: 'terminal',
|
||||
groupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
label: 'Server',
|
||||
generatedLabel: null,
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0,
|
||||
isPreview: false,
|
||||
isPinned: true
|
||||
}
|
||||
]
|
||||
},
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeTabId: 'pinned-entity-1',
|
||||
openFiles: [],
|
||||
browserTabsByWorktree: {},
|
||||
closeTab: vi.fn(),
|
||||
closeUnifiedTab: vi.fn(),
|
||||
setActiveTab: vi.fn(),
|
||||
setActiveWorktree: vi.fn(),
|
||||
requestPinnedTabCloseConfirm: vi.fn(),
|
||||
...rest
|
||||
}
|
||||
}
|
||||
|
||||
it('routes a pinned tab through the confirmation guard instead of closing it', () => {
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
const closeUnifiedTab = vi.fn()
|
||||
getStateMock.mockReturnValue(
|
||||
makePinnedTabState({
|
||||
confirmClosePinnedTab: true,
|
||||
requestPinnedTabCloseConfirm,
|
||||
closeUnifiedTab
|
||||
})
|
||||
)
|
||||
|
||||
closeTerminalTab('pinned-entity-1')
|
||||
|
||||
expect(closeUnifiedTab).not.toHaveBeenCalled()
|
||||
expect(requestPinnedTabCloseConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(requestPinnedTabCloseConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tabLabel: 'Server', onConfirm: expect.any(Function) })
|
||||
)
|
||||
})
|
||||
|
||||
it('closes the pinned tab when the confirmation callback runs', () => {
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
const closeUnifiedTab = vi.fn()
|
||||
getStateMock.mockReturnValue(
|
||||
makePinnedTabState({
|
||||
confirmClosePinnedTab: true,
|
||||
requestPinnedTabCloseConfirm,
|
||||
closeUnifiedTab
|
||||
})
|
||||
)
|
||||
|
||||
closeTerminalTab('pinned-entity-1')
|
||||
const { onConfirm } = requestPinnedTabCloseConfirm.mock.calls[0][0] as { onConfirm: () => void }
|
||||
onConfirm()
|
||||
|
||||
expect(closeUnifiedTab).toHaveBeenCalledWith('unified-pinned-1')
|
||||
})
|
||||
|
||||
it('guards a pinned tab closed by its unified id (workspace overlay path)', () => {
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
const closeUnifiedTab = vi.fn()
|
||||
getStateMock.mockReturnValue(
|
||||
makePinnedTabState({
|
||||
confirmClosePinnedTab: true,
|
||||
requestPinnedTabCloseConfirm,
|
||||
closeUnifiedTab
|
||||
})
|
||||
)
|
||||
|
||||
// Why: TerminalPaneOverlayLayer closes by terminalTab.id (the unified id),
|
||||
// not the entityId. The guard must still recognize it as pinned.
|
||||
closeTerminalTab('unified-pinned-1')
|
||||
|
||||
expect(closeUnifiedTab).not.toHaveBeenCalled()
|
||||
expect(requestPinnedTabCloseConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('closes a pinned tab immediately when the confirmation setting is off', () => {
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
const closeUnifiedTab = vi.fn()
|
||||
getStateMock.mockReturnValue(
|
||||
makePinnedTabState({
|
||||
confirmClosePinnedTab: false,
|
||||
requestPinnedTabCloseConfirm,
|
||||
closeUnifiedTab
|
||||
})
|
||||
)
|
||||
|
||||
closeTerminalTab('pinned-entity-1')
|
||||
|
||||
expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled()
|
||||
expect(closeUnifiedTab).toHaveBeenCalledWith('unified-pinned-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeOtherTerminalTabs', () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@/runtime/web-runtime-session'
|
||||
import { resolveHostSessionTabIdForWebSessionTab } from '@/runtime/web-session-tabs-sync'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { guardPinnedTabClose, resolvePinnedTabLabel } from '@/store/pinned-tab-close-guard'
|
||||
|
||||
const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>([
|
||||
'editor',
|
||||
@@ -141,7 +142,7 @@ export function createNewTerminalTab(
|
||||
state.setTabBarOrder(activeWorktreeId, order)
|
||||
}
|
||||
|
||||
export function closeTerminalTab(tabId: string): void {
|
||||
export function closeTerminalTab(tabId: string, options?: { force?: boolean }): void {
|
||||
const state = useAppStore.getState()
|
||||
const target = resolveCloseTerminalTabTarget(state, tabId)
|
||||
if (!target) {
|
||||
@@ -149,7 +150,14 @@ export function closeTerminalTab(tabId: string): void {
|
||||
}
|
||||
const { worktreeId: owningWorktreeId, terminalTabId } = target
|
||||
|
||||
if (isPinnedVisibleTab(state, owningWorktreeId, terminalTabId)) {
|
||||
// Why: a pinned tab routes through the confirmation guard instead of closing
|
||||
// outright. `force` is the post-confirmation re-entry, which skips the guard.
|
||||
if (!options?.force && isPinnedVisibleTab(state, owningWorktreeId, terminalTabId)) {
|
||||
guardPinnedTabClose({
|
||||
isPinned: true,
|
||||
tabLabel: resolvePinnedTabLabel(state, owningWorktreeId, terminalTabId),
|
||||
onClose: () => closeTerminalTab(tabId, { force: true })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@ import {
|
||||
} from './useIpcEvents'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
|
||||
const { closeTerminalTabMock } = vi.hoisted(() => ({
|
||||
closeTerminalTabMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/terminal/terminal-tab-actions', () => ({
|
||||
closeTerminalTab: closeTerminalTabMock
|
||||
}))
|
||||
|
||||
const FUTURE_LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const STALE_LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const ORPHAN_LEAF_ID = '33333333-3333-4333-8333-333333333333'
|
||||
@@ -1814,6 +1822,442 @@ describe('useIpcEvents browser tab close routing', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.unstubAllGlobals()
|
||||
closeTerminalTabMock.mockReset()
|
||||
})
|
||||
|
||||
type RequestTabCloseListener = (data: {
|
||||
requestId: string
|
||||
tabId: string | null
|
||||
worktreeId?: string
|
||||
}) => void
|
||||
type CloseActiveTabListener = () => void
|
||||
type CloseTerminalListener = (data: { tabId: string; paneRuntimeId?: number | null }) => void
|
||||
|
||||
async function useIpcEventsForCloseRouting({
|
||||
closeActiveTabListenerRef,
|
||||
closeTerminalListenerRef,
|
||||
getState,
|
||||
requestTabCloseListenerRef,
|
||||
replyTabClose = vi.fn()
|
||||
}: {
|
||||
closeActiveTabListenerRef?: { current: CloseActiveTabListener | null }
|
||||
closeTerminalListenerRef?: { current: CloseTerminalListener | null }
|
||||
getState: () => Record<string, unknown>
|
||||
requestTabCloseListenerRef?: { current: RequestTabCloseListener | null }
|
||||
replyTabClose?: ReturnType<typeof vi.fn>
|
||||
}): Promise<void> {
|
||||
vi.doMock('react', async () => {
|
||||
const actual = await vi.importActual<typeof ReactModule>('react')
|
||||
return {
|
||||
...actual,
|
||||
useEffect: (effect: () => void | (() => void)) => {
|
||||
effect()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const appStoreModule = {
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => ({
|
||||
setUpdateStatus: vi.fn(),
|
||||
fetchRepos: vi.fn(),
|
||||
fetchWorktrees: vi.fn(),
|
||||
setActiveView: vi.fn(),
|
||||
activeModal: null,
|
||||
closeModal: vi.fn(),
|
||||
openModal: vi.fn(),
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeView: 'terminal',
|
||||
setActiveRepo: vi.fn(),
|
||||
setActiveWorktree: vi.fn(),
|
||||
revealWorktreeInSidebar: vi.fn(),
|
||||
setIsFullScreen: vi.fn(),
|
||||
updateBrowserTabPageState: vi.fn(),
|
||||
activeTabType: 'browser',
|
||||
editorFontZoomLevel: 0,
|
||||
setEditorFontZoomLevel: vi.fn(),
|
||||
setRateLimitsFromPush: vi.fn(),
|
||||
setSshConnectionState: vi.fn(),
|
||||
setSshTargetLabels: vi.fn(),
|
||||
setPortForwards: vi.fn(),
|
||||
clearPortForwards: vi.fn(),
|
||||
setDetectedPorts: vi.fn(),
|
||||
enqueueSshCredentialRequest: vi.fn(),
|
||||
removeSshCredentialRequest: vi.fn(),
|
||||
settings: { activeRuntimeEnvironmentId: null, terminalFontSize: 13 },
|
||||
activeBrowserTabId: 'workspace-1',
|
||||
activeBrowserTabIdByWorktree: { 'wt-1': 'workspace-1' },
|
||||
browserTabsByWorktree: { 'wt-1': [{ id: 'workspace-1' }] },
|
||||
browserPagesByWorkspace: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
closeBrowserTab: vi.fn(),
|
||||
closeBrowserPage: vi.fn(),
|
||||
requestPinnedTabCloseConfirm: vi.fn(),
|
||||
...getState()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
vi.doMock('../store', () => appStoreModule)
|
||||
vi.doMock('@/store', () => appStoreModule)
|
||||
|
||||
vi.doMock('@/lib/ui-zoom', () => ({
|
||||
applyUIZoom: vi.fn()
|
||||
}))
|
||||
vi.doMock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn(),
|
||||
ensureWorktreeHasInitialTerminal: vi.fn()
|
||||
}))
|
||||
vi.doMock('@/components/sidebar/visible-worktrees', () => ({
|
||||
getVisibleWorktreeIds: () => []
|
||||
}))
|
||||
vi.doMock('@/lib/editor-font-zoom', () => ({
|
||||
nextEditorFontZoomLevel: vi.fn(() => 0),
|
||||
computeEditorFontSize: vi.fn(() => 13)
|
||||
}))
|
||||
vi.doMock('@/components/settings/SettingsConstants', () => ({
|
||||
zoomLevelToPercent: vi.fn(() => 100),
|
||||
ZOOM_MIN: -3,
|
||||
ZOOM_MAX: 3
|
||||
}))
|
||||
vi.doMock('@/lib/zoom-events', () => ({
|
||||
dispatchZoomLevelChanged: vi.fn()
|
||||
}))
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
dispatchEvent: vi.fn(),
|
||||
api: {
|
||||
repos: { onChanged: () => () => {} },
|
||||
worktrees: {
|
||||
onChanged: () => () => {},
|
||||
onBaseStatus: () => () => {},
|
||||
onRemoteBranchConflict: () => () => {}
|
||||
},
|
||||
ui: {
|
||||
onStateChanged: () => () => {},
|
||||
onOpenSettings: () => () => {},
|
||||
onOpenFeatureTour: () => () => {},
|
||||
onToggleLeftSidebar: () => () => {},
|
||||
onToggleRightSidebar: () => () => {},
|
||||
onToggleWorktreePalette: () => () => {},
|
||||
onToggleFloatingTerminal: () => () => {},
|
||||
onOpenQuickOpen: () => () => {},
|
||||
onOpenNewWorkspace: () => () => {},
|
||||
onOpenTasks: () => () => {},
|
||||
onJumpToWorktreeIndex: () => () => {},
|
||||
onJumpToTabIndex: () => () => {},
|
||||
onWorktreeHistoryNavigate: () => () => {},
|
||||
onActivateWorktree: () => () => {},
|
||||
onCreateTerminal: () => () => {},
|
||||
onRequestTerminalCreate: () => () => {},
|
||||
replyTerminalCreate: () => {},
|
||||
onSplitTerminal: () => () => {},
|
||||
onRenameTerminal: () => () => {},
|
||||
onFocusTerminal: () => () => {},
|
||||
onFocusEditorTab: () => () => {},
|
||||
onCloseSessionTab: () => () => {},
|
||||
onMoveSessionTab: () => () => {},
|
||||
onOpenFileFromMobile: () => () => {},
|
||||
onOpenDiffFromMobile: () => () => {},
|
||||
onCloseTerminal: (listener: CloseTerminalListener) => {
|
||||
if (closeTerminalListenerRef) {
|
||||
closeTerminalListenerRef.current = listener
|
||||
}
|
||||
return () => {}
|
||||
},
|
||||
onSleepWorktree: () => () => {},
|
||||
onNewBrowserTab: () => () => {},
|
||||
onNewMarkdownTab: () => () => {},
|
||||
onRequestTabCreate: () => () => {},
|
||||
replyTabCreate: () => {},
|
||||
onRequestTabClose: (listener: RequestTabCloseListener) => {
|
||||
if (requestTabCloseListenerRef) {
|
||||
requestTabCloseListenerRef.current = listener
|
||||
}
|
||||
return () => {}
|
||||
},
|
||||
replyTabClose,
|
||||
onRequestTabSetProfile: () => () => {},
|
||||
replyTabSetProfile: () => {},
|
||||
onNewTerminalTab: () => () => {},
|
||||
onCloseActiveTab: (listener: CloseActiveTabListener) => {
|
||||
if (closeActiveTabListenerRef) {
|
||||
closeActiveTabListenerRef.current = listener
|
||||
}
|
||||
return () => {}
|
||||
},
|
||||
onSwitchTab: () => () => {},
|
||||
onSwitchTabAcrossAllTypes: () => () => {},
|
||||
onSwitchRecentTab: () => () => {},
|
||||
onSwitchTerminalTab: () => () => {},
|
||||
onToggleStatusBar: () => () => {},
|
||||
onFullscreenChanged: () => () => {},
|
||||
onTerminalZoom: () => () => {},
|
||||
getZoomLevel: () => 0,
|
||||
set: vi.fn()
|
||||
},
|
||||
settings: {
|
||||
onChanged: () => () => {}
|
||||
},
|
||||
updater: {
|
||||
getStatus: () => Promise.resolve({ state: 'idle' }),
|
||||
onStatus: () => () => {},
|
||||
onClearDismissal: () => () => {}
|
||||
},
|
||||
browser: {
|
||||
onGuestLoadFailed: () => () => {},
|
||||
onOpenLinkInOrcaTab: () => () => {},
|
||||
onNavigationUpdate: () => () => {},
|
||||
onActivateView: () => () => {},
|
||||
onPaneFocus: () => () => {}
|
||||
},
|
||||
rateLimits: {
|
||||
get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }),
|
||||
onUpdate: () => () => {}
|
||||
},
|
||||
ssh: {
|
||||
listTargets: () => Promise.resolve([]),
|
||||
listPortForwards: () => Promise.resolve([]),
|
||||
listDetectedPorts: () => Promise.resolve([]),
|
||||
getState: () => Promise.resolve(null),
|
||||
onStateChanged: () => () => {},
|
||||
onCredentialRequest: () => () => {},
|
||||
onPortForwardsChanged: () => () => {},
|
||||
onDetectedPortsChanged: () => () => {},
|
||||
onCredentialResolved: () => () => {}
|
||||
},
|
||||
runtime: {
|
||||
getTerminalFitOverrides: () => Promise.resolve([]),
|
||||
getTerminalDrivers: () => Promise.resolve([]),
|
||||
getBrowserDrivers: () => Promise.resolve([]),
|
||||
onTerminalFitOverrideChanged: () => () => {},
|
||||
onTerminalDriverChanged: () => () => {},
|
||||
onBrowserDriverChanged: () => {}
|
||||
},
|
||||
agentStatus: { onSet: () => () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
const { useIpcEvents: registerIpcEvents } = await import('./useIpcEvents')
|
||||
registerIpcEvents()
|
||||
}
|
||||
|
||||
it('delegates terminal close IPC without a pane id to the shared terminal close flow', async () => {
|
||||
const closeTerminalListenerRef: { current: CloseTerminalListener | null } = { current: null }
|
||||
|
||||
await useIpcEventsForCloseRouting({
|
||||
closeTerminalListenerRef,
|
||||
getState: () => ({})
|
||||
})
|
||||
|
||||
closeTerminalListenerRef.current?.({ tabId: 'terminal-1' })
|
||||
|
||||
expect(closeTerminalTabMock).toHaveBeenCalledWith('terminal-1')
|
||||
})
|
||||
|
||||
it('confirms before closing a pinned active browser tab from the native close event', async () => {
|
||||
const closeActiveTabListenerRef: { current: CloseActiveTabListener | null } = { current: null }
|
||||
const closeBrowserTab = vi.fn()
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
|
||||
await useIpcEventsForCloseRouting({
|
||||
closeActiveTabListenerRef,
|
||||
getState: () => ({
|
||||
closeBrowserTab,
|
||||
requestPinnedTabCloseConfirm,
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'browser-unified-1',
|
||||
entityId: 'workspace-1',
|
||||
contentType: 'browser',
|
||||
label: 'Docs',
|
||||
isPinned: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
closeActiveTabListenerRef.current?.()
|
||||
|
||||
expect(closeBrowserTab).not.toHaveBeenCalled()
|
||||
expect(requestPinnedTabCloseConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tabLabel: 'Docs', onConfirm: expect.any(Function) })
|
||||
)
|
||||
|
||||
const { onConfirm } = requestPinnedTabCloseConfirm.mock.calls[0][0] as { onConfirm: () => void }
|
||||
onConfirm()
|
||||
|
||||
expect(closeBrowserTab).toHaveBeenCalledWith('workspace-1')
|
||||
})
|
||||
|
||||
it('confirms CLI workspace browser closes and replies after confirmation', async () => {
|
||||
const requestTabCloseListenerRef: { current: RequestTabCloseListener | null } = {
|
||||
current: null
|
||||
}
|
||||
const closeBrowserTab = vi.fn()
|
||||
const replyTabClose = vi.fn()
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
|
||||
await useIpcEventsForCloseRouting({
|
||||
requestTabCloseListenerRef,
|
||||
replyTabClose,
|
||||
getState: () => ({
|
||||
closeBrowserTab,
|
||||
requestPinnedTabCloseConfirm,
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'browser-unified-1',
|
||||
entityId: 'workspace-1',
|
||||
contentType: 'browser',
|
||||
label: 'Docs',
|
||||
isPinned: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
requestTabCloseListenerRef.current?.({ requestId: 'req-pinned', tabId: 'workspace-1' })
|
||||
|
||||
expect(closeBrowserTab).not.toHaveBeenCalled()
|
||||
expect(replyTabClose).not.toHaveBeenCalledWith({ requestId: 'req-pinned' })
|
||||
const request = requestPinnedTabCloseConfirm.mock.calls[0][0] as {
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
request.onConfirm()
|
||||
|
||||
expect(closeBrowserTab).toHaveBeenCalledWith('workspace-1')
|
||||
expect(replyTabClose).toHaveBeenCalledWith({ requestId: 'req-pinned' })
|
||||
})
|
||||
|
||||
it('replies with the pinned error when a CLI browser close is canceled', async () => {
|
||||
const requestTabCloseListenerRef: { current: RequestTabCloseListener | null } = {
|
||||
current: null
|
||||
}
|
||||
const closeBrowserTab = vi.fn()
|
||||
const replyTabClose = vi.fn()
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
|
||||
await useIpcEventsForCloseRouting({
|
||||
requestTabCloseListenerRef,
|
||||
replyTabClose,
|
||||
getState: () => ({
|
||||
closeBrowserTab,
|
||||
requestPinnedTabCloseConfirm,
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'browser-unified-1',
|
||||
entityId: 'workspace-1',
|
||||
contentType: 'browser',
|
||||
label: 'Docs',
|
||||
isPinned: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
requestTabCloseListenerRef.current?.({ requestId: 'req-cancel', tabId: 'workspace-1' })
|
||||
const request = requestPinnedTabCloseConfirm.mock.calls[0][0] as {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
request.onCancel()
|
||||
|
||||
expect(closeBrowserTab).not.toHaveBeenCalled()
|
||||
expect(replyTabClose).toHaveBeenCalledWith({
|
||||
requestId: 'req-cancel',
|
||||
error: 'Browser tab workspace-1 is pinned'
|
||||
})
|
||||
})
|
||||
|
||||
it('lets CLI browser closes bypass confirmation when the pinned-tab setting is off', async () => {
|
||||
const requestTabCloseListenerRef: { current: RequestTabCloseListener | null } = {
|
||||
current: null
|
||||
}
|
||||
const closeBrowserTab = vi.fn()
|
||||
const replyTabClose = vi.fn()
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
|
||||
await useIpcEventsForCloseRouting({
|
||||
requestTabCloseListenerRef,
|
||||
replyTabClose,
|
||||
getState: () => ({
|
||||
closeBrowserTab,
|
||||
requestPinnedTabCloseConfirm,
|
||||
settings: {
|
||||
activeRuntimeEnvironmentId: null,
|
||||
confirmClosePinnedTab: false,
|
||||
terminalFontSize: 13
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'browser-unified-1',
|
||||
entityId: 'workspace-1',
|
||||
contentType: 'browser',
|
||||
label: 'Docs',
|
||||
isPinned: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
requestTabCloseListenerRef.current?.({ requestId: 'req-off', tabId: 'workspace-1' })
|
||||
|
||||
expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled()
|
||||
expect(closeBrowserTab).toHaveBeenCalledWith('workspace-1')
|
||||
expect(replyTabClose).toHaveBeenCalledWith({ requestId: 'req-off' })
|
||||
})
|
||||
|
||||
it('guards a CLI last-page close for a pinned browser workspace', async () => {
|
||||
const requestTabCloseListenerRef: { current: RequestTabCloseListener | null } = {
|
||||
current: null
|
||||
}
|
||||
const closeBrowserTab = vi.fn()
|
||||
const closeBrowserPage = vi.fn()
|
||||
const replyTabClose = vi.fn()
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
|
||||
await useIpcEventsForCloseRouting({
|
||||
requestTabCloseListenerRef,
|
||||
replyTabClose,
|
||||
getState: () => ({
|
||||
closeBrowserTab,
|
||||
closeBrowserPage,
|
||||
requestPinnedTabCloseConfirm,
|
||||
browserPagesByWorkspace: {
|
||||
'workspace-1': [{ id: 'page-1', workspaceId: 'workspace-1' }]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'browser-unified-1',
|
||||
entityId: 'workspace-1',
|
||||
contentType: 'browser',
|
||||
label: 'Docs',
|
||||
isPinned: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
requestTabCloseListenerRef.current?.({ requestId: 'req-page', tabId: 'page-1' })
|
||||
|
||||
expect(closeBrowserPage).not.toHaveBeenCalled()
|
||||
expect(closeBrowserTab).not.toHaveBeenCalled()
|
||||
expect(requestPinnedTabCloseConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tabLabel: 'Docs', onConfirm: expect.any(Function) })
|
||||
)
|
||||
})
|
||||
|
||||
it('closes the active browser tab for the requested worktree when main does not provide a tab id', async () => {
|
||||
|
||||
@@ -88,6 +88,7 @@ import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name'
|
||||
import type { RuntimeClientEvent } from '../../../shared/runtime-client-events'
|
||||
import type { AppState } from '../store/types'
|
||||
import { guardPinnedTabClose, resolvePinnedTabLabel } from '../store/pinned-tab-close-guard'
|
||||
import {
|
||||
closeWebRuntimeSessionTab,
|
||||
createWebRuntimeSessionBrowserTab,
|
||||
@@ -112,6 +113,7 @@ import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-tit
|
||||
import { titleHasAgentName } from '../../../shared/agent-detection'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions'
|
||||
|
||||
function getShortcutPlatform(): NodeJS.Platform {
|
||||
if (navigator.userAgent.includes('Mac')) {
|
||||
@@ -1605,16 +1607,18 @@ export function useIpcEvents(): void {
|
||||
const store = useAppStore.getState()
|
||||
const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId)
|
||||
if (browserTarget) {
|
||||
if (isPinnedSessionTab(store, worktreeId, browserTarget.workspaceId)) {
|
||||
return
|
||||
}
|
||||
store.closeBrowserTab(browserTarget.workspaceId)
|
||||
guardPinnedTabClose({
|
||||
isPinned: isPinnedSessionTab(store, worktreeId, browserTarget.workspaceId),
|
||||
tabLabel: resolvePinnedTabLabel(store, worktreeId, browserTarget.workspaceId),
|
||||
onClose: () => useAppStore.getState().closeBrowserTab(browserTarget.workspaceId)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (isPinnedSessionTab(store, worktreeId, tabId)) {
|
||||
return
|
||||
}
|
||||
store.closeUnifiedTab(tabId)
|
||||
guardPinnedTabClose({
|
||||
isPinned: isPinnedSessionTab(store, worktreeId, tabId),
|
||||
tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId),
|
||||
onClose: () => useAppStore.getState().closeUnifiedTab(tabId)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1688,15 +1692,7 @@ export function useIpcEvents(): void {
|
||||
const detail: CloseTerminalPaneDetail = { tabId, paneRuntimeId }
|
||||
window.dispatchEvent(new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail }))
|
||||
} else {
|
||||
const store = useAppStore.getState()
|
||||
const worktreeId =
|
||||
Object.entries(store.tabsByWorktree).find(([, tabs]) =>
|
||||
tabs.some((tab) => tab.id === tabId)
|
||||
)?.[0] ?? null
|
||||
if (worktreeId && isPinnedSessionTab(store, worktreeId, tabId)) {
|
||||
return
|
||||
}
|
||||
store.closeTab(tabId)
|
||||
closeTerminalTab(tabId)
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -2074,7 +2070,32 @@ export function useIpcEvents(): void {
|
||||
}
|
||||
const store = useAppStore.getState()
|
||||
const explicitTargetId = data.tabId ?? null
|
||||
let tabToClose =
|
||||
const replyPinnedBrowserCloseCanceled = (tabId: string): void => {
|
||||
window.api.ui.replyTabClose({
|
||||
requestId: data.requestId,
|
||||
error: translate(
|
||||
'auto.hooks.useIpcEvents.2f6637fe6c',
|
||||
'Browser tab {{value0}} is pinned',
|
||||
{ value0: tabId }
|
||||
)
|
||||
})
|
||||
}
|
||||
const closeBrowserWorkspaceWithReply = (
|
||||
worktreeId: string,
|
||||
workspaceId: string
|
||||
): void => {
|
||||
const currentStore = useAppStore.getState()
|
||||
guardPinnedTabClose({
|
||||
isPinned: isPinnedSessionTab(currentStore, worktreeId, workspaceId),
|
||||
tabLabel: resolvePinnedTabLabel(currentStore, worktreeId, workspaceId),
|
||||
onClose: () => {
|
||||
useAppStore.getState().closeBrowserTab(workspaceId)
|
||||
window.api.ui.replyTabClose({ requestId: data.requestId })
|
||||
},
|
||||
onCancel: () => replyPinnedBrowserCloseCanceled(workspaceId)
|
||||
})
|
||||
}
|
||||
const tabToClose =
|
||||
explicitTargetId ??
|
||||
(data.worktreeId
|
||||
? (store.activeBrowserTabIdByWorktree?.[data.worktreeId] ?? null)
|
||||
@@ -2108,15 +2129,8 @@ export function useIpcEvents(): void {
|
||||
Object.entries(store.browserTabsByWorktree).find(([, tabs]) =>
|
||||
tabs.some((tab) => tab.id === workspaceId)
|
||||
)?.[0] ?? null
|
||||
if (owningWorktreeId && isPinnedSessionTab(store, owningWorktreeId, workspaceId)) {
|
||||
window.api.ui.replyTabClose({
|
||||
requestId: data.requestId,
|
||||
error: translate(
|
||||
'auto.hooks.useIpcEvents.0e3cf53060',
|
||||
'Browser tab {{value0}} is pinned',
|
||||
{ value0: workspaceId }
|
||||
)
|
||||
})
|
||||
if (owningWorktreeId) {
|
||||
closeBrowserWorkspaceWithReply(owningWorktreeId, workspaceId)
|
||||
return
|
||||
}
|
||||
store.closeBrowserTab(workspaceId)
|
||||
@@ -2127,6 +2141,14 @@ export function useIpcEvents(): void {
|
||||
return
|
||||
}
|
||||
}
|
||||
const owningWorktreeId =
|
||||
Object.entries(store.browserTabsByWorktree).find(([, tabs]) =>
|
||||
tabs.some((tab) => tab.id === tabToClose)
|
||||
)?.[0] ?? null
|
||||
if (owningWorktreeId) {
|
||||
closeBrowserWorkspaceWithReply(owningWorktreeId, tabToClose)
|
||||
return
|
||||
}
|
||||
if (explicitTargetId) {
|
||||
window.api.ui.replyTabClose({
|
||||
requestId: data.requestId,
|
||||
@@ -2138,21 +2160,6 @@ export function useIpcEvents(): void {
|
||||
})
|
||||
return
|
||||
}
|
||||
const owningWorktreeId =
|
||||
Object.entries(store.browserTabsByWorktree).find(([, tabs]) =>
|
||||
tabs.some((tab) => tab.id === tabToClose)
|
||||
)?.[0] ?? null
|
||||
if (owningWorktreeId && isPinnedSessionTab(store, owningWorktreeId, tabToClose)) {
|
||||
window.api.ui.replyTabClose({
|
||||
requestId: data.requestId,
|
||||
error: translate(
|
||||
'auto.hooks.useIpcEvents.0e3cf53060',
|
||||
'Browser tab {{value0}} is pinned',
|
||||
{ value0: tabToClose }
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
store.closeBrowserTab(tabToClose)
|
||||
window.api.ui.replyTabClose({ requestId: data.requestId })
|
||||
} catch (err) {
|
||||
@@ -2223,28 +2230,34 @@ export function useIpcEvents(): void {
|
||||
}
|
||||
const store = useAppStore.getState()
|
||||
if (store.activeTabType === 'browser' && store.activeBrowserTabId) {
|
||||
if (
|
||||
store.activeWorktreeId &&
|
||||
isPinnedSessionTab(store, store.activeWorktreeId, store.activeBrowserTabId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const environmentId = getWorktreeRuntimeEnvironmentId(store.activeWorktreeId)
|
||||
if (environmentId && store.activeWorktreeId) {
|
||||
if (!isWebRuntimeSessionActive(environmentId)) {
|
||||
store.closeBrowserTab(store.activeBrowserTabId)
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
await closeWebRuntimeSessionTab({
|
||||
worktreeId: store.activeWorktreeId!,
|
||||
tabId: store.activeBrowserTabId!,
|
||||
const tabId = store.activeBrowserTabId
|
||||
const worktreeId = store.activeWorktreeId
|
||||
const closeActiveBrowserTab = (): void => {
|
||||
const currentStore = useAppStore.getState()
|
||||
const environmentId = getWorktreeRuntimeEnvironmentId(worktreeId)
|
||||
if (environmentId && worktreeId) {
|
||||
if (!isWebRuntimeSessionActive(environmentId)) {
|
||||
currentStore.closeBrowserTab(tabId)
|
||||
return
|
||||
}
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId,
|
||||
tabId,
|
||||
environmentId
|
||||
})
|
||||
})()
|
||||
return
|
||||
}
|
||||
currentStore.closeBrowserTab(tabId)
|
||||
}
|
||||
if (worktreeId && isPinnedSessionTab(store, worktreeId, tabId)) {
|
||||
guardPinnedTabClose({
|
||||
isPinned: true,
|
||||
tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId),
|
||||
onClose: closeActiveBrowserTab
|
||||
})
|
||||
return
|
||||
}
|
||||
store.closeBrowserTab(store.activeBrowserTabId)
|
||||
closeActiveBrowserTab()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@@ -527,6 +527,7 @@
|
||||
},
|
||||
"useIpcEvents": {
|
||||
"0e3cf53060": "Browser tab {{value0}} not found",
|
||||
"2f6637fe6c": "Browser tab {{value0}} is pinned",
|
||||
"a8d2bf8e9e": "No active browser tab to close",
|
||||
"291c8ed902": "Browser tabs are unavailable while a remote runtime is active",
|
||||
"f45fa2b03c": "Browser profiles are unavailable while a remote runtime is active",
|
||||
@@ -2353,6 +2354,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PinnedTabCloseDialog": {
|
||||
"6c190f295a": "Close pinned tab?",
|
||||
"0d1963f4a6": "This tab is pinned. Are you sure you want to close it?",
|
||||
"dont_ask_again": "Don't ask again for pinned tabs",
|
||||
"0b38ee2f86": "Cancel",
|
||||
"c337c9d75c": "Close"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4784,6 +4792,8 @@
|
||||
},
|
||||
"GeneralPane": {
|
||||
"d58fccfd84": "Navigation",
|
||||
"5cb5475664": "Confirm before closing pinned tabs",
|
||||
"36b2a5dc6d": "Show a confirmation dialog before a pinned tab is closed.",
|
||||
"projectRuntime": "Project Runtime",
|
||||
"projectRuntimeDescription": "Default runtime for local Windows projects that do not override it."
|
||||
},
|
||||
@@ -6991,6 +7001,8 @@
|
||||
"7baf524b04": "workspace",
|
||||
"d0bc793689": "Root directory where workspace folders are created.",
|
||||
"4c95d08fa2": "Workspace Directory",
|
||||
"161a86a9da": "Confirm before closing pinned tabs",
|
||||
"8e593f04fc": "Show a confirmation dialog before a pinned tab is closed.",
|
||||
"defaultProjectRuntime": "Default Project Runtime",
|
||||
"defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects."
|
||||
}
|
||||
|
||||
@@ -527,6 +527,7 @@
|
||||
},
|
||||
"useIpcEvents": {
|
||||
"0e3cf53060": "Pestaña del navegador {{value0}} no encontrada",
|
||||
"2f6637fe6c": "La pestaña del navegador {{value0}} está fijada",
|
||||
"a8d2bf8e9e": "No hay ninguna pestaña activa del navegador para cerrar",
|
||||
"291c8ed902": "Las pestañas del navegador no están disponibles mientras un tiempo de ejecución remoto está activo",
|
||||
"f45fa2b03c": "Los perfiles del navegador no están disponibles mientras un tiempo de ejecución remoto está activo",
|
||||
@@ -2353,6 +2354,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PinnedTabCloseDialog": {
|
||||
"6c190f295a": "¿Cerrar pestaña fijada?",
|
||||
"0d1963f4a6": "Esta pestaña está fijada. ¿Seguro que quieres cerrarla?",
|
||||
"dont_ask_again": "No volver a preguntar por pestañas fijadas",
|
||||
"0b38ee2f86": "Cancelar",
|
||||
"c337c9d75c": "Cerrar"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4784,6 +4792,8 @@
|
||||
},
|
||||
"GeneralPane": {
|
||||
"d58fccfd84": "Navegación",
|
||||
"5cb5475664": "Confirmar antes de cerrar pestañas fijadas",
|
||||
"36b2a5dc6d": "Muestra un diálogo de confirmación antes de cerrar una pestaña fijada.",
|
||||
"projectRuntime": "Project Runtime",
|
||||
"projectRuntimeDescription": "Default runtime for local Windows projects that do not override it."
|
||||
},
|
||||
@@ -6954,6 +6964,8 @@
|
||||
"7baf524b04": "espacio de trabajo",
|
||||
"d0bc793689": "Directorio raíz donde se crean las carpetas del espacio de trabajo.",
|
||||
"4c95d08fa2": "Directorio de espacio de trabajo",
|
||||
"161a86a9da": "Confirmar antes de cerrar pestañas fijadas",
|
||||
"8e593f04fc": "Muestra un diálogo de confirmación antes de cerrar una pestaña fijada.",
|
||||
"defaultProjectRuntime": "Default Project Runtime",
|
||||
"defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects."
|
||||
}
|
||||
|
||||
@@ -527,6 +527,7 @@
|
||||
},
|
||||
"useIpcEvents": {
|
||||
"0e3cf53060": "ブラウザタブ {{value0}} が見つかりません",
|
||||
"2f6637fe6c": "ブラウザタブ {{value0}} はピン留めされています",
|
||||
"a8d2bf8e9e": "閉じるためのアクティブなブラウザ タブがありません",
|
||||
"291c8ed902": "リモート ランタイムがアクティブな間はブラウザ タブは使用できません",
|
||||
"f45fa2b03c": "リモート ランタイムがアクティブな間はブラウザ プロファイルを使用できません",
|
||||
@@ -2353,6 +2354,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PinnedTabCloseDialog": {
|
||||
"6c190f295a": "ピン留めしたタブを閉じますか?",
|
||||
"0d1963f4a6": "このタブはピン留めされています。本当に閉じますか?",
|
||||
"dont_ask_again": "ピン留めしたタブについて今後確認しない",
|
||||
"0b38ee2f86": "キャンセル",
|
||||
"c337c9d75c": "閉じる"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4769,6 +4777,8 @@
|
||||
},
|
||||
"GeneralPane": {
|
||||
"d58fccfd84": "ナビゲーション",
|
||||
"5cb5475664": "ピン留めしたタブを閉じる前に確認",
|
||||
"36b2a5dc6d": "ピン留めしたタブを閉じる前に確認ダイアログを表示します。",
|
||||
"projectRuntime": "Project Runtime",
|
||||
"projectRuntimeDescription": "Default runtime for local Windows projects that do not override it."
|
||||
},
|
||||
@@ -6976,6 +6986,8 @@
|
||||
"7baf524b04": "ワークスペース",
|
||||
"d0bc793689": "ワークスペースフォルダーが作成されるルートディレクトリ。",
|
||||
"4c95d08fa2": "ワークスペースディレクトリ",
|
||||
"161a86a9da": "ピン留めしたタブを閉じる前に確認",
|
||||
"8e593f04fc": "ピン留めしたタブを閉じる前に確認ダイアログを表示します。",
|
||||
"defaultProjectRuntime": "Default Project Runtime",
|
||||
"defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects."
|
||||
}
|
||||
|
||||
@@ -527,6 +527,7 @@
|
||||
},
|
||||
"useIpcEvents": {
|
||||
"0e3cf53060": "브라우저 탭 {{value0}}을(를) 찾을 수 없습니다",
|
||||
"2f6637fe6c": "브라우저 탭 {{value0}}이(가) 고정되어 있습니다",
|
||||
"a8d2bf8e9e": "닫을 활성 브라우저 탭이 없습니다.",
|
||||
"291c8ed902": "원격 런타임이 활성화된 동안에는 브라우저 탭을 사용할 수 없습니다.",
|
||||
"f45fa2b03c": "원격 런타임이 활성화된 동안에는 브라우저 프로필을 사용할 수 없습니다.",
|
||||
@@ -2353,6 +2354,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PinnedTabCloseDialog": {
|
||||
"6c190f295a": "고정된 탭을 닫을까요?",
|
||||
"0d1963f4a6": "이 탭은 고정되어 있습니다. 정말 닫으시겠어요?",
|
||||
"dont_ask_again": "고정된 탭에 대해 다시 묻지 않기",
|
||||
"0b38ee2f86": "취소",
|
||||
"c337c9d75c": "닫기"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4769,6 +4777,8 @@
|
||||
},
|
||||
"GeneralPane": {
|
||||
"d58fccfd84": "항해",
|
||||
"5cb5475664": "고정된 탭을 닫기 전에 확인",
|
||||
"36b2a5dc6d": "고정된 탭을 닫기 전에 확인 대화상자를 표시합니다.",
|
||||
"projectRuntime": "Project Runtime",
|
||||
"projectRuntimeDescription": "Default runtime for local Windows projects that do not override it."
|
||||
},
|
||||
@@ -6939,6 +6949,8 @@
|
||||
"7baf524b04": "워크스페이스",
|
||||
"d0bc793689": "워크스페이스 폴더가 생성되는 루트 디렉터리입니다.",
|
||||
"4c95d08fa2": "워크스페이스 디렉토리",
|
||||
"161a86a9da": "고정된 탭을 닫기 전에 확인",
|
||||
"8e593f04fc": "고정된 탭을 닫기 전에 확인 대화상자를 표시합니다.",
|
||||
"defaultProjectRuntime": "Default Project Runtime",
|
||||
"defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects."
|
||||
}
|
||||
|
||||
@@ -527,6 +527,7 @@
|
||||
},
|
||||
"useIpcEvents": {
|
||||
"0e3cf53060": "未找到浏览器选项卡 {{value0}}",
|
||||
"2f6637fe6c": "浏览器选项卡 {{value0}} 已固定",
|
||||
"a8d2bf8e9e": "没有可关闭的活动浏览器选项卡",
|
||||
"291c8ed902": "当远程运行时处于活动状态时,浏览器选项卡不可用",
|
||||
"f45fa2b03c": "当远程运行时处于活动状态时,浏览器配置文件不可用",
|
||||
@@ -2353,6 +2354,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PinnedTabCloseDialog": {
|
||||
"6c190f295a": "关闭固定的标签页?",
|
||||
"0d1963f4a6": "此标签页已固定。确定要关闭它吗?",
|
||||
"dont_ask_again": "固定标签页不再询问",
|
||||
"0b38ee2f86": "取消",
|
||||
"c337c9d75c": "关闭"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4769,6 +4777,8 @@
|
||||
},
|
||||
"GeneralPane": {
|
||||
"d58fccfd84": "导航",
|
||||
"5cb5475664": "关闭固定标签页前确认",
|
||||
"36b2a5dc6d": "关闭固定标签页前显示确认对话框。",
|
||||
"projectRuntime": "Project Runtime",
|
||||
"projectRuntimeDescription": "Default runtime for local Windows projects that do not override it."
|
||||
},
|
||||
@@ -6939,6 +6949,8 @@
|
||||
"7baf524b04": "工作区",
|
||||
"d0bc793689": "创建工作区文件夹的根目录。",
|
||||
"4c95d08fa2": "工作区目录",
|
||||
"161a86a9da": "关闭固定标签页前确认",
|
||||
"8e593f04fc": "关闭固定标签页前显示确认对话框。",
|
||||
"defaultProjectRuntime": "Default Project Runtime",
|
||||
"defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects."
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { createWorkspaceCleanupSlice } from './slices/workspace-cleanup'
|
||||
import { createRuntimeStatusSlice } from './slices/runtime-status'
|
||||
import { createPullRequestGenerationSlice } from './slices/pull-request-generation'
|
||||
import { createCommitMessageGenerationSlice } from './slices/commit-message-generation'
|
||||
import { createPinnedTabCloseConfirmSlice } from './slices/pinned-tab-close-confirm'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
|
||||
|
||||
@@ -67,7 +68,8 @@ export const useAppStore = create<AppState>()((...a) => ({
|
||||
...createWorkspaceCleanupSlice(...a),
|
||||
...createRuntimeStatusSlice(...a),
|
||||
...createPullRequestGenerationSlice(...a),
|
||||
...createCommitMessageGenerationSlice(...a)
|
||||
...createCommitMessageGenerationSlice(...a),
|
||||
...createPinnedTabCloseConfirmSlice(...a)
|
||||
}))
|
||||
|
||||
registerHttpLinkStoreAccessor(() => useAppStore.getState())
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getStateMock } = vi.hoisted(() => ({
|
||||
getStateMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: getStateMock
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
guardPinnedTabClose,
|
||||
isUnifiedTabPinned,
|
||||
resolvePinnedTabLabel
|
||||
} from './pinned-tab-close-guard'
|
||||
import type { AppState } from './types'
|
||||
|
||||
function makeState(overrides: Partial<AppState>): AppState {
|
||||
return {
|
||||
settings: { confirmClosePinnedTab: true },
|
||||
unifiedTabsByWorktree: {},
|
||||
requestPinnedTabCloseConfirm: vi.fn(),
|
||||
...overrides
|
||||
} as unknown as AppState
|
||||
}
|
||||
|
||||
describe('guardPinnedTabClose', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('closes immediately for a non-pinned tab without touching the store', () => {
|
||||
const onClose = vi.fn()
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
getStateMock.mockReturnValue(makeState({ requestPinnedTabCloseConfirm }))
|
||||
|
||||
guardPinnedTabClose({ isPinned: false, tabLabel: 'Docs', onClose })
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requests confirmation for a pinned tab when the setting is on', () => {
|
||||
const onClose = vi.fn()
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
getStateMock.mockReturnValue(
|
||||
makeState({
|
||||
settings: { confirmClosePinnedTab: true } as AppState['settings'],
|
||||
requestPinnedTabCloseConfirm
|
||||
})
|
||||
)
|
||||
|
||||
guardPinnedTabClose({ isPinned: true, tabLabel: 'Docs', onClose })
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(requestPinnedTabCloseConfirm).toHaveBeenCalledWith({
|
||||
tabLabel: 'Docs',
|
||||
onConfirm: onClose
|
||||
})
|
||||
})
|
||||
|
||||
it('passes cancel callbacks to confirmation requests', () => {
|
||||
const onClose = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
getStateMock.mockReturnValue(
|
||||
makeState({
|
||||
settings: { confirmClosePinnedTab: true } as AppState['settings'],
|
||||
requestPinnedTabCloseConfirm
|
||||
})
|
||||
)
|
||||
|
||||
guardPinnedTabClose({ isPinned: true, tabLabel: 'Docs', onClose, onCancel })
|
||||
|
||||
expect(requestPinnedTabCloseConfirm).toHaveBeenCalledWith({
|
||||
tabLabel: 'Docs',
|
||||
onConfirm: onClose,
|
||||
onCancel
|
||||
})
|
||||
})
|
||||
|
||||
it('closes a pinned tab immediately when the setting is off', () => {
|
||||
const onClose = vi.fn()
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
getStateMock.mockReturnValue(
|
||||
makeState({
|
||||
settings: { confirmClosePinnedTab: false } as AppState['settings'],
|
||||
requestPinnedTabCloseConfirm
|
||||
})
|
||||
)
|
||||
|
||||
guardPinnedTabClose({ isPinned: true, tabLabel: 'Docs', onClose })
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults to confirming when settings are not loaded yet', () => {
|
||||
const onClose = vi.fn()
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
getStateMock.mockReturnValue(makeState({ settings: null, requestPinnedTabCloseConfirm }))
|
||||
|
||||
guardPinnedTabClose({ isPinned: true, tabLabel: 'Docs', onClose })
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(requestPinnedTabCloseConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePinnedTabLabel', () => {
|
||||
it('uses the same label priority as the tab strip', () => {
|
||||
const state = makeState({
|
||||
settings: {
|
||||
confirmClosePinnedTab: true,
|
||||
tabAutoGenerateTitle: true
|
||||
} as AppState['settings'],
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'a',
|
||||
entityId: 'ea',
|
||||
customLabel: ' Custom ',
|
||||
quickCommandLabel: 'Run tests',
|
||||
generatedLabel: 'Gen',
|
||||
label: 'Plain'
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
entityId: 'eb',
|
||||
customLabel: ' ',
|
||||
quickCommandLabel: ' Run tests ',
|
||||
generatedLabel: 'Gen',
|
||||
label: 'Plain'
|
||||
},
|
||||
{
|
||||
id: 'c',
|
||||
entityId: 'ec',
|
||||
customLabel: null,
|
||||
quickCommandLabel: null,
|
||||
generatedLabel: ' Gen ',
|
||||
label: 'Plain'
|
||||
},
|
||||
{
|
||||
id: 'd',
|
||||
entityId: 'ed',
|
||||
customLabel: null,
|
||||
quickCommandLabel: null,
|
||||
generatedLabel: null,
|
||||
label: ' Plain '
|
||||
}
|
||||
]
|
||||
} as unknown as AppState['unifiedTabsByWorktree']
|
||||
})
|
||||
|
||||
expect(resolvePinnedTabLabel(state, 'wt-1', 'a')).toBe('Custom')
|
||||
expect(resolvePinnedTabLabel(state, 'wt-1', 'b')).toBe('Run tests')
|
||||
expect(resolvePinnedTabLabel(state, 'wt-1', 'ec')).toBe('Gen')
|
||||
expect(resolvePinnedTabLabel(state, 'wt-1', 'ed')).toBe('Plain')
|
||||
})
|
||||
|
||||
it('falls back to the live label when generated tab titles are disabled', () => {
|
||||
const state = makeState({
|
||||
settings: {
|
||||
confirmClosePinnedTab: true,
|
||||
tabAutoGenerateTitle: false
|
||||
} as AppState['settings'],
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'a',
|
||||
entityId: 'ea',
|
||||
customLabel: null,
|
||||
quickCommandLabel: null,
|
||||
generatedLabel: 'Gen',
|
||||
label: 'Plain'
|
||||
}
|
||||
]
|
||||
} as unknown as AppState['unifiedTabsByWorktree']
|
||||
})
|
||||
|
||||
expect(resolvePinnedTabLabel(state, 'wt-1', 'a')).toBe('Plain')
|
||||
})
|
||||
|
||||
it('returns an empty string when the tab is not found', () => {
|
||||
expect(resolvePinnedTabLabel(makeState({}), 'wt-1', 'missing')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUnifiedTabPinned', () => {
|
||||
const state = makeState({
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{ id: 'uni-1', entityId: 'ent-1', isPinned: true },
|
||||
{ id: 'uni-2', entityId: 'ent-2', isPinned: false }
|
||||
]
|
||||
} as unknown as AppState['unifiedTabsByWorktree']
|
||||
})
|
||||
|
||||
it('matches a pinned tab by its unified id or entityId', () => {
|
||||
expect(isUnifiedTabPinned(state, 'wt-1', 'uni-1')).toBe(true)
|
||||
expect(isUnifiedTabPinned(state, 'wt-1', 'ent-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for unpinned or unknown tabs', () => {
|
||||
expect(isUnifiedTabPinned(state, 'wt-1', 'uni-2')).toBe(false)
|
||||
expect(isUnifiedTabPinned(state, 'wt-1', 'missing')).toBe(false)
|
||||
expect(isUnifiedTabPinned(state, 'wt-unknown', 'uni-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { resolveUnifiedTabLabel } from '../../../shared/tab-title-resolution'
|
||||
import type { AppState } from './types'
|
||||
|
||||
/** Resolves the displayed tab-strip label for the destructive confirmation. */
|
||||
export function resolvePinnedTabLabel(
|
||||
state: AppState,
|
||||
worktreeId: string,
|
||||
visibleId: string
|
||||
): string {
|
||||
const tab = (state.unifiedTabsByWorktree?.[worktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === visibleId || candidate.entityId === visibleId
|
||||
)
|
||||
return resolveUnifiedTabLabel(tab, state.settings?.tabAutoGenerateTitle === true)
|
||||
}
|
||||
|
||||
/** Whether the unified tab matching `tabId` (by id or entityId) in the given
|
||||
* worktree is pinned. Used to let pin confirmation take precedence over the
|
||||
* running-process close prompt. */
|
||||
export function isUnifiedTabPinned(state: AppState, worktreeId: string, tabId: string): boolean {
|
||||
return (state.unifiedTabsByWorktree?.[worktreeId] ?? []).some(
|
||||
(tab) => (tab.id === tabId || tab.entityId === tabId) && tab.isPinned === true
|
||||
)
|
||||
}
|
||||
|
||||
/** Routes a pinned-tab close attempt through the confirmation dialog when the
|
||||
* setting is on. Non-pinned tabs (and pinned tabs when the setting is off)
|
||||
* close immediately. Keeping every close path behind this single helper is why
|
||||
* the keyboard/native-menu paths can no longer silently drop a pinned tab. */
|
||||
export function guardPinnedTabClose(params: {
|
||||
isPinned: boolean
|
||||
tabLabel: string
|
||||
onClose: () => void
|
||||
onCancel?: () => void
|
||||
}): void {
|
||||
const { isPinned, tabLabel, onClose, onCancel } = params
|
||||
if (!isPinned) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
const state = useAppStore.getState()
|
||||
const shouldConfirm = state.settings?.confirmClosePinnedTab ?? true
|
||||
if (!shouldConfirm) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
state.requestPinnedTabCloseConfirm({
|
||||
tabLabel,
|
||||
onConfirm: onClose,
|
||||
...(onCancel ? { onCancel } : {})
|
||||
})
|
||||
}
|
||||
@@ -139,6 +139,7 @@ import { createWorkspaceCleanupSlice } from './workspace-cleanup'
|
||||
import { createRuntimeStatusSlice } from './runtime-status'
|
||||
import { createPullRequestGenerationSlice } from './pull-request-generation'
|
||||
import { createCommitMessageGenerationSlice } from './commit-message-generation'
|
||||
import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm'
|
||||
|
||||
function createTestStore() {
|
||||
return create<AppState>()((...a) => ({
|
||||
@@ -173,7 +174,8 @@ function createTestStore() {
|
||||
...createWorkspaceCleanupSlice(...a),
|
||||
...createRuntimeStatusSlice(...a),
|
||||
...createPullRequestGenerationSlice(...a),
|
||||
...createCommitMessageGenerationSlice(...a)
|
||||
...createCommitMessageGenerationSlice(...a),
|
||||
...createPinnedTabCloseConfirmSlice(...a)
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { create } from 'zustand'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm'
|
||||
import type { AppState } from '../types'
|
||||
|
||||
function makeStore() {
|
||||
return create<
|
||||
Pick<
|
||||
AppState,
|
||||
| 'pinnedTabCloseConfirm'
|
||||
| 'requestPinnedTabCloseConfirm'
|
||||
| 'confirmPinnedTabClose'
|
||||
| 'dismissPinnedTabClose'
|
||||
>
|
||||
>()((...args) =>
|
||||
createPinnedTabCloseConfirmSlice(
|
||||
...(args as Parameters<typeof createPinnedTabCloseConfirmSlice>)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
describe('createPinnedTabCloseConfirmSlice', () => {
|
||||
it('starts with no pending request', () => {
|
||||
expect(makeStore().getState().pinnedTabCloseConfirm).toBeNull()
|
||||
})
|
||||
|
||||
it('stores the pending request when one is requested', () => {
|
||||
const store = makeStore()
|
||||
const onConfirm = vi.fn()
|
||||
|
||||
store.getState().requestPinnedTabCloseConfirm({ tabLabel: 'Docs', onConfirm })
|
||||
|
||||
expect(store.getState().pinnedTabCloseConfirm).toEqual({ tabLabel: 'Docs', onConfirm })
|
||||
})
|
||||
|
||||
it('runs onConfirm and clears the request when confirmed', () => {
|
||||
const store = makeStore()
|
||||
const onConfirm = vi.fn()
|
||||
store.getState().requestPinnedTabCloseConfirm({ tabLabel: 'Docs', onConfirm })
|
||||
|
||||
store.getState().confirmPinnedTabClose()
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().pinnedTabCloseConfirm).toBeNull()
|
||||
})
|
||||
|
||||
it('clears the request before running onConfirm so re-entrant closes do not loop', () => {
|
||||
const store = makeStore()
|
||||
const onConfirm = vi.fn(() => {
|
||||
// Why: a close path may synchronously inspect the pending request; it must
|
||||
// already be cleared by the time onConfirm runs.
|
||||
expect(store.getState().pinnedTabCloseConfirm).toBeNull()
|
||||
})
|
||||
store.getState().requestPinnedTabCloseConfirm({ tabLabel: 'Docs', onConfirm })
|
||||
|
||||
store.getState().confirmPinnedTabClose()
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does nothing when confirming with no pending request', () => {
|
||||
const store = makeStore()
|
||||
expect(() => store.getState().confirmPinnedTabClose()).not.toThrow()
|
||||
expect(store.getState().pinnedTabCloseConfirm).toBeNull()
|
||||
})
|
||||
|
||||
it('dismisses without running onConfirm', () => {
|
||||
const store = makeStore()
|
||||
const onConfirm = vi.fn()
|
||||
store.getState().requestPinnedTabCloseConfirm({ tabLabel: 'Docs', onConfirm })
|
||||
|
||||
store.getState().dismissPinnedTabClose()
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
expect(store.getState().pinnedTabCloseConfirm).toBeNull()
|
||||
})
|
||||
|
||||
it('runs onCancel and clears the request when dismissed', () => {
|
||||
const store = makeStore()
|
||||
const onCancel = vi.fn()
|
||||
store.getState().requestPinnedTabCloseConfirm({
|
||||
tabLabel: 'Docs',
|
||||
onConfirm: vi.fn(),
|
||||
onCancel
|
||||
})
|
||||
|
||||
store.getState().dismissPinnedTabClose()
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().pinnedTabCloseConfirm).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
|
||||
/** A pending request to confirm closing a pinned tab. `onConfirm` runs the
|
||||
* original close once the user accepts; the label is shown in the dialog. */
|
||||
export type PinnedTabCloseConfirmRequest = {
|
||||
tabLabel: string
|
||||
onConfirm: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export type PinnedTabCloseConfirmSlice = {
|
||||
pinnedTabCloseConfirm: PinnedTabCloseConfirmRequest | null
|
||||
requestPinnedTabCloseConfirm: (request: PinnedTabCloseConfirmRequest) => void
|
||||
confirmPinnedTabClose: () => void
|
||||
dismissPinnedTabClose: () => void
|
||||
}
|
||||
|
||||
export const createPinnedTabCloseConfirmSlice: StateCreator<
|
||||
AppState,
|
||||
[],
|
||||
[],
|
||||
PinnedTabCloseConfirmSlice
|
||||
> = (set, get) => ({
|
||||
pinnedTabCloseConfirm: null,
|
||||
|
||||
requestPinnedTabCloseConfirm: (request) => set({ pinnedTabCloseConfirm: request }),
|
||||
|
||||
confirmPinnedTabClose: () => {
|
||||
const request = get().pinnedTabCloseConfirm
|
||||
if (!request) {
|
||||
return
|
||||
}
|
||||
// Why: clear before running onConfirm so a re-entrant close path can't see
|
||||
// the stale request and re-open the dialog.
|
||||
set({ pinnedTabCloseConfirm: null })
|
||||
request.onConfirm()
|
||||
},
|
||||
|
||||
dismissPinnedTabClose: () => {
|
||||
const request = get().pinnedTabCloseConfirm
|
||||
if (!request) {
|
||||
return
|
||||
}
|
||||
// Why: CLI close requests wait for a response even when the user cancels.
|
||||
set({ pinnedTabCloseConfirm: null })
|
||||
request.onCancel?.()
|
||||
}
|
||||
})
|
||||
@@ -40,6 +40,7 @@ import { createWorkspaceCleanupSlice } from './workspace-cleanup'
|
||||
import { createRuntimeStatusSlice } from './runtime-status'
|
||||
import { createPullRequestGenerationSlice } from './pull-request-generation'
|
||||
import { createCommitMessageGenerationSlice } from './commit-message-generation'
|
||||
import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export const TEST_REPO = {
|
||||
@@ -83,7 +84,8 @@ export function createTestStore() {
|
||||
...createWorkspaceCleanupSlice(...a),
|
||||
...createRuntimeStatusSlice(...a),
|
||||
...createPullRequestGenerationSlice(...a),
|
||||
...createCommitMessageGenerationSlice(...a)
|
||||
...createCommitMessageGenerationSlice(...a),
|
||||
...createPinnedTabCloseConfirmSlice(...a)
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import type { WorkspaceCleanupSlice } from './slices/workspace-cleanup'
|
||||
import type { RuntimeStatusSlice } from './slices/runtime-status'
|
||||
import type { PullRequestGenerationSlice } from './slices/pull-request-generation'
|
||||
import type { CommitMessageGenerationSlice } from './slices/commit-message-generation'
|
||||
import type { PinnedTabCloseConfirmSlice } from './slices/pinned-tab-close-confirm'
|
||||
|
||||
export type AppState = RepoSlice &
|
||||
SparsePresetsSlice &
|
||||
@@ -62,4 +63,5 @@ export type AppState = RepoSlice &
|
||||
WorkspaceCleanupSlice &
|
||||
RuntimeStatusSlice &
|
||||
PullRequestGenerationSlice &
|
||||
CommitMessageGenerationSlice
|
||||
CommitMessageGenerationSlice &
|
||||
PinnedTabCloseConfirmSlice
|
||||
|
||||
@@ -43,6 +43,10 @@ describe('getDefaultSettings', () => {
|
||||
expect(getDefaultSettings('/tmp').uiLanguage).toBe('system')
|
||||
})
|
||||
|
||||
it('confirms before closing pinned tabs by default', () => {
|
||||
expect(getDefaultSettings('/tmp').confirmClosePinnedTab).toBe(true)
|
||||
})
|
||||
|
||||
it('enables Source Control AI by default without pinning a separate agent', () => {
|
||||
expect(getDefaultSettings('/tmp').commitMessageAi).toMatchObject({
|
||||
enabled: true,
|
||||
|
||||
@@ -310,6 +310,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
||||
agentYoloDefaultsMigrated: true,
|
||||
agentStatusHooksEnabled: true,
|
||||
tabAutoGenerateTitle: false,
|
||||
confirmClosePinnedTab: true,
|
||||
keepComputerAwakeWhileAgentsRun: false,
|
||||
// Why: 'auto' runs a layout-aware probe at boot (see
|
||||
// src/renderer/src/lib/keyboard-layout/*) that picks 'true' for US and
|
||||
|
||||
@@ -2605,6 +2605,10 @@ export type GlobalSettings = {
|
||||
/** Why: generated tab titles are semantic but subjective, so they stay opt-in
|
||||
* and manual renames remain the stronger user intent. */
|
||||
tabAutoGenerateTitle: boolean
|
||||
/** Why: pinned tabs can still be closed via the keyboard/native-menu close
|
||||
* path, so this gates that close behind a confirmation prompt to prevent
|
||||
* accidental loss. Defaults on. */
|
||||
confirmClosePinnedTab: boolean
|
||||
/** When true, Orca requests local awake assertions while hook-reported agents are working. */
|
||||
keepComputerAwakeWhileAgentsRun: boolean
|
||||
/** Why: macOS terminals must choose between letting Option compose layout
|
||||
|
||||
Reference in New Issue
Block a user