diff --git a/src/main/persistence/applying-settings/settings-update-terminal-link-preference.test.ts b/src/main/persistence/applying-settings/settings-update-terminal-link-preference.test.ts new file mode 100644 index 00000000000..e3f9e215c0a --- /dev/null +++ b/src/main/persistence/applying-settings/settings-update-terminal-link-preference.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + closeTestStores, + createStore, + testState, + writeDataFile +} from '../../persistence-test-harness' +import { getDefaultPersistedState } from '../../../shared/constants' +import { updateSettings, type SettingsMutationOperations } from './settings-update' + +function makeOperations(): SettingsMutationOperations { + const state = getDefaultPersistedState(tmpdir()) + state.settings.terminalLinkActionPopoverEnabled = false + return { + state, + bumpLocalWorktreeScanGeneration: vi.fn(), + removeRetainedBlob: vi.fn(), + scheduleSave: vi.fn(), + notifySettingsChanged: vi.fn() + } +} + +describe('terminal URL click preference writes', () => { + it.each(['actions', 'open', 'none'] as const)( + 'publishes the explicit %s choice with a consistent legacy switch', + (behavior) => { + const operations = makeOperations() + operations.state.settings.terminalLinkActionPopoverEnabled = behavior !== 'actions' + operations.state.settings.terminalLinkClickBehavior = + behavior === 'actions' ? 'none' : 'actions' + updateSettings( + operations, + { + terminalLinkClickBehavior: behavior, + terminalLinkActionPopoverEnabled: behavior !== 'actions' + }, + { notifyListeners: true, originWebContentsId: 7 } + ) + + expect(operations.state.settings.terminalLinkClickBehavior).toBe(behavior) + expect(operations.state.settings.terminalLinkActionPopoverEnabled).toBe( + behavior === 'actions' + ) + expect(operations.notifySettingsChanged).toHaveBeenCalledWith( + expect.objectContaining({ + terminalLinkClickBehavior: behavior, + terminalLinkActionPopoverEnabled: behavior === 'actions' + }), + 7 + ) + } + ) + + it('preserves a legacy-only switch update', () => { + const operations = makeOperations() + updateSettings(operations, { terminalLinkActionPopoverEnabled: true }) + expect(operations.state.settings.terminalLinkActionPopoverEnabled).toBe(true) + updateSettings(operations, { terminalLinkActionPopoverEnabled: false }) + expect(operations.state.settings.terminalLinkActionPopoverEnabled).toBe(false) + }) +}) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) + +describe('terminal URL click preference persistence', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-terminal-link-preference-')) + writeDataFile({ version: 1, settings: { terminalLinkActionPopoverEnabled: false } }) + }) + afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('keeps an old opt-out after saving unrelated settings and reopening the profile', async () => { + const store = createStore() + store.updateSettings({ terminalFontSize: 15 }) + store.flush() + await closeTestStores() + + const reopened = createStore() + expect(reopened.getSettings()).toMatchObject({ + terminalLinkClickBehavior: 'actions', + terminalLinkActionPopoverEnabled: false, + terminalFontSize: 15 + }) + }) + + it('keeps a newly selected Actions preference after reopening the profile', async () => { + const store = createStore() + store.updateSettings({ terminalLinkClickBehavior: 'actions' }) + store.flush() + await closeTestStores() + + const reopened = createStore() + expect(reopened.getSettings()).toMatchObject({ + terminalLinkClickBehavior: 'actions', + terminalLinkActionPopoverEnabled: true + }) + }) +}) diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index 706f9105148..932d4a41204 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -58,6 +58,11 @@ export function updateSettings( options: { notifyListeners?: boolean; originWebContentsId?: number } = {} ): GlobalSettings { const sanitizedUpdates = stripRetiredGlobalSettings(updates) + const linkBehavior = updates.terminalLinkClickBehavior + if (linkBehavior === 'actions' || linkBehavior === 'open' || linkBehavior === 'none') { + // Loaded defaults are ambiguous; synchronize the legacy switch only on an explicit write. + sanitizedUpdates.terminalLinkActionPopoverEnabled = linkBehavior === 'actions' + } if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) { operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie) } diff --git a/tests/e2e/terminal-link-click-ownership.spec.ts b/tests/e2e/terminal-link-click-ownership.spec.ts index 50b570b0a6d..7ebad18453d 100644 --- a/tests/e2e/terminal-link-click-ownership.spec.ts +++ b/tests/e2e/terminal-link-click-ownership.spec.ts @@ -12,6 +12,11 @@ import { } from './helpers/terminal' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + readPanelNavigationObserver, + startPanelNavigationObserver +} from './helpers/plugin-panel-navigation-observer' + const FIXTURE_PATH = path.join( process.cwd(), 'tests/e2e/fixtures/terminal-link-mouse-owner-fixture.cjs' @@ -92,12 +97,117 @@ async function expectChildMouseReports(mouseLogPath: string): Promise { .toBeGreaterThan(0) } -async function expectOrcaOwnedMouseOutcome(mouseLogPath: string): Promise { +async function expectOrcaOwnedMouseOutcome( + mouseLogPath: string, + reportsBeforeClick = 0 +): Promise { await new Promise((resolve) => setTimeout(resolve, 1_000)) - expect(childMouseReportCount(mouseLogPath)).toBe(0) + expect(childMouseReportCount(mouseLogPath)).toBe(reportsBeforeClick) +} + +async function openLegacyDisabledLinkSettings(orcaPage: Page): Promise { + await orcaPage.evaluate(() => { + void window.__store?.getState().updateSettings({ terminalLinkActionPopoverEnabled: false }) + }) + await expect + .poll(() => + orcaPage.evaluate(() => window.__store?.getState().settings?.terminalLinkActionPopoverEnabled) + ) + .toBe(false) + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsPage() + state?.openSettingsTarget({ pane: 'browser', repoId: null }) + }) } test.describe('terminal link click ownership', () => { + test('selecting Actions reenables a legacy-disabled popover and survives renderer reload', async ({ + orcaPage + }, testInfo) => { + const { mouseLogPath, ptyId, target } = await startMouseAwareLinkFixture(orcaPage, testInfo) + await openLegacyDisabledLinkSettings(orcaPage) + + const choices = orcaPage.getByRole('radiogroup', { name: 'Plain click URL behavior' }) + await expect(choices.getByRole('radio', { name: 'Leave to terminal' })).toHaveAttribute( + 'aria-checked', + 'true' + ) + await choices.getByRole('radio', { name: 'Actions', exact: true }).click() + await expect(choices.getByRole('radio', { name: 'Actions', exact: true })).toHaveAttribute( + 'aria-checked', + 'true' + ) + + await orcaPage.reload() + await waitForSessionReady(orcaPage) + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsPage() + state?.openSettingsTarget({ pane: 'browser', repoId: null }) + }) + await expect(choices.getByRole('radio', { name: 'Actions', exact: true })).toHaveAttribute( + 'aria-checked', + 'true' + ) + await orcaPage.screenshot({ path: testInfo.outputPath('actions-selected-after.png') }) + await orcaPage.evaluate(() => window.__store?.getState().closeSettingsPage()) + await waitForActiveTerminalManager(orcaPage) + await orcaPage.mouse.click(target.x, target.y) + await expect(orcaPage.locator('[data-terminal-link-action-popover]')).toBeVisible() + await expect(orcaPage.locator('[data-terminal-link-destination]')).toHaveText(LINK) + await expectOrcaOwnedMouseOutcome(mouseLogPath) + await orcaPage.screenshot({ path: testInfo.outputPath('actions-after.png') }) + await sendToTerminal(orcaPage, ptyId, 'q') + }) + + test('explicit Open URL and Leave to terminal choices preserve direct-open modifiers', async ({ + orcaPage, + electronApp + }, testInfo) => { + const { mouseLogPath, ptyId, target } = await startMouseAwareLinkFixture(orcaPage, testInfo) + await startPanelNavigationObserver(electronApp, orcaPage.url()) + await openLegacyDisabledLinkSettings(orcaPage) + const choices = orcaPage.getByRole('radiogroup', { name: 'Plain click URL behavior' }) + await choices.getByRole('radio', { name: 'Open URL', exact: true }).click() + await expect(choices.getByRole('radio', { name: 'Open URL', exact: true })).toHaveAttribute( + 'aria-checked', + 'true' + ) + await orcaPage.evaluate(() => window.__store?.getState().closeSettingsPage()) + await orcaPage.mouse.click(target.x, target.y) + await expect + .poll(async () => (await readPanelNavigationObserver(electronApp)).externalUrls) + .toEqual([LINK]) + await expectOrcaOwnedMouseOutcome(mouseLogPath) + + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsPage() + state?.openSettingsTarget({ pane: 'browser', repoId: null }) + }) + await choices.getByRole('radio', { name: 'Leave to terminal', exact: true }).click() + await expect( + choices.getByRole('radio', { name: 'Leave to terminal', exact: true }) + ).toHaveAttribute('aria-checked', 'true') + await orcaPage.evaluate(() => window.__store?.getState().closeSettingsPage()) + await orcaPage.mouse.click(target.x, target.y) + await expectChildMouseReports(mouseLogPath) + expect((await readPanelNavigationObserver(electronApp)).externalUrls).toEqual([LINK]) + + const reportsBeforeModifiedClick = childMouseReportCount(mouseLogPath) + const modifier = process.platform === 'darwin' ? 'Meta' : 'Control' + await orcaPage.keyboard.down(modifier) + await orcaPage.mouse.click(target.x, target.y) + await orcaPage.keyboard.up(modifier) + await expect + .poll(async () => (await readPanelNavigationObserver(electronApp)).externalUrls) + .toEqual([LINK, LINK]) + await expectOrcaOwnedMouseOutcome(mouseLogPath, reportsBeforeModifiedClick) + await expect(orcaPage.locator('[data-terminal-link-action-popover]')).toHaveCount(0) + await sendToTerminal(orcaPage, ptyId, 'q') + }) + test('an Orca-owned plain link click emits no child PTY mouse frames', async ({ orcaPage }, testInfo) => {