diff --git a/src/renderer/src/components/editor/markdown-preview-links.test.ts b/src/renderer/src/components/editor/markdown-preview-links.test.ts index 439541adda5..530e05c2bab 100644 --- a/src/renderer/src/components/editor/markdown-preview-links.test.ts +++ b/src/renderer/src/components/editor/markdown-preview-links.test.ts @@ -141,10 +141,10 @@ describe('isMarkdownPreviewSystemBrowserModifier', () => { }) describe('resolveMarkdownPreviewHttpOpenOptions', () => { - // forceSystemBrowser -> shell.openExternal (system default browser); - // worktreeId (no force) -> openHttpLink routes into the Orca browser per the - // openLinksInApp setting. See http-link-routing.test.ts for that mapping. - it('forces the system browser on Cmd+Shift-click on macOS', () => { + // modifierHeld -> openHttpLink resolves the escape hatch against the + // openLinksInApp / openLinksInAppModifierInverts pair; worktreeId (no + // modifier) routes per the setting. See http-link-routing.test.ts. + it('marks the modifier held on Cmd+Shift-click on macOS', () => { expect( resolveMarkdownPreviewHttpOpenOptions( { metaKey: true, ctrlKey: false, shiftKey: true }, @@ -152,10 +152,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => { 'wt-1', { kind: 'local' } ) - ).toEqual({ forceSystemBrowser: true, sourceOwner: { kind: 'local' } }) + ).toEqual({ worktreeId: 'wt-1', modifierHeld: true, sourceOwner: { kind: 'local' } }) }) - it('forces the system browser on Ctrl+Shift-click on Linux/Windows', () => { + it('marks the modifier held on Ctrl+Shift-click on Linux/Windows', () => { expect( resolveMarkdownPreviewHttpOpenOptions( { metaKey: false, ctrlKey: true, shiftKey: true }, @@ -163,7 +163,7 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => { 'wt-1', { kind: 'local' } ) - ).toEqual({ forceSystemBrowser: true, sourceOwner: { kind: 'local' } }) + ).toEqual({ worktreeId: 'wt-1', modifierHeld: true, sourceOwner: { kind: 'local' } }) }) it('routes a plain Cmd-click through the worktree so it can open in Orca', () => { diff --git a/src/renderer/src/components/editor/markdown-preview-links.ts b/src/renderer/src/components/editor/markdown-preview-links.ts index 0541ca0abf7..6062180ac28 100644 --- a/src/renderer/src/components/editor/markdown-preview-links.ts +++ b/src/renderer/src/components/editor/markdown-preview-links.ts @@ -113,10 +113,10 @@ export function isMarkdownPreviewSystemBrowserModifier( return event.shiftKey && (isMac ? event.metaKey : event.ctrlKey) } -// Why: Cmd/Ctrl+Shift-click is the escape hatch that forces the OS default -// browser; every other click routes through openHttpLink so the "open links in -// Orca" setting (and remote-runtime state) decides the destination. Mac uses -// metaKey, Linux/Windows use ctrlKey per AGENTS.md. +// Why: Cmd/Ctrl+Shift-click is the escape hatch; every click routes through +// openHttpLink so the "open links in Orca" setting (and remote-runtime state) +// decides the destination, with modifierHeld telling it the escape hatch fired. +// Mac uses metaKey, Linux/Windows use ctrlKey per AGENTS.md. export function resolveMarkdownPreviewHttpOpenOptions( event: Pick, isMac: boolean, @@ -124,7 +124,7 @@ export function resolveMarkdownPreviewHttpOpenOptions( sourceOwner: HttpLinkSourceOwner ): OpenHttpLinkOptions { if (isMarkdownPreviewSystemBrowserModifier(event, isMac)) { - return { forceSystemBrowser: true, sourceOwner } + return { worktreeId, modifierHeld: true, sourceOwner } } return { worktreeId, sourceOwner } } diff --git a/src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx b/src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx new file mode 100644 index 00000000000..106054a9d6a --- /dev/null +++ b/src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx @@ -0,0 +1,59 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { SearchableSetting } from './SearchableSetting' +import { SettingsSwitchRow } from './SettingsFormControls' +import { + getLinkRoutingModifierDescription, + getLinkRoutingModifierTitle +} from './browser-link-routing-modifier-copy' + +type BrowserLinkRoutingModifierSettingProps = { + settings: Pick + isMac: boolean + updateSettings: (updates: Partial) => void +} + +export function BrowserLinkRoutingModifierSetting({ + settings, + isMac, + updateSettings +}: BrowserLinkRoutingModifierSettingProps): React.JSX.Element { + const openLinksInApp = settings.openLinksInApp === true + const title = getLinkRoutingModifierTitle(openLinksInApp) + const description = getLinkRoutingModifierDescription({ openLinksInApp, isMac }) + + return ( + + {/* Nested under Link Routing: this row only describes what its modifier does. */} +
+ + updateSettings({ + openLinksInAppModifierInverts: settings.openLinksInAppModifierInverts !== true + }) + } + ariaLabel={translate( + 'auto.components.settings.BrowserLinkRoutingModifierSetting.toggleLabel', + 'Toggle inverted link routing modifier' + )} + /> +
+
+ ) +} diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index 4a75f8fec97..c030cffd912 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -10,6 +10,7 @@ import { BrowserDefaultZoomSetting } from './BrowserDefaultZoomSetting' import { BrowserUseSetup } from './BrowserUsePane' import { BrowserSearchEngineSetting } from './BrowserSearchEngineSetting' import { BrowserLinkRoutingSetting } from './BrowserLinkRoutingSetting' +import { BrowserLinkRoutingModifierSetting } from './BrowserLinkRoutingModifierSetting' import { BrowserLocalhostWorktreeLabelsSetting } from './BrowserLocalhostWorktreeLabelsSetting' import { BrowserSessionCookiesSection } from './BrowserSessionCookiesSection' import { BrowserNewProfileDialog } from './BrowserNewProfileDialog' @@ -99,8 +100,11 @@ export function BrowserPane({ const showSearchEngine = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[1]]) const showDefaultZoom = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[2]]) const showLinkRouting = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[3]]) - const showLocalhostLabels = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[4]]) - const showCookies = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[5]]) + const showLinkRoutingModifier = matchesSettingsSearch(searchQuery, [ + getBrowserPaneSearchEntries()[4] + ]) + const showLocalhostLabels = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[5]]) + const showCookies = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[6]]) const showBrowserUse = matchesSettingsSearch(searchQuery, getBrowserUsePaneSearchEntries()) const isMac = isMacUserAgent() const linkRoutingDescription = getBrowserLinkRoutingDescription({ isMac }) @@ -238,6 +242,14 @@ export function BrowserPane({ /> ) : null} + {showLinkRoutingModifier ? ( + + ) : null} + {showLocalhostLabels ? ( { it('uses macOS shortcut symbols for Link Routing copy and search metadata', () => { @@ -41,3 +45,42 @@ describe('browser settings search copy', () => { expect(linkRoutingEntry?.keywords).not.toContain('cmd') }) }) + +describe('browser link routing modifier copy', () => { + // Why: BrowserPane gates each row on getBrowserPaneSearchEntries()[n], so a + // reordered or inserted entry silently shows the wrong row for a search. + it('keeps the search entry order BrowserPane indexes by position', () => { + expect(getBrowserPaneSearchEntries({ isMac: true }).map((entry) => entry.title)).toEqual([ + 'Default Home Page', + 'Default Search Engine', + 'Default Zoom', + 'Link Routing', + 'Hold Shift to open in Orca', + 'Localhost Worktree Labels', + 'Session & Cookies' + ]) + }) + + it('names the destination the modifier actually reaches', () => { + expect(getLinkRoutingModifierTitle(false)).toBe('Hold Shift to open in Orca') + expect(getLinkRoutingModifierTitle(true)).toBe('Hold Shift to open in your web browser') + }) + + it('describes the modifier with the platform chord', () => { + expect(getLinkRoutingModifierDescription({ openLinksInApp: false, isMac: true })).toContain( + '⇧⌘' + ) + expect(getLinkRoutingModifierDescription({ openLinksInApp: false, isMac: false })).toContain( + 'Shift+Ctrl' + ) + }) + + it('points the description at Orca only when links currently open externally', () => { + expect(getLinkRoutingModifierDescription({ openLinksInApp: false, isMac: true })).toContain( + "Orca's built-in browser" + ) + expect(getLinkRoutingModifierDescription({ openLinksInApp: true, isMac: true })).toContain( + 'system browser' + ) + }) +}) diff --git a/src/renderer/src/components/settings/browser-search.ts b/src/renderer/src/components/settings/browser-search.ts index 4cd99f7fa77..f34fdb8a6de 100644 --- a/src/renderer/src/components/settings/browser-search.ts +++ b/src/renderer/src/components/settings/browser-search.ts @@ -1,6 +1,10 @@ import type { SettingsSearchEntry } from './settings-search' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' +import { + getLinkRoutingModifierDescription, + getLinkRoutingModifierTitle +} from './browser-link-routing-modifier-copy' type BrowserShortcutPlatform = { isMac: boolean @@ -16,8 +20,11 @@ export function getBrowserLinkRoutingShortcutLabel(platform: BrowserShortcutPlat return platform.isMac ? '⇧⌘-click' : 'Shift+Ctrl+click' } +// Why: the modifier sentence moved to the nested "Hold Shift to open in …" row — +// once that row can invert the routing, "always uses your system browser" is no +// longer true, and the child row states the live destination instead. export function getBrowserLinkRoutingDescription(platform: BrowserShortcutPlatform): string { - return `Open http(s) links in Orca's built-in browser — from the terminal, markdown, and the editor. ${getBrowserLinkRoutingShortcutLabel(platform)} always uses your system browser.` + return `Open http(s) links in Orca's built-in browser — from the terminal, markdown, and the editor. ${getBrowserLinkRoutingShortcutLabel(platform)} opens a link the other way.` } export function getBrowserPaneSearchEntries( @@ -109,6 +116,35 @@ export function getBrowserPaneSearchEntries( ...translateSearchKeyword('auto.components.settings.browser.search.a7a07d5415', 'editor') ] }, + { + title: getLinkRoutingModifierTitle(false), + description: getLinkRoutingModifierDescription({ + openLinksInApp: false, + isMac: platform.isMac + }), + keywords: [ + ...translateSearchKeyword('auto.components.settings.browser.search.2d2d995c58', 'browser'), + ...translateSearchKeyword('auto.components.settings.browser.search.bea27bac4b', 'links'), + ...translateSearchKeyword('auto.components.settings.browser.search.90425d313c', 'shift'), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.linkRoutingModifier.routing', + 'routing' + ), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.linkRoutingModifier.modifier', + 'modifier' + ), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.linkRoutingModifier.invert', + 'invert' + ), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.linkRoutingModifier.opposite', + 'opposite' + ), + platform.isMac ? 'cmd' : 'ctrl' + ] + }, { title: translate( 'auto.components.settings.browser.search.19ea5607cf', diff --git a/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts b/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts index 9289f67b7b2..7f62f00ed80 100644 --- a/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts +++ b/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts @@ -76,7 +76,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -86,7 +86,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 3, y: 2 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -111,7 +111,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -121,7 +121,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 1, y: 2 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -136,7 +136,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -146,7 +146,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 2, y: 2 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(false) expect(openUrlMock).not.toHaveBeenCalled() @@ -163,7 +163,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -173,7 +173,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 2, y: 2 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(false) expect(openUrlMock).not.toHaveBeenCalled() @@ -189,7 +189,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -199,7 +199,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 11, y: 2 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -218,7 +218,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -228,7 +228,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 20, y: 2 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -245,7 +245,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 4, y: 2 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -268,7 +268,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 191, y: 1 }, cols, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -278,7 +278,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 10, y: rows.length }, cols, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -298,7 +298,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -308,7 +308,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 5, y: rowTexts.length }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -342,7 +342,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 5, y: rowTexts.length }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -361,7 +361,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -377,7 +377,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: firstRow.indexOf('https') + 8, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -394,7 +394,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 10, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(false) expect(openUrlMock).not.toHaveBeenCalled() @@ -416,7 +416,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 10, y: 1 }, cols, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() diff --git a/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts b/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts index 14fbe14aa81..067fc46f94c 100644 --- a/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts +++ b/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts @@ -80,7 +80,7 @@ function openUrlAt( openUrlMock.mockReset() const opened = openHttpLinkAtBufferPosition(buffer, { x, y }, cols, { worktreeId: 'wt-repro-8832', - forceSystemBrowser: true + modifierHeld: true }) return { opened, url: openUrlMock.mock.calls[0]?.[0] as string | undefined } } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts new file mode 100644 index 00000000000..4d802efed52 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getTerminalUrlOpenHint } from './terminal-link-open-hints' + +function stubPlatform(isMac: boolean): void { + vi.stubGlobal('navigator', { userAgent: isMac ? 'Mac OS X' : 'Windows NT 10.0' }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('getTerminalUrlOpenHint', () => { + it('keeps the system-browser wording by default', () => { + stubPlatform(true) + expect(getTerminalUrlOpenHint()).toBe('⌘+click to open or ⇧⌘+click for system browser') + }) + + it('keeps the system-browser wording when inverting is off', () => { + stubPlatform(true) + expect(getTerminalUrlOpenHint({ openLinksInApp: false, modifierInverts: false })).toContain( + 'for system browser' + ) + }) + + // Why: with links already opening in Orca, inverting still lands on the system + // browser, so the hint must not promise Orca. + it('keeps the system-browser wording when inverting but links open in Orca', () => { + stubPlatform(true) + expect(getTerminalUrlOpenHint({ openLinksInApp: true, modifierInverts: true })).toContain( + 'for system browser' + ) + }) + + it('names Orca when inverting and links open externally', () => { + stubPlatform(true) + expect(getTerminalUrlOpenHint({ openLinksInApp: false, modifierInverts: true })).toBe( + '⌘+click to open or ⇧⌘+click to open in Orca' + ) + }) + + it('uses the Ctrl chord off macOS', () => { + stubPlatform(false) + expect(getTerminalUrlOpenHint({ openLinksInApp: false, modifierInverts: true })).toBe( + 'Ctrl+click to open or Shift+Ctrl+click to open in Orca' + ) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts index 0d51669a6c6..3ecb4f9dbb4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts @@ -20,7 +20,20 @@ export function getTerminalHtmlFileOpenHint(): string { : 'Ctrl+click to open or Shift+Ctrl+click for default browser' } -export function getTerminalUrlOpenHint(): string { +export type TerminalUrlOpenHintOptions = { + openLinksInApp?: boolean + modifierInverts?: boolean +} + +// Why: with modifierInverts on, Shift no longer always means "system browser" — +// it means "the other one" — so the hint has to name the actual destination. +export function getTerminalUrlOpenHint(options: TerminalUrlOpenHintOptions = {}): string { + const invertsToOrca = options.modifierInverts === true && options.openLinksInApp !== true + if (invertsToOrca) { + return isMacPlatform() + ? '⌘+click to open or ⇧⌘+click to open in Orca' + : 'Ctrl+click to open or Shift+Ctrl+click to open in Orca' + } return isMacPlatform() ? '⌘+click to open or ⇧⌘+click for system browser' : 'Ctrl+click to open or Shift+Ctrl+click for system browser' diff --git a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts index ea1eff1b244..c51eedbd5c3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts @@ -81,7 +81,7 @@ export function handleOscLink( if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { openTerminalHttpLink(parsed.toString(), { worktreeId: deps.worktreeId, - forceSystemBrowser: Boolean(event?.shiftKey), + modifierHeld: Boolean(event?.shiftKey), requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference }) return true diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts index 3a6e2c1f274..d38ce6d9378 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts @@ -11,7 +11,7 @@ import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-termi type UrlLinkHitTestDeps = { worktreeId: string - forceSystemBrowser?: boolean + modifierHeld?: boolean requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester } @@ -225,7 +225,7 @@ export function installHttpLinkClickFallback( // never established, while defaultPrevented avoids duplicate opens. const opened = openHttpLinkAtTerminalMouseEvent(terminal, event, { worktreeId: deps.worktreeId, - forceSystemBrowser: event.shiftKey, + modifierHeld: event.shiftKey, requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference }) if (opened) { @@ -303,8 +303,10 @@ function rangeContainsBufferPosition( } export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): void { - if (deps.forceSystemBrowser) { - openHttpLink(url, { worktreeId: deps.worktreeId, forceSystemBrowser: true }) + if (deps.modifierHeld) { + // Why: the modifier states a destination outright, so it also skips the + // one-time routing prompt; openHttpLink resolves which destination it means. + openHttpLink(url, { worktreeId: deps.worktreeId, modifierHeld: true }) return } diff --git a/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts b/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts index c512cc84126..96b7d3ff003 100644 --- a/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts +++ b/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts @@ -29,7 +29,7 @@ export function handleTerminalWebLinkClick( deps.terminal && openHttpLinkAtTerminalMouseEvent(deps.terminal, event, { worktreeId: deps.worktreeId, - forceSystemBrowser: Boolean(event.shiftKey), + modifierHeld: Boolean(event.shiftKey), requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference }) ) { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index bdc8ea6300b..dce481daec7 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -778,7 +778,12 @@ export function useTerminalPaneLifecycle({ }) const fileOpenLinkHint = getTerminalFileOpenHint() - const urlOpenLinkHint = getTerminalUrlOpenHint() + // Why: read settingsRef at fire time so toggling link routing applies without recreating panes. + const getUrlOpenLinkHint = (): string => + getTerminalUrlOpenHint({ + openLinksInApp: settingsRef.current?.openLinksInApp === true, + modifierInverts: settingsRef.current?.openLinksInAppModifierInverts === true + }) const osc7UncHost = extractUncHost(startupCwd) let releaseWebviewDragPassthrough: (() => void) | null = null @@ -1100,6 +1105,7 @@ export function useTerminalPaneLifecycle({ hover: (_event, text) => { oscTooltipHoverToken += 1 const hoverToken = oscTooltipHoverToken + const urlOpenLinkHint = getUrlOpenLinkHint() pane.linkTooltip.textContent = `${text} (${urlOpenLinkHint})` pane.linkTooltip.style.display = '' void formatTerminalUrlTooltip(text, urlOpenLinkHint).then((nextText) => { @@ -1405,6 +1411,7 @@ export function useTerminalPaneLifecycle({ requestOpenLinksInAppPreference }) }, + linkOpenHint: getUrlOpenLinkHint, formatLinkTooltip: (url, openLinkHint) => formatTerminalUrlTooltip(url, openLinkHint), // Why: hidden panes stay mounted so PTYs survive navigation, but their WebGL contexts drain Chromium's budget and can blank visible panes. initialRenderingSuspended: !isVisibleRef.current, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a8e950f092d..8d2a0486a2b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9745,6 +9745,11 @@ "serviceManagerHelp": "Update Orca through the service manager that starts this server.", "unpackedHelp": "Development builds must be updated from their source checkout.", "legacyHelp": "Update this server manually once to enable remote updates." + }, + "BrowserLinkRoutingModifierSetting": { + "toggleLabel": "Toggle inverted link routing modifier", + "titleSystem": "Hold Shift to open in your web browser", + "titleOrca": "Hold Shift to open in Orca" } }, "right": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 23b52f4463b..5d604009e32 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9722,6 +9722,11 @@ "serviceManagerHelp": "Update Orca through the service manager that starts this server.", "unpackedHelp": "Development builds must be updated from their source checkout.", "legacyHelp": "Update this server manually once to enable remote updates." + }, + "BrowserLinkRoutingModifierSetting": { + "toggleLabel": "Toggle inverted link routing modifier", + "titleSystem": "Hold Shift to open in your web browser", + "titleOrca": "Hold Shift to open in Orca" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 145b4d5792b..a78ef3bd957 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9722,6 +9722,11 @@ "serviceManagerHelp": "Update Orca through the service manager that starts this server.", "unpackedHelp": "Development builds must be updated from their source checkout.", "legacyHelp": "Update this server manually once to enable remote updates." + }, + "BrowserLinkRoutingModifierSetting": { + "toggleLabel": "Toggle inverted link routing modifier", + "titleSystem": "Hold Shift to open in your web browser", + "titleOrca": "Hold Shift to open in Orca" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8522609d008..ec7196f8a16 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9722,6 +9722,11 @@ "serviceManagerHelp": "Update Orca through the service manager that starts this server.", "unpackedHelp": "Development builds must be updated from their source checkout.", "legacyHelp": "Update this server manually once to enable remote updates." + }, + "BrowserLinkRoutingModifierSetting": { + "toggleLabel": "Toggle inverted link routing modifier", + "titleSystem": "Hold Shift to open in your web browser", + "titleOrca": "Hold Shift to open in Orca" } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index efc3b530c02..355bffb9f2b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9722,6 +9722,11 @@ "serviceManagerHelp": "Update Orca through the service manager that starts this server.", "unpackedHelp": "Development builds must be updated from their source checkout.", "legacyHelp": "Update this server manually once to enable remote updates." + }, + "BrowserLinkRoutingModifierSetting": { + "toggleLabel": "Toggle inverted link routing modifier", + "titleSystem": "Hold Shift to open in your web browser", + "titleOrca": "Hold Shift to open in Orca" } }, "right": { diff --git a/src/renderer/src/lib/http-link-modifier-routing.test.ts b/src/renderer/src/lib/http-link-modifier-routing.test.ts new file mode 100644 index 00000000000..b37a900cde3 --- /dev/null +++ b/src/renderer/src/lib/http-link-modifier-routing.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { resolveModifierRouting } from './http-link-routing' + +describe('resolveModifierRouting', () => { + it('is inert without the modifier regardless of settings', () => { + for (const openLinksInApp of [true, false]) { + for (const inverts of [true, false]) { + expect(resolveModifierRouting(false, openLinksInApp, inverts)).toEqual({ + wantsOrca: false, + wantsSystemBrowser: false + }) + } + } + }) + + // Why: the setting ships off, so the historical one-way escape hatch must be + // byte-for-byte unchanged for every existing user. + it('always forces the system browser when inverting is off', () => { + expect(resolveModifierRouting(true, true, false)).toEqual({ + wantsOrca: false, + wantsSystemBrowser: true + }) + expect(resolveModifierRouting(true, false, false)).toEqual({ + wantsOrca: false, + wantsSystemBrowser: true + }) + }) + + it('still reaches the system browser when inverting and links open in Orca', () => { + expect(resolveModifierRouting(true, true, true)).toEqual({ + wantsOrca: false, + wantsSystemBrowser: true + }) + }) + + it('reaches Orca when inverting and links open in the system browser', () => { + expect(resolveModifierRouting(true, false, true)).toEqual({ + wantsOrca: true, + wantsSystemBrowser: false + }) + }) + + it('only diverges from the legacy behavior when links open externally', () => { + expect(resolveModifierRouting(true, true, true)).toEqual( + resolveModifierRouting(true, true, false) + ) + expect(resolveModifierRouting(true, false, true)).not.toEqual( + resolveModifierRouting(true, false, false) + ) + }) +}) diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts index 56ddc9b6e27..86c3bc18c54 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -16,6 +16,7 @@ const storeState = { settings: undefined as | { openLinksInApp?: boolean + openLinksInAppModifierInverts?: boolean openLinksInAppPreferencePrompted?: boolean activeRuntimeEnvironmentId?: string | null localhostWorktreeLabelsEnabled?: boolean @@ -380,3 +381,78 @@ describe('openHttpLink', () => { expect(registerLocalhostLabelMock).not.toHaveBeenCalled() }) }) + +describe('openHttpLink modifier routing', () => { + it('forces the system browser when inverting is off and links open in Orca', () => { + storeState.settings = { openLinksInApp: true, openLinksInAppModifierInverts: false } + + openHttpLink('https://example.com/', { worktreeId: 'wt-1', modifierHeld: true }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() + }) + + // Why: this is the pre-existing dead gesture — Shift already meant "system + // browser", which is where the link was going anyway. + it('stays on the system browser when inverting is off and links open externally', () => { + storeState.settings = { openLinksInApp: false, openLinksInAppModifierInverts: false } + + openHttpLink('https://example.com/', { worktreeId: 'wt-1', modifierHeld: true }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() + }) + + it('opens in Orca when inverting is on and links open externally', () => { + storeState.settings = { openLinksInApp: false, openLinksInAppModifierInverts: true } + + openHttpLink('https://example.com/', { worktreeId: 'wt-1', modifierHeld: true }) + + expect(setActiveWorktreeMock).toHaveBeenCalledWith('wt-1') + expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { + activate: true + }) + expect(openUrlMock).not.toHaveBeenCalled() + }) + + it('opens in the system browser when inverting is on and links open in Orca', () => { + storeState.settings = { openLinksInApp: true, openLinksInAppModifierInverts: true } + + openHttpLink('https://example.com/', { worktreeId: 'wt-1', modifierHeld: true }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() + }) + + it('leaves unmodified clicks on the setting', () => { + storeState.settings = { openLinksInApp: false, openLinksInAppModifierInverts: true } + + openHttpLink('https://example.com/', { worktreeId: 'wt-1' }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() + }) + + // Why: remote-owned links must never land in an Orca tab that cannot reach them. + it('never routes a remote source into Orca even when inverting', () => { + storeState.settings = { openLinksInApp: false, openLinksInAppModifierInverts: true } + + openHttpLink('https://example.com/', { + worktreeId: 'wt-1', + modifierHeld: true, + sourceOwner: { kind: 'ssh', connectionId: 'conn-1' } + }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() + }) + + it('keeps forceSystemBrowser unconditional', () => { + storeState.settings = { openLinksInApp: true, openLinksInAppModifierInverts: true } + + openHttpLink('https://example.com/', { worktreeId: 'wt-1', forceSystemBrowser: true }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts index e3a34faa463..06cc0d5235e 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -8,7 +8,10 @@ import type { WorkspacePort, WorkspacePortScanResult } from '../../../shared/wor export type OpenHttpLinkOptions = { worktreeId?: string | null + /** Unconditional: always use the system browser regardless of settings. */ forceSystemBrowser?: boolean + /** The Shift escape-hatch modifier was held; resolveModifierRouting decides what it means. */ + modifierHeld?: boolean sourceOwner?: HttpLinkSourceOwner } @@ -22,7 +25,10 @@ type StoreAccessor = () => { settings?: Partial< Pick< GlobalSettings, - 'openLinksInApp' | 'activeRuntimeEnvironmentId' | 'localhostWorktreeLabelsEnabled' + | 'openLinksInApp' + | 'openLinksInAppModifierInverts' + | 'activeRuntimeEnvironmentId' + | 'localhostWorktreeLabelsEnabled' > > | null setActiveWorktree: (worktreeId: string) => void @@ -61,21 +67,49 @@ export function registerHttpLinkStoreAccessor(fn: StoreAccessor): void { // Scope: http(s) URLs only. file: URIs and in-worktree markdown targets are // owned by resolveMarkdownLinkTarget and must stay on that path — this helper // is only invoked on target.kind === 'external' (and for the terminal's http -// branch). Shift+Cmd/Ctrl is the escape hatch: callers pass forceSystemBrowser -// to bypass the setting entirely. +// branch). Shift+Cmd/Ctrl is the escape hatch: click handlers pass modifierHeld +// so resolveModifierRouting applies it; forceSystemBrowser stays reserved for +// callers that must bypass the setting unconditionally. +/** + * Resolves what the Shift modifier means for one click. Historically it always + * forced the system browser, which is a no-op when that is already the default; + * openLinksInAppModifierInverts makes it flip whichever way Link Routing points + * so the other destination is always one click away. + */ +export function resolveModifierRouting( + modifierHeld: boolean, + openLinksInApp: boolean, + modifierInverts: boolean +): { wantsOrca: boolean; wantsSystemBrowser: boolean } { + if (!modifierHeld) { + return { wantsOrca: false, wantsSystemBrowser: false } + } + if (!modifierInverts) { + return { wantsOrca: false, wantsSystemBrowser: true } + } + return { wantsOrca: !openLinksInApp, wantsSystemBrowser: openLinksInApp } +} + export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void { - const { worktreeId, forceSystemBrowser, sourceOwner } = opts + const { worktreeId, forceSystemBrowser, modifierHeld, sourceOwner } = opts if (sourceOwner?.kind === 'unknown') { return } const state = storeAccessor?.() const remoteRuntimeActive = Boolean(state?.settings?.activeRuntimeEnvironmentId?.trim()) const sourceIsLocal = sourceOwner ? sourceOwner.kind === 'local' : !remoteRuntimeActive + const openLinksInApp = state?.settings?.openLinksInApp === true + const modifier = resolveModifierRouting( + Boolean(modifierHeld), + openLinksInApp, + state?.settings?.openLinksInAppModifierInverts === true + ) const routeToOrca = sourceIsLocal && !forceSystemBrowser && + !modifier.wantsSystemBrowser && Boolean(worktreeId) && - state?.settings?.openLinksInApp === true + (openLinksInApp || modifier.wantsOrca) if (routeToOrca && worktreeId && state) { // Why: http clicks from inside a worktree should not push a worktree-switch diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 7c2fa26fab6..59fde2ea8de 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -60,7 +60,6 @@ export function createPaneDOM( const fitAddon = new FitAddon() const searchAddon = new SearchAddon() const unicode11Addon = new Unicode11Addon() - const openLinkHint = getTerminalUrlOpenHint() // Why: async tooltip formatting can resolve after hover changes, so stale // results must not overwrite the tooltip for the currently hovered link. let linkTooltipHoverToken = 0 @@ -83,6 +82,7 @@ export function createPaneDOM( if (uri) { linkTooltipHoverToken += 1 const hoverToken = linkTooltipHoverToken + const openLinkHint = options.linkOpenHint?.() ?? getTerminalUrlOpenHint() linkTooltip.textContent = defaultLinkTooltipText(uri, openLinkHint) linkTooltip.style.display = '' const formatted = options.formatLinkTooltip?.(uri, openLinkHint) diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 2c1fd67de6d..fb53097c1f8 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -60,6 +60,8 @@ export type PaneManagerOptions = { terminalOptions?: (paneId: number) => Partial terminalTuiScrollSensitivity?: () => number | undefined onLinkClick?: (event: MouseEvent | undefined, url: string) => void + /** Resolved per hover so link-routing setting changes apply without recreating panes. */ + linkOpenHint?: () => string formatLinkTooltip?: ( url: string, openLinkHint: string diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7c11e607d96..ecbfd7594b7 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -263,6 +263,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { openLinksInApp: false, localhostWorktreeLabelsEnabled: false, openLinksInAppPreferencePrompted: false, + openLinksInAppModifierInverts: false, openAgentTabsInChatByDefault: false, experimentalNativeChat: false, nativeChatSessionOptions: {}, diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 9cbfaac20e2..f477f67a0e4 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -241,6 +241,7 @@ type BooleanGlobalSettingsKey = { export const SETTINGS_CHANGED_WHITELIST = [ 'editorAutoSave', 'openLinksInApp', + 'openLinksInAppModifierInverts', 'experimentalMobile', 'experimentalPet', 'experimentalNativeChat', diff --git a/src/shared/types.ts b/src/shared/types.ts index 2983cbf32ac..b3bcd17dc4c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2764,6 +2764,8 @@ export type GlobalSettings = { localhostWorktreeLabelsEnabled?: boolean /** Tracks the one-time first-use prompt for terminal link routing (avoid silently changing where links open). */ openLinksInAppPreferencePrompted: boolean + /** Opt-in: Shift+modifier click inverts openLinksInApp instead of always forcing the system browser. Off keeps the historical one-way escape hatch. */ + openLinksInAppModifierInverts?: boolean /** Opt-in: open new coding-agent tabs in native chat instead of the raw terminal; optional for legacy settings. */ openAgentTabsInChatByDefault?: boolean /** Experimental native chat surface for Claude/Codex sessions; off by default. */