From 2d06b6d6f93edbbf76427fb90a4b4371c780d811 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:18:09 +0000 Subject: [PATCH] fix(links): open local html file links in the browser link routing picks (#32706) --- .../native-chat-web-link-actions.ts | 7 ++-- .../local-html-file-destinations.ts | 14 ++++++++ .../terminal-file-link-actions.test.ts | 29 ++++++++++++++- .../terminal-file-link-actions.ts | 36 ++++++++++++------- .../terminal-file-open-routing.ts | 32 +++++++++++++---- .../terminal-link-click-fallback.test.ts | 1 + .../terminal-link-file-open-routing.test.ts | 28 +++++++++++++-- .../terminal-pane/terminal-link-handlers.ts | 8 ++--- .../terminal-link-open-beside-source.test.ts | 1 + .../terminal-pane/terminal-link-open-hints.ts | 21 +++++++---- .../terminal-link-osc-url-routing.test.ts | 15 ++++---- .../terminal-url-link-hit-testing.ts | 7 ++-- .../src/lib/http-link-destinations.ts | 8 +++++ 13 files changed, 158 insertions(+), 49 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/local-html-file-destinations.ts diff --git a/src/renderer/src/components/native-chat/native-chat-web-link-actions.ts b/src/renderer/src/components/native-chat/native-chat-web-link-actions.ts index 01498793536..6cedf940788 100644 --- a/src/renderer/src/components/native-chat/native-chat-web-link-actions.ts +++ b/src/renderer/src/components/native-chat/native-chat-web-link-actions.ts @@ -7,6 +7,7 @@ import { import type { TerminalLinkClickBehavior } from '@/components/terminal-pane/terminal-link-click-behavior' import { buildHttpLinkActions, + httpLinkDirectClickDestination, openRoutedHttpLink, type HttpLinkActionDestinations, type HttpLinkDestination @@ -59,11 +60,7 @@ export function handleNativeChatWebLink( if (isTerminalLinkDirectActivation(event)) { event.preventDefault() - open( - event.shiftKey - ? (deps.destinations.alternate ?? deps.destinations.primary) - : deps.destinations.primary - ) + open(httpLinkDirectClickDestination(deps.destinations, event.shiftKey)) return true } if (!isTerminalLinkActionActivation(event)) { diff --git a/src/renderer/src/components/terminal-pane/local-html-file-destinations.ts b/src/renderer/src/components/terminal-pane/local-html-file-destinations.ts new file mode 100644 index 00000000000..52e34b8d9f4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/local-html-file-destinations.ts @@ -0,0 +1,14 @@ +import { + httpLinkActionDestinationsFor, + type HttpLinkActionDestinations +} from '@/lib/http-link-destinations' +import { useAppStore } from '@/store' + +export function isHtmlFilePath(filePath: string): boolean { + return /\.html?$/i.test(filePath) +} + +/** A local HTML file is a page, so it opens where http links do. */ +export function localHtmlFileDestinations(): HttpLinkActionDestinations { + return httpLinkActionDestinationsFor(useAppStore.getState().settings, { kind: 'local' }, false) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts index 55e900c30dd..6889a710b8e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TerminalLinkActionContext } from './terminal-link-action-request' const mocks = vi.hoisted(() => { - const settings: { activeRuntimeEnvironmentId: string | null } = { + const settings: { activeRuntimeEnvironmentId: string | null; openLinksInApp?: boolean } = { activeRuntimeEnvironmentId: null } const tabGroups: Record = {} @@ -105,6 +105,33 @@ describe('terminal file link actions', () => { }) }) + it('offers a local HTML file the link-routing browsers, primary first', () => { + const request = vi.fn() + handleTerminalFileLink('/repo/report.html', null, null, plainEvent(), deps, context(request)) + mocks.settings = { activeRuntimeEnvironmentId: null, openLinksInApp: true } + handleTerminalFileLink('/repo/report.html', null, null, plainEvent(), deps, context(request)) + + const [systemFirst, orcaFirst] = request.mock.calls.map(([call]) => call) + expect([systemFirst.primary.label, systemFirst.alternate.label]).toEqual([ + 'System Browser', + 'Orca Browser' + ]) + expect([orcaFirst.primary.label, orcaFirst.alternate.label]).toEqual([ + 'Orca Browser', + 'System Browser' + ]) + systemFirst.primary.run() + systemFirst.alternate.run() + expect(mocks.openDetectedFilePath).toHaveBeenNthCalledWith(1, '/repo/report.html', null, null, { + ...deps, + browserDestination: 'system' + }) + expect(mocks.openDetectedFilePath).toHaveBeenNthCalledWith(2, '/repo/report.html', null, null, { + ...deps, + browserDestination: 'orca' + }) + }) + it('labels workspace switching and omits an impossible remote alternate', () => { mocks.worktreeRoot = true mocks.canOpenWithSystemDefault = false diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts index 7416f77d0f3..d059723f209 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts @@ -1,5 +1,6 @@ import type { LinkActionRequest } from '@/components/link-actions/link-action-request' import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' +import { buildHttpLinkActions } from '@/lib/http-link-destinations' import { getTerminalFileContext, mapTerminalFilePath, @@ -8,6 +9,7 @@ import { terminalLinkWslDistro, type FileOpenFailure } from './terminal-file-open-routing' +import { isHtmlFilePath, localHtmlFileDestinations } from './local-html-file-destinations' import { isTerminalLinkDirectActivation } from './terminal-link-activation' import { requestTerminalLinkAction, @@ -166,22 +168,30 @@ export function buildFileLinkActions( } : null const secondaryActions = [openInGroupRow, revealRow].filter((row) => row !== null) + const rows = + !worktreeRoot && canOpenWithSystemDefault && isHtmlFilePath(mappedPath) + ? buildHttpLinkActions(localHtmlFileDestinations(), (browserDestination) => + openDetectedFilePath(filePath, line, column, { ...deps, browserDestination }) + ) + : { + primary: { + label: worktreeRoot + ? translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.switchWorkspace', + 'Switch workspace' + ) + : translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.openFile', + 'Open file' + ), + run: () => openDetectedFilePath(filePath, line, column, deps) + }, + ...(systemDefaultRow ? { alternate: systemDefaultRow } : {}) + } return { destination: mappedPath, kind: worktreeRoot ? 'workspace' : 'file', - primary: { - label: worktreeRoot - ? translate( - 'auto.components.terminal.pane.TerminalLinkActionPopover.switchWorkspace', - 'Switch workspace' - ) - : translate( - 'auto.components.terminal.pane.TerminalLinkActionPopover.openFile', - 'Open file' - ), - run: () => openDetectedFilePath(filePath, line, column, deps) - }, - ...(systemDefaultRow ? { alternate: systemDefaultRow } : {}), + ...rows, ...(secondaryActions.length > 0 ? { secondaryActions } : {}) } } diff --git a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts index df301c75139..64df5b5724d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts @@ -39,6 +39,11 @@ export { type TerminalFileContext } from './terminal-file-path-mapping' import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' +import { + httpLinkDirectClickDestination, + type HttpLinkDestination +} from '@/lib/http-link-destinations' +import { isHtmlFilePath, localHtmlFileDestinations } from './local-html-file-destinations' export type FileOpenFailure = { /** `missing` is a verified absence; `unverifiable` means the host could not answer (dropped SSH, timeout, denied path). */ @@ -51,7 +56,10 @@ type TerminalFileOpenDeps = { worktreePath: string runtimeEnvironmentId?: string | null wslDistro?: string | null + /** Shift+modifier: the OS default app, or the other link-routing browser for a local HTML file. */ openWithSystemDefault?: boolean + /** Where a local HTML file opens, chosen from its popover; otherwise link routing decides. */ + browserDestination?: HttpLinkDestination /** The paired-server terminal that printed the link; lets its host grant a file outside workspaces. */ terminalHandle?: string | null /** Reports a path that could not be verified before opening; skipped once a later open supersedes it. */ @@ -62,10 +70,6 @@ type TerminalFileOpenDeps = { openInSourceGroup?: boolean } -export function isHtmlFilePath(filePath: string): boolean { - return /\.html?$/i.test(filePath) -} - function openHtmlFileInBrowser(filePath: string, worktreeId: string, clientLocal: boolean): void { const store = useAppStore.getState() if (worktreeId) { @@ -213,7 +217,21 @@ export function openDetectedFilePath( } const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault(fileContext) - if (openWithSystemDefault && canOpenWithSystemDefault) { + // Why: a remote workspace's hint and rows promise Orca before the path resolves, and an + // explicit group request falls through so the HTML branch can refuse it. + const pageDestination = + canOpenWithSystemDefault && + !clientLocal && + hostGrantId === null && + !explicitGroupRequest && + isHtmlFilePath(mappedFilePath) + ? (deps.browserDestination ?? + httpLinkDirectClickDestination(localHtmlFileDestinations(), openWithSystemDefault)) + : null + const opensWithSystemDefault = pageDestination + ? pageDestination === 'system' + : openWithSystemDefault + if (opensWithSystemDefault && canOpenWithSystemDefault) { // Why: Shift+Cmd/Ctrl mirrors URL links by escaping Orca and honoring the // user's OS file associations without adding editor-specific settings. const openedWithSystemDefault = await window.api.shell.openFilePath( @@ -243,8 +261,8 @@ export function openDetectedFilePath( return } - // Why: local HTML files render in Orca's browser for ordinary Cmd/Ctrl-click, - // and remain the fallback if Shift+Cmd/Ctrl cannot launch the OS default. + // Why: a local HTML file routed to Orca renders in its browser, which also catches a + // system-browser open the OS could not launch. if (isHtmlFilePath(mappedFilePath) && hostGrantId === null) { if (canOpenWithSystemDefault) { // Why: Orca's browser has no group placement in v1. diff --git a/src/renderer/src/components/terminal-pane/terminal-link-click-fallback.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-click-fallback.test.ts index 778dc801ac0..8c4ad0fdf04 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-click-fallback.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-click-fallback.test.ts @@ -201,6 +201,7 @@ describe('createFilePathLinkProvider range bounds', () => { it('opens a wrapped continuation-row html path from a direct modifier-click fallback', async () => { setPlatform('Macintosh') + storeState.settings = { openLinksInApp: true } const rows = [ makeBufferLine('open mobile/mock-'), makeBufferLine('homepage.html', { isWrapped: true }) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts index 79546d1a4a8..5df3efdc217 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts @@ -49,8 +49,9 @@ vi.mock('@/lib/runtime-workspace-file-route', () => ({ installTerminalLinkTestEnvironment(doubles) describe('handleOscLink', () => { - it('opens local .html file paths in Orca browser tabs with the platform modifier', async () => { + it('opens local .html file paths in Orca browser tabs when links route to Orca', async () => { setPlatform('Macintosh') + storeState.settings = { openLinksInApp: true } openDetectedFilePath('/tmp/report.html', null, null, deps) @@ -72,8 +73,9 @@ describe('handleOscLink', () => { }) }) - it('also opens local .htm paths in Orca browser tabs with the platform modifier', async () => { + it('also opens local .htm paths in Orca browser tabs when links route to Orca', async () => { setPlatform('Macintosh') + storeState.settings = { openLinksInApp: true } openDetectedFilePath('/tmp/legacy.HTM', null, null, deps) await new Promise((resolve) => setTimeout(resolve, 0)) @@ -88,6 +90,28 @@ describe('handleOscLink', () => { expect(openFilePathMock).not.toHaveBeenCalled() }) + it('opens local .html file paths in the system browser when links route there', async () => { + setPlatform('Macintosh') + storeState.settings = { openLinksInApp: false } + + openDetectedFilePath('/tmp/report.html', null, null, deps) + await flushAsyncWork() + + expect(openFilePathMock).toHaveBeenCalledWith('/tmp/report.html', 'local') + expect(createBrowserTabMock).not.toHaveBeenCalled() + + // Shift takes the other destination, as it does for http links. + openDetectedFilePath('/tmp/report.html', null, null, { ...deps, openWithSystemDefault: true }) + await flushAsyncWork() + + expect(openFilePathMock).toHaveBeenCalledTimes(1) + expect(createBrowserTabMock).toHaveBeenCalledWith( + 'wt-1', + 'file:///tmp/report.html', + expect.objectContaining({ title: 'report.html', activate: true }) + ) + }) + it('opens local file paths in Orca and reveals default column 1 with the platform modifier', async () => { setPlatform('Macintosh') diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index b7c2b0c2b7e..18317ec87c7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -8,10 +8,8 @@ import { buildCandidateLogicalLinesForBufferPosition, openFilePathLinkAtBufferPosition } from './terminal-file-link-hit-testing' -import { - isHtmlFilePath, - shouldOpenTerminalFileWithSystemDefault -} from './terminal-file-open-routing' +import { shouldOpenTerminalFileWithSystemDefault } from './terminal-file-open-routing' +import { isHtmlFilePath, localHtmlFileDestinations } from './local-html-file-destinations' import { fileLinkTargetExists, resolveFileLinkTarget } from './terminal-file-link-target' import { buildHardWrappedPathLogicalLineCandidates, @@ -180,7 +178,7 @@ export function createFilePathLinkProvider( ? getTerminalWorktreePathOpenHint(canOpenWithSystemDefault, showActions) : canOpenWithSystemDefault ? isHtmlFilePath(mappedPath) - ? getTerminalHtmlFileOpenHint(showActions) + ? getTerminalHtmlFileOpenHint(localHtmlFileDestinations(), showActions) : showActions ? openLinkHint : getTerminalFileOpenHint(false) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-beside-source.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-beside-source.test.ts index a4da27e7050..1af0d9ab0d6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-beside-source.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-beside-source.test.ts @@ -184,6 +184,7 @@ describe('terminal file links carry their source tab', () => { it('keeps automatic HTML opens on their existing browser route', async () => { setPlatform('Macintosh') + storeState.settings = { openLinksInApp: true } seedSourceTab() openDetectedFilePath('/tmp/report.html', null, null, sourceDeps) 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 1af33f4f402..886e135ac92 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 @@ -1,4 +1,7 @@ -import { canSourceOwnerOpenInOrca } from '@/lib/http-link-destinations' +import { + canSourceOwnerOpenInOrca, + type HttpLinkActionDestinations +} from '@/lib/http-link-destinations' import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' export function isMacPlatform(): boolean { @@ -23,12 +26,16 @@ export function getTerminalOrcaFileOpenHint(showActions = true): string { : `${prefix}Ctrl+click to open in Orca` } -// Why: local HTML paths keep Shift+modifier as the system-browser shortcut. -export function getTerminalHtmlFileOpenHint(showActions = true): string { - const prefix = terminalLinkActionHintPrefix(showActions) - return isMacPlatform() - ? `${prefix}⌘+click to open, or ⇧⌘+click for default browser` - : `${prefix}Ctrl+click to open, or Shift+Ctrl+click for default browser` +// Why: Shift+modifier on a local HTML file always takes the other link-routing destination. +export function getTerminalHtmlFileOpenHint( + destinations: HttpLinkActionDestinations, + showActions = true +): string { + return getTerminalUrlOpenHint({ + openLinksInApp: destinations.primary === 'orca', + modifierInverts: true, + showActions + }) } export type TerminalUrlOpenHintOptions = { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-osc-url-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-osc-url-routing.test.ts index 7fd4ffc9c6c..ac6525052a8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-osc-url-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-osc-url-routing.test.ts @@ -226,8 +226,11 @@ describe('handleOscLink', () => { expect(getTerminalFileOpenHint()).toBe( 'Click for actions, ⌘+click to open, or ⇧⌘+click for default app' ) - expect(getTerminalHtmlFileOpenHint()).toBe( - 'Click for actions, ⌘+click to open, or ⇧⌘+click for default browser' + expect(getTerminalHtmlFileOpenHint({ primary: 'system', alternate: 'orca' })).toBe( + 'Click for actions, ⌘+click to open, or ⇧⌘+click to open in Orca' + ) + expect(getTerminalHtmlFileOpenHint({ primary: 'orca', alternate: 'system' })).toBe( + 'Click for actions, ⌘+click to open, or ⇧⌘+click for system browser' ) expect(getTerminalUrlOpenHint()).toBe( 'Click for actions, ⌘+click to open, or ⇧⌘+click for system browser' @@ -237,8 +240,8 @@ describe('handleOscLink', () => { expect(getTerminalFileOpenHint()).toBe( 'Click for actions, Ctrl+click to open, or Shift+Ctrl+click for default app' ) - expect(getTerminalHtmlFileOpenHint()).toBe( - 'Click for actions, Ctrl+click to open, or Shift+Ctrl+click for default browser' + expect(getTerminalHtmlFileOpenHint({ primary: 'system', alternate: 'orca' })).toBe( + 'Click for actions, Ctrl+click to open, or Shift+Ctrl+click to open in Orca' ) expect(getTerminalUrlOpenHint()).toBe( 'Click for actions, Ctrl+click to open, or Shift+Ctrl+click for system browser' @@ -248,8 +251,8 @@ describe('handleOscLink', () => { it('omits plain-click actions from hover hints when the popover is disabled', () => { setPlatform('Macintosh') expect(getTerminalFileOpenHint(false)).toBe('⌘+click to open, or ⇧⌘+click for default app') - expect(getTerminalHtmlFileOpenHint(false)).toBe( - '⌘+click to open, or ⇧⌘+click for default browser' + expect(getTerminalHtmlFileOpenHint({ primary: 'system', alternate: 'orca' }, false)).toBe( + '⌘+click to open, or ⇧⌘+click to open in Orca' ) expect(getTerminalUrlOpenHint({ showActions: false })).toBe( '⌘+click to open, or ⇧⌘+click for system browser' 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 55301e914b3..7014f35742f 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 @@ -14,6 +14,7 @@ import { terminalLogicalLinkHasOscLink } from './terminal-osc-link-cells' import { isTerminalLinkifierHoverActive } from '@/lib/pane-manager/terminal-linkifier-hover-reset' import { buildHttpLinkActions, + httpLinkDirectClickDestination, openRoutedHttpLink, type HttpLinkActionDestinations, type HttpLinkDestination, @@ -72,9 +73,9 @@ export function handleTerminalHttpLink( deps: UrlLinkHitTestDeps ): boolean { if (isTerminalHttpLinkActivation(event)) { - const forceDestination = event?.shiftKey - ? (deps.actionDestinations?.alternate ?? deps.actionDestinations?.primary) - : deps.actionDestinations?.primary + const forceDestination = + deps.actionDestinations && + httpLinkDirectClickDestination(deps.actionDestinations, Boolean(event?.shiftKey)) openRoutedHttpLink(url, { ...deps, modifierHeld: forceDestination ? false : Boolean(event?.shiftKey), diff --git a/src/renderer/src/lib/http-link-destinations.ts b/src/renderer/src/lib/http-link-destinations.ts index 8fecfe99cc8..f05093dc8f1 100644 --- a/src/renderer/src/lib/http-link-destinations.ts +++ b/src/renderer/src/lib/http-link-destinations.ts @@ -42,6 +42,14 @@ export function httpLinkActionDestinationsFor( : { primary: 'system', alternate: 'orca' } } +/** A modifier click opens the primary destination; Shift takes the other one. */ +export function httpLinkDirectClickDestination( + destinations: HttpLinkActionDestinations, + shiftKey: boolean +): HttpLinkDestination { + return shiftKey ? (destinations.alternate ?? destinations.primary) : destinations.primary +} + export function httpLinkDestinationLabel(destination: HttpLinkDestination): string { return destination === 'orca' ? translate(