mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
feat(browser): let Shift invert link routing instead of always forcing the system browser
Shift+Cmd/Ctrl-click has always meant "open in the system browser", which is a no-op when that is already where links go. Users who keep Link Routing off have had no gesture to pull a single link into Orca's built-in browser. Adds "Hold Shift to open in ___", a nested toggle under Link Routing that makes the modifier open a link the opposite way from the setting. It ships off, so the one-way escape hatch is unchanged for every existing user. The title and description name the destination the modifier actually reaches and flip with the parent setting, since "the opposite" is meaningless on its own. - openHttpLink gains modifierHeld; resolveModifierRouting owns the decision so every surface (terminal URLs, OSC 8, xterm web links, markdown preview) routes identically. forceSystemBrowser stays for callers that must bypass settings. - The terminal hover hint names Orca when the modifier would open there, and is re-resolved per hover so toggling applies without recreating panes. - Link Routing's own copy drops "always uses your system browser", which the new toggle can falsify; the nested row states the live destination instead.
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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<MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>,
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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<GlobalSettings, 'openLinksInApp' | 'openLinksInAppModifierInverts'>
|
||||
isMac: boolean
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => 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 (
|
||||
<SearchableSetting
|
||||
title={title}
|
||||
description={description}
|
||||
keywords={[
|
||||
'browser',
|
||||
'links',
|
||||
'routing',
|
||||
'shift',
|
||||
'modifier',
|
||||
'invert',
|
||||
'opposite',
|
||||
isMac ? 'cmd' : 'ctrl'
|
||||
]}
|
||||
>
|
||||
{/* Nested under Link Routing: this row only describes what its modifier does. */}
|
||||
<div className="ml-4 border-l border-border pl-4">
|
||||
<SettingsSwitchRow
|
||||
label={title}
|
||||
description={description}
|
||||
checked={settings.openLinksInAppModifierInverts === true}
|
||||
onChange={() =>
|
||||
updateSettings({
|
||||
openLinksInAppModifierInverts: settings.openLinksInAppModifierInverts !== true
|
||||
})
|
||||
}
|
||||
ariaLabel={translate(
|
||||
'auto.components.settings.BrowserLinkRoutingModifierSetting.toggleLabel',
|
||||
'Toggle inverted link routing modifier'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
)
|
||||
}
|
||||
@@ -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 ? (
|
||||
<BrowserLinkRoutingModifierSetting
|
||||
settings={settings}
|
||||
isMac={isMac}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showLocalhostLabels ? (
|
||||
<BrowserLocalhostWorktreeLabelsSetting
|
||||
settings={settings}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
/**
|
||||
* Title and description both name the destination the modifier reaches, which is
|
||||
* the opposite of wherever Link Routing points. Kept out of the component so the
|
||||
* settings-search index and the component render identical strings.
|
||||
*/
|
||||
export function getLinkRoutingModifierTitle(openLinksInApp: boolean): string {
|
||||
return openLinksInApp
|
||||
? translate(
|
||||
'auto.components.settings.BrowserLinkRoutingModifierSetting.titleSystem',
|
||||
'Hold Shift to open in your web browser'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.BrowserLinkRoutingModifierSetting.titleOrca',
|
||||
'Hold Shift to open in Orca'
|
||||
)
|
||||
}
|
||||
|
||||
export function getLinkRoutingModifierDescription({
|
||||
openLinksInApp,
|
||||
isMac
|
||||
}: {
|
||||
openLinksInApp: boolean
|
||||
isMac: boolean
|
||||
}): string {
|
||||
const chord = isMac ? '⇧⌘' : 'Shift+Ctrl'
|
||||
return openLinksInApp
|
||||
? `Links open in Orca, so ${chord}+click sends one to your system browser instead.`
|
||||
: `Links open in your system browser, so ${chord}+click opens one in Orca's built-in browser instead.`
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
getBrowserLinkRoutingShortcutLabel,
|
||||
getBrowserPaneSearchEntries
|
||||
} from './browser-search'
|
||||
import {
|
||||
getLinkRoutingModifierDescription,
|
||||
getLinkRoutingModifierTitle
|
||||
} from './browser-link-routing-modifier-copy'
|
||||
|
||||
describe('browser settings search copy', () => {
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
+22
-22
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -60,6 +60,8 @@ export type PaneManagerOptions = {
|
||||
terminalOptions?: (paneId: number) => Partial<ITerminalOptions>
|
||||
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
|
||||
|
||||
@@ -263,6 +263,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
||||
openLinksInApp: false,
|
||||
localhostWorktreeLabelsEnabled: false,
|
||||
openLinksInAppPreferencePrompted: false,
|
||||
openLinksInAppModifierInverts: false,
|
||||
openAgentTabsInChatByDefault: false,
|
||||
experimentalNativeChat: false,
|
||||
nativeChatSessionOptions: {},
|
||||
|
||||
@@ -241,6 +241,7 @@ type BooleanGlobalSettingsKey = {
|
||||
export const SETTINGS_CHANGED_WHITELIST = [
|
||||
'editorAutoSave',
|
||||
'openLinksInApp',
|
||||
'openLinksInAppModifierInverts',
|
||||
'experimentalMobile',
|
||||
'experimentalPet',
|
||||
'experimentalNativeChat',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user