From db5325204fcfd0aa30a8f4f4fc7675d7f79121fe Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:27:58 -0700 Subject: [PATCH] Route terminal links based on pane ownership, not global state (#12233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): route remote-runtime link clicks to the system browser Terminal link clicks classified ownership from the global activeRuntimeEnvironmentId, which is null when runtimes are bound per workspace, so a link clicked in a remote-hosted pane opened a local-only Orca browser tab and never reached the host. Thread each pane's resolved runtimeEnvironmentId into openHttpLink as sourceOwner across the OSC 8, WebLinksAddon, and click-fallback paths. Co-authored-by: Orca * fix(terminal): route link clicks based on pane ownership, not global sta Clicking links on remote-hosted panes was routing based on global runtime state, causing unexpected reconnections. Now link routing decisions (where to open: Orca vs system browser) are based on the actual pane's owner — local, SSH connection, remote runtime, or unknown — regardless of whether any runtime is globally active. This ensures a local pane can route to Orca while another pane's remote runtime is active, and a remote pane always routes to the system browser. --------- Co-authored-by: Orca --- .../terminal-http-link-source-owner.test.ts | 64 +++++ .../terminal-http-link-source-owner.ts | 46 ++++ .../terminal-http-url-extraction.ts | 156 ++++++++++++ .../terminal-link-open-hints.test.ts | 39 +++ .../terminal-pane/terminal-link-open-hints.ts | 18 +- .../terminal-osc-link-routing.ts | 7 + ...terminal-runtime-host-link-routing.test.ts | 238 ++++++++++++++++++ .../terminal-url-link-hit-testing.ts | 184 ++------------ .../terminal-pane/terminal-web-link-click.ts | 7 + .../use-terminal-pane-lifecycle.ts | 40 ++- .../lib/http-link-modifier-routing.test.ts | 67 ++++- .../src/lib/http-link-routing.test.ts | 98 ++++++++ src/renderer/src/lib/http-link-routing.ts | 9 +- .../pane-manager/pane-dom-creation.test.ts | 22 ++ .../src/lib/pane-manager/pane-dom-creation.ts | 4 +- .../lib/pane-manager/pane-manager-types.ts | 4 +- 16 files changed, 816 insertions(+), 187 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.test.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.test.ts new file mode 100644 index 00000000000..330560c57a6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { resolveTerminalHttpLinkSourceOwner } from './terminal-http-link-source-owner' + +function transport( + ptyId: string | null, + runtimeEnvironmentId?: string | null, + connectionId?: string | null +) { + return { + getPtyId: () => ptyId, + getRuntimeEnvironmentId: () => runtimeEnvironmentId ?? null, + getConnectionId: () => connectionId ?? null + } +} + +describe('resolveTerminalHttpLinkSourceOwner', () => { + it('keeps ordinary local PTYs local', () => { + expect(resolveTerminalHttpLinkSourceOwner(transport('local-pty'))).toEqual({ kind: 'local' }) + }) + + it('classifies direct SSH PTYs by their embedded connection', () => { + expect(resolveTerminalHttpLinkSourceOwner(transport(toAppSshPtyId('ssh-1', 'pty-2')))).toEqual({ + kind: 'ssh', + connectionId: 'ssh-1' + }) + }) + + it('keeps direct SSH ownership while its PTY id is unavailable', () => { + expect(resolveTerminalHttpLinkSourceOwner(transport(null, null, 'ssh-recovering'))).toEqual({ + kind: 'ssh', + connectionId: 'ssh-recovering' + }) + }) + + it('prefers the transport runtime owner while its recovery PTY id is null', () => { + expect(resolveTerminalHttpLinkSourceOwner(transport(null, 'env-recovering'))).toEqual({ + kind: 'runtime', + runtimeEnvironmentId: 'env-recovering' + }) + }) + + it('uses the retained runtime owner for legacy ownerless remote PTY ids', () => { + expect( + resolveTerminalHttpLinkSourceOwner(transport('remote:legacy-handle', 'env-legacy')) + ).toEqual({ + kind: 'runtime', + runtimeEnvironmentId: 'env-legacy' + }) + }) + + it('falls back to the owner encoded in current remote PTY ids', () => { + expect( + resolveTerminalHttpLinkSourceOwner(transport(toRemoteRuntimePtyId('handle-1', 'env-encoded'))) + ).toEqual({ kind: 'runtime', runtimeEnvironmentId: 'env-encoded' }) + }) + + it('does not classify an ownerless remote PTY as local without retained ownership', () => { + expect(resolveTerminalHttpLinkSourceOwner(transport('remote:legacy-handle'))).toEqual({ + kind: 'unknown' + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.ts new file mode 100644 index 00000000000..31b0479ee51 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.ts @@ -0,0 +1,46 @@ +import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' +import { + getRemoteRuntimePtyEnvironmentId, + parseRemoteRuntimePtyId +} from '@/runtime/runtime-terminal-stream' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { PtyTransport } from './pty-transport-types' + +type OwnerTransport = Pick + +export function resolveTerminalHttpLinkSourceOwner( + transport: OwnerTransport | null | undefined +): HttpLinkSourceOwner { + const retainedRuntimeEnvironmentId = transport?.getRuntimeEnvironmentId?.()?.trim() + if (retainedRuntimeEnvironmentId) { + return { kind: 'runtime', runtimeEnvironmentId: retainedRuntimeEnvironmentId } + } + + const ptyId = transport?.getPtyId() ?? null + const retainedSshConnectionId = transport?.getConnectionId?.()?.trim() + if (!ptyId) { + return retainedSshConnectionId + ? { kind: 'ssh', connectionId: retainedSshConnectionId } + : { kind: 'local' } + } + + const runtimeEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) + if (runtimeEnvironmentId) { + return { kind: 'runtime', runtimeEnvironmentId } + } + + const sshPty = parseAppSshPtyId(ptyId) + if (sshPty) { + return { kind: 'ssh', connectionId: sshPty.connectionId } + } + + if (retainedSshConnectionId) { + return { kind: 'ssh', connectionId: retainedSshConnectionId } + } + + // Why: legacy remote ids without a retained transport owner are not evidence of local ownership. + if (parseRemoteRuntimePtyId(ptyId)) { + return { kind: 'unknown' } + } + return { kind: 'local' } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts b/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts new file mode 100644 index 00000000000..192c4db15e1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts @@ -0,0 +1,156 @@ +import { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits' + +type ParsedTerminalHttpLink = { + url: string + startIndex: number + endIndex: number +} + +const HTTP_SCHEME_PREFIXES = ['https://', 'http://'] as const + +export function extractTerminalHttpLinks(lineText: string): ParsedTerminalHttpLink[] { + const links: ParsedTerminalHttpLink[] = [] + for (const candidate of iterateTerminalHttpUrlCandidates(lineText)) { + let parsed: URL + try { + parsed = new URL(candidate.url) + } catch { + continue + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + continue + } + links.push({ + url: parsed.toString(), + startIndex: candidate.startIndex, + endIndex: candidate.endIndex + }) + } + return links +} + +function* iterateTerminalHttpUrlCandidates( + lineText: string +): Generator<{ url: string; startIndex: number; endIndex: number }> { + let searchStart = 0 + while (searchStart < lineText.length) { + const startIndex = findNextHttpSchemeIndex(lineText, searchStart) + if (startIndex === -1) { + return + } + + if (!hasHttpUrlWordBoundary(lineText, startIndex)) { + searchStart = startIndex + 1 + continue + } + + const rawEndIndex = findHttpUrlCandidateEnd(lineText, startIndex) + const endIndex = trimHttpUrlTrailingPunctuation(lineText, startIndex, rawEndIndex) + searchStart = Math.max(rawEndIndex, startIndex + 1) + if (endIndex <= startIndex || rawEndIndex - startIndex > TERMINAL_HTTP_URL_MAX_LENGTH) { + continue + } + + yield { + url: lineText.slice(startIndex, endIndex), + startIndex, + endIndex + } + } +} + +function findNextHttpSchemeIndex(lineText: string, searchStart: number): number { + let nextIndex = -1 + for (const prefix of HTTP_SCHEME_PREFIXES) { + const candidateIndex = lineText.indexOf(prefix, searchStart) + if (candidateIndex !== -1 && (nextIndex === -1 || candidateIndex < nextIndex)) { + nextIndex = candidateIndex + } + } + return nextIndex +} + +function hasHttpUrlWordBoundary(lineText: string, startIndex: number): boolean { + return startIndex === 0 || !isAsciiWordCode(lineText.charCodeAt(startIndex - 1)) +} + +function findHttpUrlCandidateEnd(lineText: string, startIndex: number): number { + const scanEnd = Math.min(lineText.length, startIndex + TERMINAL_HTTP_URL_MAX_LENGTH + 1) + for (let index = startIndex; index < scanEnd; index += 1) { + if (isHttpUrlBodyTerminator(lineText.charCodeAt(index))) { + return index + } + } + return scanEnd +} + +function trimHttpUrlTrailingPunctuation( + lineText: string, + startIndex: number, + rawEndIndex: number +): number { + let endIndex = rawEndIndex + while (endIndex > startIndex && isHttpUrlTrailingPunctuation(lineText.charCodeAt(endIndex - 1))) { + endIndex -= 1 + } + return endIndex +} + +function isHttpUrlBodyTerminator(code: number): boolean { + return ( + isAsciiWhitespace(code) || + code === 0x22 || + code === 0x27 || + code === 0x21 || + code === 0x2a || + code === 0x28 || + code === 0x29 || + code === 0x7b || + code === 0x7d || + code === 0x7c || + code === 0x5c || + code === 0x5e || + code === 0x3c || + code === 0x3e || + code === 0x60 + ) +} + +function isHttpUrlTrailingPunctuation(code: number): boolean { + return ( + isAsciiWhitespace(code) || + code === 0x22 || + code === 0x27 || + code === 0x3a || + code === 0x2c || + code === 0x2e || + code === 0x21 || + code === 0x3f || + code === 0x7b || + code === 0x7d || + code === 0x7c || + code === 0x5c || + code === 0x5e || + code === 0x7e || + code === 0x5b || + code === 0x5d || + code === 0x28 || + code === 0x29 || + code === 0x3c || + code === 0x3e || + code === 0x60 + ) +} + +function isAsciiWhitespace(code: number): boolean { + return code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32 +} + +function isAsciiWordCode(code: number): boolean { + return ( + (code >= 48 && code <= 57) || + (code >= 65 && code <= 90) || + code === 95 || + (code >= 97 && code <= 122) + ) +} 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 index 656db8d4bfd..890a8f3f4c3 100644 --- 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 @@ -86,4 +86,43 @@ describe('terminalUrlOpenHintOptionsFor', () => { modifierInverts: false }) }) + + // Why: a workspace-bound remote pane routes externally even with no globally + // active runtime, so the global setting alone would advertise an impossible + // "open in Orca" destination. + it.each([ + ['runtime', { kind: 'runtime', runtimeEnvironmentId: 'env-1' }] as const, + ['ssh', { kind: 'ssh', connectionId: 'conn-1' }] as const, + ['unknown', { kind: 'unknown' }] as const + ])('drops inversion for a %s-owned pane without an active runtime', (_kind, sourceOwner) => { + stubPlatform(true) + const options = terminalUrlOpenHintOptionsFor( + { + openLinksInApp: false, + openLinksInAppModifierInverts: true, + activeRuntimeEnvironmentId: null + }, + sourceOwner + ) + + expect(options.modifierInverts).toBe(false) + expect(getTerminalUrlOpenHint(options)).toContain('for system browser') + }) + + // Why: the clicked pane's owner wins over the global runtime — a local pane + // can still reach Orca while some other pane's runtime is active. + it('keeps inversion for a local pane while a remote runtime is active', () => { + stubPlatform(true) + const options = terminalUrlOpenHintOptionsFor( + { + openLinksInApp: false, + openLinksInAppModifierInverts: true, + activeRuntimeEnvironmentId: 'remote-1' + }, + { kind: 'local' } + ) + + expect(options.modifierInverts).toBe(true) + expect(getTerminalUrlOpenHint(options)).toContain('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 0bdf05c6108..a069ad03f44 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,3 +1,5 @@ +import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' + export function isMacPlatform(): boolean { return navigator.userAgent.includes('Mac') } @@ -25,8 +27,10 @@ export type TerminalUrlOpenHintOptions = { modifierInverts?: boolean } -// Why: openHttpLink only routes to Orca when the source is local, so a remote runtime -// pins every link to the system browser and inverting cannot reach Orca there. +// Why: openHttpLink only routes to Orca when the source is local, so a remote pane +// pins every link to the system browser and inverting cannot reach Orca there. The +// clicked pane's owner decides that, not the global active runtime — a workspace-bound +// remote pane is remote even when no runtime is globally active. export function terminalUrlOpenHintOptionsFor( settings: | { @@ -35,13 +39,15 @@ export function terminalUrlOpenHintOptionsFor( activeRuntimeEnvironmentId?: string | null } | null - | undefined + | undefined, + sourceOwner?: HttpLinkSourceOwner ): TerminalUrlOpenHintOptions { + const sourceIsLocal = sourceOwner + ? sourceOwner.kind === 'local' + : !settings?.activeRuntimeEnvironmentId?.trim() return { openLinksInApp: settings?.openLinksInApp === true, - modifierInverts: - settings?.openLinksInAppModifierInverts === true && - !settings?.activeRuntimeEnvironmentId?.trim() + modifierInverts: settings?.openLinksInAppModifierInverts === true && sourceIsLocal } } 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 c51eedbd5c3..e5dbe2b1374 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 @@ -8,6 +8,7 @@ import { openTerminalHttpLink, type TerminalLinkRoutingPreferenceRequester } from './terminal-url-link-hit-testing' +import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' type TerminalLinkEvent = Pick & Partial> @@ -29,6 +30,7 @@ export function handleOscLink( event: TerminalLinkEvent | undefined, deps: Pick & Partial> & { + sourceOwner?: HttpLinkSourceOwner requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester } ): boolean { @@ -81,6 +83,11 @@ export function handleOscLink( if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { openTerminalHttpLink(parsed.toString(), { worktreeId: deps.worktreeId, + sourceOwner: + deps.sourceOwner ?? + (deps.runtimeEnvironmentId + ? { kind: 'runtime', runtimeEnvironmentId: deps.runtimeEnvironmentId } + : { kind: 'local' }), modifierHeld: Boolean(event?.shiftKey), requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference }) diff --git a/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts new file mode 100644 index 00000000000..0113ea31a3d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts @@ -0,0 +1,238 @@ +import type { IBufferLine, Terminal } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' +import { handleOscLink } from './terminal-osc-link-routing' +import { handleTerminalWebLinkClick } from './terminal-web-link-click' +import { installHttpLinkClickFallback } from './terminal-url-link-hit-testing' + +const URL = 'http://example.com/' +const COLS = 80 +const ROWS = 24 + +const openUrlMock = vi.fn() +const setActiveWorktreeMock = vi.fn() +const createBrowserTabMock = vi.fn() +const runtimeSourceOwner = { kind: 'runtime', runtimeEnvironmentId: 'env-1' } as const +const sshSourceOwner = { kind: 'ssh', connectionId: 'ssh-1' } as const + +type ListenerRegistration = [string, EventListener, AddEventListenerOptions | boolean | undefined] + +function makeBufferLine(text: string): IBufferLine { + const padded = text.padEnd(COLS) + return { + isWrapped: false, + length: COLS, + translateToString: ( + _trimRight?: boolean, + startColumn = 0, + endColumn = padded.length, + outColumns?: number[] + ) => { + if (outColumns) { + outColumns.splice( + 0, + outColumns.length, + ...Array.from( + { length: endColumn - startColumn + 1 }, + (_value, index) => index + startColumn + ) + ) + } + return padded.slice(startColumn, endColumn) + } + } as IBufferLine +} + +function makeTerminal(): { terminal: Terminal; registrations: ListenerRegistration[] } { + const registrations: ListenerRegistration[] = [] + const screen = { + getBoundingClientRect: () => ({ left: 0, top: 0, width: COLS * 10, height: ROWS * 10 }) + } + return { + terminal: { + cols: COLS, + rows: ROWS, + options: { mouseEventsRequireAlt: false }, + element: { + ownerDocument: { + defaultView: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }, + querySelector: vi.fn(() => screen), + addEventListener: vi.fn( + (name: string, listener: EventListener, options?: AddEventListenerOptions | boolean) => { + registrations.push([name, listener, options]) + } + ), + removeEventListener: vi.fn() + }, + buffer: { + active: { + viewportY: 0, + getLine: (y: number) => (y === 0 ? makeBufferLine(URL) : undefined) + } + }, + clearSelection: vi.fn() + } as unknown as Terminal, + registrations + } +} + +function clickEvent(): MouseEvent { + return { + button: 0, + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + defaultPrevented: false, + clientX: 15, + clientY: 5, + preventDefault: vi.fn() + } as unknown as MouseEvent +} + +// Why: runtimes bind per workspace, so the global activeRuntimeEnvironmentId is +// null even while the clicked pane lives on a remote host. +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } }) + registerHttpLinkStoreAccessor(() => ({ + settings: { openLinksInApp: true, activeRuntimeEnvironmentId: null }, + setActiveWorktree: setActiveWorktreeMock, + createBrowserTab: createBrowserTabMock + })) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('terminal HTTP links on a runtime-hosted pane', () => { + const baseDeps = { worktreeId: 'wt-1', worktreePath: '/tmp', startupCwd: '/tmp' } + + it('sends an OSC 8 hyperlink to the system browser', () => { + expect(handleOscLink(URL, clickEvent(), { ...baseDeps, sourceOwner: runtimeSourceOwner })).toBe( + true + ) + + expect(openUrlMock).toHaveBeenCalledWith(URL) + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(setActiveWorktreeMock).not.toHaveBeenCalled() + }) + + it('sends a WebLinksAddon click to the system browser', () => { + const { terminal } = makeTerminal() + + expect( + handleTerminalWebLinkClick(URL, clickEvent(), { + ...baseDeps, + terminal, + sourceOwner: runtimeSourceOwner + }) + ).toBe(true) + + expect(openUrlMock).toHaveBeenCalledWith(URL) + expect(createBrowserTabMock).not.toHaveBeenCalled() + }) + + it('sends a click-fallback activation to the system browser', () => { + const { terminal, registrations } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { + worktreeId: 'wt-1', + getSourceOwner: () => runtimeSourceOwner + }) + + registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options === undefined + )?.[1](clickEvent()) + + expect(openUrlMock).toHaveBeenCalledWith(URL) + expect(createBrowserTabMock).not.toHaveBeenCalled() + disposable.dispose() + }) + + it('never prompts for the in-app routing preference it could not honor', () => { + const requestOpenLinksInAppPreference = vi.fn(() => Promise.resolve(true)) + + handleOscLink(URL, clickEvent(), { + ...baseDeps, + sourceOwner: runtimeSourceOwner, + requestOpenLinksInAppPreference + }) + + expect(requestOpenLinksInAppPreference).not.toHaveBeenCalled() + expect(openUrlMock).toHaveBeenCalledWith(URL) + }) +}) + +describe('terminal HTTP links on a direct SSH pane', () => { + const baseDeps = { worktreeId: 'wt-1', worktreePath: '/tmp', startupCwd: '/tmp' } + + it('sends an OSC 8 hyperlink to the system browser', () => { + expect(handleOscLink(URL, clickEvent(), { ...baseDeps, sourceOwner: sshSourceOwner })).toBe( + true + ) + + expect(openUrlMock).toHaveBeenCalledWith(URL) + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(setActiveWorktreeMock).not.toHaveBeenCalled() + }) + + it('sends a WebLinksAddon click to the system browser', () => { + const { terminal } = makeTerminal() + + expect( + handleTerminalWebLinkClick(URL, clickEvent(), { + ...baseDeps, + terminal, + sourceOwner: sshSourceOwner + }) + ).toBe(true) + + expect(openUrlMock).toHaveBeenCalledWith(URL) + expect(createBrowserTabMock).not.toHaveBeenCalled() + }) + + it('sends a click-fallback activation to the system browser', () => { + const { terminal, registrations } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { + worktreeId: 'wt-1', + getSourceOwner: () => sshSourceOwner + }) + + registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options === undefined + )?.[1](clickEvent()) + + expect(openUrlMock).toHaveBeenCalledWith(URL) + expect(createBrowserTabMock).not.toHaveBeenCalled() + disposable.dispose() + }) +}) + +describe('terminal HTTP links on a local pane', () => { + const baseDeps = { worktreeId: 'wt-1', worktreePath: '/tmp', startupCwd: '/tmp' } + + it('still opens an OSC 8 hyperlink in an Orca browser tab', () => { + expect(handleOscLink(URL, clickEvent(), { ...baseDeps, runtimeEnvironmentId: null })).toBe(true) + + expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', URL, { activate: true }) + expect(openUrlMock).not.toHaveBeenCalled() + }) + + it('still opens a click-fallback activation in an Orca browser tab', () => { + const { terminal, registrations } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + + registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options === undefined + )?.[1](clickEvent()) + + expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', URL, { activate: true }) + expect(openUrlMock).not.toHaveBeenCalled() + disposable.dispose() + }) +}) 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 277fee7fe15..d173157ea65 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 @@ -1,23 +1,29 @@ import type { IBufferLine, IBufferRange, IDisposable, Terminal } from '@xterm/xterm' -import { openHttpLink } from '@/lib/http-link-routing' +import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing' import { buildEdgeWrappedHttpLogicalLineCandidates } from './edge-wrapped-terminal-http-links' import { buildHardWrappedHttpLogicalLineCandidates } from './hard-wrapped-terminal-http-links' import { dedupeLogicalLines } from './terminal-file-link-hit-testing' import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' import { installTerminalLinkPtyMouseSuppression } from './terminal-link-pty-mouse-suppression' import { getTerminalBufferPositionForMouseEvent } from './terminal-mouse-buffer-position' -import { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits' +import { extractTerminalHttpLinks } from './terminal-http-url-extraction' import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges' import { isTerminalLinkifierHoverActive } from '@/lib/pane-manager/terminal-linkifier-hover-reset' +export { extractTerminalHttpLinks } from './terminal-http-url-extraction' +export { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits' + type UrlLinkHitTestDeps = { worktreeId: string + sourceOwner?: HttpLinkSourceOwner modifierHeld?: boolean requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester } type UrlLinkClickFallbackDeps = { worktreeId: string + /** Resolved per click: the pane's PTY (and its runtime binding) may not exist at install time. */ + getSourceOwner?: () => HttpLinkSourceOwner requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester } @@ -25,36 +31,6 @@ export type TerminalLinkRoutingPreferenceRequester = ( url: string ) => boolean | Promise | null | undefined -type ParsedTerminalHttpLink = { - url: string - startIndex: number - endIndex: number -} - -const HTTP_SCHEME_PREFIXES = ['https://', 'http://'] as const -export { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits' - -export function extractTerminalHttpLinks(lineText: string): ParsedTerminalHttpLink[] { - const links: ParsedTerminalHttpLink[] = [] - for (const candidate of iterateTerminalHttpUrlCandidates(lineText)) { - let parsed: URL - try { - parsed = new URL(candidate.url) - } catch { - continue - } - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - continue - } - links.push({ - url: parsed.toString(), - startIndex: candidate.startIndex, - endIndex: candidate.endIndex - }) - } - return links -} - function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean { if (event.defaultPrevented || event.button !== 0) { return false @@ -65,132 +41,6 @@ function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean { return isTerminalHttpLinkActivation(event) } -function* iterateTerminalHttpUrlCandidates( - lineText: string -): Generator<{ url: string; startIndex: number; endIndex: number }> { - let searchStart = 0 - while (searchStart < lineText.length) { - const startIndex = findNextHttpSchemeIndex(lineText, searchStart) - if (startIndex === -1) { - return - } - - if (!hasHttpUrlWordBoundary(lineText, startIndex)) { - searchStart = startIndex + 1 - continue - } - - const rawEndIndex = findHttpUrlCandidateEnd(lineText, startIndex) - const endIndex = trimHttpUrlTrailingPunctuation(lineText, startIndex, rawEndIndex) - searchStart = Math.max(rawEndIndex, startIndex + 1) - if (endIndex <= startIndex || rawEndIndex - startIndex > TERMINAL_HTTP_URL_MAX_LENGTH) { - continue - } - - yield { - url: lineText.slice(startIndex, endIndex), - startIndex, - endIndex - } - } -} - -function findNextHttpSchemeIndex(lineText: string, searchStart: number): number { - let nextIndex = -1 - for (const prefix of HTTP_SCHEME_PREFIXES) { - const candidateIndex = lineText.indexOf(prefix, searchStart) - if (candidateIndex !== -1 && (nextIndex === -1 || candidateIndex < nextIndex)) { - nextIndex = candidateIndex - } - } - return nextIndex -} - -function hasHttpUrlWordBoundary(lineText: string, startIndex: number): boolean { - return startIndex === 0 || !isAsciiWordCode(lineText.charCodeAt(startIndex - 1)) -} - -function findHttpUrlCandidateEnd(lineText: string, startIndex: number): number { - const scanEnd = Math.min(lineText.length, startIndex + TERMINAL_HTTP_URL_MAX_LENGTH + 1) - for (let index = startIndex; index < scanEnd; index += 1) { - if (isHttpUrlBodyTerminator(lineText.charCodeAt(index))) { - return index - } - } - return scanEnd -} - -function trimHttpUrlTrailingPunctuation( - lineText: string, - startIndex: number, - rawEndIndex: number -): number { - let endIndex = rawEndIndex - while (endIndex > startIndex && isHttpUrlTrailingPunctuation(lineText.charCodeAt(endIndex - 1))) { - endIndex -= 1 - } - return endIndex -} - -function isHttpUrlBodyTerminator(code: number): boolean { - return ( - isAsciiWhitespace(code) || - code === 0x22 || - code === 0x27 || - code === 0x21 || - code === 0x2a || - code === 0x28 || - code === 0x29 || - code === 0x7b || - code === 0x7d || - code === 0x7c || - code === 0x5c || - code === 0x5e || - code === 0x3c || - code === 0x3e || - code === 0x60 - ) -} - -function isHttpUrlTrailingPunctuation(code: number): boolean { - return ( - isAsciiWhitespace(code) || - code === 0x22 || - code === 0x27 || - code === 0x3a || - code === 0x2c || - code === 0x2e || - code === 0x21 || - code === 0x3f || - code === 0x7b || - code === 0x7d || - code === 0x7c || - code === 0x5c || - code === 0x5e || - code === 0x7e || - code === 0x5b || - code === 0x5d || - code === 0x28 || - code === 0x29 || - code === 0x3c || - code === 0x3e || - code === 0x60 - ) -} - -function isAsciiWhitespace(code: number): boolean { - return code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32 -} - -function isAsciiWordCode(code: number): boolean { - return ( - (code >= 48 && code <= 57) || - (code >= 65 && code <= 90) || - code === 95 || - (code >= 97 && code <= 122) - ) -} - export function openHttpLinkAtTerminalMouseEvent( terminal: Terminal, event: MouseEvent, @@ -229,6 +79,7 @@ export function installHttpLinkClickFallback( // never established, while defaultPrevented avoids duplicate opens. const opened = openHttpLinkAtTerminalMouseEvent(terminal, event, { worktreeId: deps.worktreeId, + sourceOwner: deps.getSourceOwner?.() ?? { kind: 'local' }, modifierHeld: event.shiftKey, requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference }) @@ -307,16 +158,22 @@ function rangeContainsBufferPosition( } export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): void { + // Why: Orca browser tabs are local-only, so a link clicked in a runtime-hosted + // pane must be classified by its pane's host, not the global active runtime. + const sourceOwner = deps.sourceOwner ?? { kind: 'local' } 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 }) + openHttpLink(url, { worktreeId: deps.worktreeId, modifierHeld: true, sourceOwner }) return } - const preferenceDecision = deps.requestOpenLinksInAppPreference?.(url) + // Why: a runtime-hosted link can only reach the system browser, so prompting + // would persist an in-app preference this click cannot honor. + const preferenceDecision = + sourceOwner.kind === 'local' ? deps.requestOpenLinksInAppPreference?.(url) : null if (preferenceDecision === null || preferenceDecision === undefined) { - openHttpLink(url, { worktreeId: deps.worktreeId }) + openHttpLink(url, { worktreeId: deps.worktreeId, sourceOwner }) return } @@ -327,10 +184,11 @@ export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): voi .then((openInOrca) => { openHttpLink(url, { worktreeId: deps.worktreeId, - forceSystemBrowser: !openInOrca + forceSystemBrowser: !openInOrca, + sourceOwner }) }) .catch(() => { - openHttpLink(url, { worktreeId: deps.worktreeId, forceSystemBrowser: true }) + openHttpLink(url, { worktreeId: deps.worktreeId, forceSystemBrowser: true, sourceOwner }) }) } 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 96b7d3ff003..f6d776eb289 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 @@ -6,12 +6,14 @@ import { openHttpLinkAtTerminalMouseEvent, type TerminalLinkRoutingPreferenceRequester } from './terminal-url-link-hit-testing' +import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' type TerminalWebLinkClickDeps = Pick< LinkHandlerDeps, 'worktreeId' | 'worktreePath' | 'startupCwd' | 'runtimeEnvironmentId' | 'terminalHomePath' > & { terminal: Terminal | null + sourceOwner?: HttpLinkSourceOwner requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester } @@ -29,6 +31,11 @@ export function handleTerminalWebLinkClick( deps.terminal && openHttpLinkAtTerminalMouseEvent(deps.terminal, event, { worktreeId: deps.worktreeId, + sourceOwner: + deps.sourceOwner ?? + (deps.runtimeEnvironmentId + ? { kind: 'runtime', runtimeEnvironmentId: deps.runtimeEnvironmentId } + : { kind: 'local' }), 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 92f877819dc..113fd52a527 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 @@ -42,7 +42,11 @@ import { type TerminalLinkRoutingPreferenceRequester } from './terminal-url-link-hit-testing' import { installTerminalLinkifierClickPriming } from './terminal-linkifier-click-priming' -import { resolveLocalhostHttpLinkDisplayUrl } from '@/lib/http-link-routing' +import { + resolveLocalhostHttpLinkDisplayUrl, + type HttpLinkSourceOwner +} from '@/lib/http-link-routing' +import { resolveTerminalHttpLinkSourceOwner } from './terminal-http-link-source-owner' import type { GlobalSettings, SetupSplitDirection, @@ -110,7 +114,6 @@ import { reconcileMissingSessions, type ReconcilableBinding } from './terminal-dead-session-reconcile' -import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' import { getConnectionId } from '@/lib/connection-context' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard' @@ -199,8 +202,12 @@ function reportActiveRendererPtyForPane( } } -async function formatTerminalUrlTooltip(url: string, openLinkHint: string): Promise { - const labeledUrl = await resolveLocalhostHttpLinkDisplayUrl(url) +async function formatTerminalUrlTooltip( + url: string, + openLinkHint: string, + sourceOwner: HttpLinkSourceOwner +): Promise { + const labeledUrl = await resolveLocalhostHttpLinkDisplayUrl(url, sourceOwner) if (!labeledUrl) { return null } @@ -719,6 +726,8 @@ export function useTerminalPaneLifecycle({ const terminalHomePath = resolveTerminalHomePathFromEnv(startup?.env) const getPaneLinkCwd = (paneId: number): string => resolvePaneLinkCwd(paneCwdRef.current, paneId, startupCwd) + const getHttpLinkSourceOwnerForPane = (paneId: number) => + resolveTerminalHttpLinkSourceOwner(paneTransportsRef.current.get(paneId)) // Why: lifecycle-scoped cache for cross-SSH/runtime existence probes; may hold temporarily stale entries. const pathExistsCache = new Map() const linkDeps: LinkHandlerDeps = { @@ -731,8 +740,8 @@ export function useTerminalPaneLifecycle({ linkProviderDisposablesRef, pathExistsCache, getRuntimeEnvironmentIdForPane: (paneId) => { - const ptyId = paneTransportsRef.current.get(paneId)?.getPtyId() - return ptyId ? getRemoteRuntimePtyEnvironmentId(ptyId) : null + const sourceOwner = getHttpLinkSourceOwnerForPane(paneId) + return sourceOwner.kind === 'runtime' ? sourceOwner.runtimeEnvironmentId : null } } let resizeRaf: number | null = null @@ -836,8 +845,10 @@ export function useTerminalPaneLifecycle({ const fileOpenLinkHint = getTerminalFileOpenHint() // Why: read settingsRef at fire time so toggling link routing applies without recreating panes. - const getUrlOpenLinkHint = (): string => - getTerminalUrlOpenHint(terminalUrlOpenHintOptionsFor(settingsRef.current)) + const getUrlOpenLinkHint = (paneId: number): string => + getTerminalUrlOpenHint( + terminalUrlOpenHintOptionsFor(settingsRef.current, getHttpLinkSourceOwnerForPane(paneId)) + ) const osc7UncHost = extractUncHost(startupCwd) let releaseWebviewDragPassthrough: (() => void) | null = null @@ -1060,6 +1071,7 @@ export function useTerminalPaneLifecycle({ fileLinkClickFallbackDisposablesRef.current.set(pane.id, fileLinkClickFallbackDisposable) const httpLinkClickFallbackDisposable = installHttpLinkClickFallback(pane.terminal, { ...linkDeps, + getSourceOwner: () => getHttpLinkSourceOwnerForPane(pane.id), requestOpenLinksInAppPreference }) httpLinkClickFallbackDisposables.set(pane.id, httpLinkClickFallbackDisposable) @@ -1129,6 +1141,7 @@ export function useTerminalPaneLifecycle({ ...linkDeps, startupCwd: getPaneLinkCwd(pane.id), runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null, + sourceOwner: getHttpLinkSourceOwnerForPane(pane.id), requestOpenLinksInAppPreference }) // Why: link activation can steal focus before the click's mouseup reaches xterm, stranding its drag-select @@ -1141,10 +1154,11 @@ export function useTerminalPaneLifecycle({ hover: (_event, text) => { oscTooltipHoverToken += 1 const hoverToken = oscTooltipHoverToken - const urlOpenLinkHint = getUrlOpenLinkHint() + const urlOpenLinkHint = getUrlOpenLinkHint(pane.id) pane.linkTooltip.textContent = `${text} (${urlOpenLinkHint})` pane.linkTooltip.style.display = '' - void formatTerminalUrlTooltip(text, urlOpenLinkHint).then((nextText) => { + const sourceOwner = getHttpLinkSourceOwnerForPane(pane.id) + void formatTerminalUrlTooltip(text, urlOpenLinkHint, sourceOwner).then((nextText) => { if (hoverToken === oscTooltipHoverToken && nextText) { pane.linkTooltip.textContent = nextText } @@ -1454,11 +1468,15 @@ export function useTerminalPaneLifecycle({ runtimeEnvironmentId: activePane ? (linkDeps.getRuntimeEnvironmentIdForPane?.(activePane.id) ?? null) : null, + sourceOwner: activePane + ? getHttpLinkSourceOwnerForPane(activePane.id) + : { kind: 'local' }, requestOpenLinksInAppPreference }) }, linkOpenHint: getUrlOpenLinkHint, - formatLinkTooltip: (url, openLinkHint) => formatTerminalUrlTooltip(url, openLinkHint), + formatLinkTooltip: (paneId, url, openLinkHint) => + formatTerminalUrlTooltip(url, openLinkHint, getHttpLinkSourceOwnerForPane(paneId)), // 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, // Why: remote-runtime panes honor the GPU setting too; late snapshots are handled by post-replay rebuildPaneWebgl in pty-connection. diff --git a/src/renderer/src/lib/http-link-modifier-routing.test.ts b/src/renderer/src/lib/http-link-modifier-routing.test.ts index b37a900cde3..c7da95ae4c2 100644 --- a/src/renderer/src/lib/http-link-modifier-routing.test.ts +++ b/src/renderer/src/lib/http-link-modifier-routing.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from 'vitest' -import { resolveModifierRouting } from './http-link-routing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + openHttpLink, + registerHttpLinkStoreAccessor, + resolveModifierRouting +} from './http-link-routing' describe('resolveModifierRouting', () => { it('is inert without the modifier regardless of settings', () => { @@ -49,3 +53,62 @@ describe('resolveModifierRouting', () => { ) }) }) + +describe('modifier routing across link source owners', () => { + const openUrlMock = vi.fn() + const setActiveWorktreeMock = vi.fn() + const createBrowserTabMock = vi.fn() + const storeState = { + settings: {} as { + openLinksInApp?: boolean + openLinksInAppModifierInverts?: boolean + activeRuntimeEnvironmentId?: string | null + }, + setActiveWorktree: setActiveWorktreeMock, + createBrowserTab: createBrowserTabMock + } + + beforeEach(() => { + vi.clearAllMocks() + registerHttpLinkStoreAccessor(() => storeState) + vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('still lets the inverting modifier pull a local link into Orca', () => { + storeState.settings = { openLinksInApp: false, openLinksInAppModifierInverts: true } + + openHttpLink('https://example.com/', { + worktreeId: 'wt-1', + modifierHeld: true, + sourceOwner: { kind: 'local' } + }) + + expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { + activate: true + }) + }) + + it('never lets a modifier pull a runtime-owned link into Orca', () => { + for (const inverts of [true, false]) { + vi.clearAllMocks() + storeState.settings = { + openLinksInApp: false, + openLinksInAppModifierInverts: inverts, + activeRuntimeEnvironmentId: null + } + + openHttpLink('https://example.com/', { + worktreeId: 'wt-1', + modifierHeld: true, + sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' } + }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() + } + }) +}) diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts index 86c3bc18c54..d0c96799978 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -145,6 +145,21 @@ describe('openHttpLink', () => { expect(registerLocalhostLabelMock).not.toHaveBeenCalled() }) + // Why: runtimes bind per workspace, so activeRuntimeEnvironmentId is commonly + // null while a pane is remote — ownership must come from the click source. + it('keeps a runtime-owned link out of Orca when no runtime is globally active', () => { + storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: null } + + openHttpLink('https://example.com/', { + worktreeId: 'wt-1', + sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' } + }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(setActiveWorktreeMock).not.toHaveBeenCalled() + }) + it('labels explicit local links from the local scan instead of a merged remote port', async () => { storeState.settings = { openLinksInApp: true, @@ -380,6 +395,89 @@ describe('openHttpLink', () => { await expect(resolveLocalhostHttpLinkDisplayUrl('http://localhost:5180/')).resolves.toBe(null) expect(registerLocalhostLabelMock).not.toHaveBeenCalled() }) + + // Why: the hover label must describe the click's real destination — a remote pane's + // loopback URL opens raw in the system browser, so a local worktree label would lie. + it.each([ + ['runtime', { kind: 'runtime', runtimeEnvironmentId: 'env-1' }] as const, + ['ssh', { kind: 'ssh', connectionId: 'conn-1' }] as const + ])('does not label a %s-owned localhost link without an active runtime', async (_kind, owner) => { + storeState.settings = { + localhostWorktreeLabelsEnabled: true, + activeRuntimeEnvironmentId: null + } + storeState.repos = [{ id: 'repo-1', displayName: 'snapstudio' }] + storeState.worktreesByRepo = { 'repo-1': [{ id: 'wt-main', projectId: 'repo-1' }] } + storeState.workspacePortScan = { + result: { + platform: 'darwin', + scannedAt: 1, + ports: [ + { + id: 'tcp:5180', + kind: 'workspace', + port: 5180, + protocol: 'http', + bindHost: '127.0.0.1', + connectHost: 'localhost', + owner: { + repoId: 'repo-1', + worktreeId: 'wt-main', + displayName: 'main', + path: '/repo/main', + confidence: 'cwd' + } + } + ] + } + } + + await expect(resolveLocalhostHttpLinkDisplayUrl('http://localhost:5180/', owner)).resolves.toBe( + null + ) + expect(registerLocalhostLabelMock).not.toHaveBeenCalled() + }) + + // Why: a local pane keeps its label from the local scan even while another pane's + // runtime is globally active — the same scan the click resolves. + it('labels a local-owned localhost link from the local scan', async () => { + storeState.settings = { + localhostWorktreeLabelsEnabled: true, + activeRuntimeEnvironmentId: 'env-other' + } + storeState.repos = [{ id: 'repo-1', displayName: 'snapstudio' }] + storeState.worktreesByRepo = { 'repo-1': [{ id: 'wt-main', projectId: 'repo-1' }] } + storeState.workspacePortScansByKey = { + 'local:all': { + platform: 'darwin', + scannedAt: 1, + ports: [ + { + id: 'tcp:5180', + kind: 'workspace', + port: 5180, + protocol: 'http', + bindHost: '127.0.0.1', + connectHost: 'localhost', + owner: { + repoId: 'repo-1', + worktreeId: 'wt-main', + displayName: 'main', + path: '/repo/main', + confidence: 'cwd' + } + } + ] + } + } + registerLocalhostLabelMock.mockResolvedValue({ + url: 'http://snapstudio-main.orca.localhost:60016/' + }) + + await expect( + resolveLocalhostHttpLinkDisplayUrl('http://localhost:5180/', { kind: 'local' }) + ).resolves.toBe('http://snapstudio-main.orca.localhost:60016/') + }) }) describe('openHttpLink modifier routing', () => { diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts index 06cc0d5235e..86e4f91fcd5 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -160,12 +160,17 @@ function localhostLabelRouteForHttpLink( return localhostLabelRouteForTerminalLink(url, state, sourceOwner?.kind === 'local', sourceScan) } -export async function resolveLocalhostHttpLinkDisplayUrl(url: string): Promise { +export async function resolveLocalhostHttpLinkDisplayUrl( + url: string, + sourceOwner?: HttpLinkSourceOwner +): Promise { const state = storeAccessor?.() if (!state) { return null } - const localhostRoute = localhostLabelRouteForTerminalLink(url, state) + // Why: the hover label must resolve the same route the click will take, or a + // remote pane's loopback URL gets shown with a local worktree's label. + const localhostRoute = localhostLabelRouteForHttpLink(url, state, sourceOwner) if (!localhostRoute) { return null } diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts index 13e36a962f0..64cbdd6ada1 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts @@ -113,4 +113,26 @@ describe('createPaneDOM link tooltips', () => { expect(pane.linkTooltip.textContent).toBe(labeledText) }) + + // Why: the hovered pane's host decides where its links can go, so both hooks must + // receive that pane's id rather than resolving against global state. + it('identifies the hovered pane to both tooltip hooks', () => { + const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId + const linkOpenHint = vi.fn(() => 'open hint') + const formatLinkTooltip = vi.fn(() => null) + createPaneDOM( + 7, + leafId, + { linkOpenHint, formatLinkTooltip }, + { active: null } as never, + {} as never, + vi.fn(), + vi.fn() + ) + + webLinksAddonMock.options?.hover?.({} as MouseEvent, 'http://localhost:5180/') + + expect(linkOpenHint).toHaveBeenCalledWith(7) + expect(formatLinkTooltip).toHaveBeenCalledWith(7, 'http://localhost:5180/', 'open hint') + }) }) 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 089e590a0bb..0163587662e 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -76,10 +76,10 @@ export function createPaneDOM( if (uri) { linkTooltipHoverToken += 1 const hoverToken = linkTooltipHoverToken - const openLinkHint = options.linkOpenHint() + const openLinkHint = options.linkOpenHint(id) linkTooltip.textContent = defaultLinkTooltipText(uri, openLinkHint) linkTooltip.style.display = '' - const formatted = options.formatLinkTooltip?.(uri, openLinkHint) + const formatted = options.formatLinkTooltip?.(id, uri, openLinkHint) if (formatted && typeof formatted === 'object' && 'then' in formatted) { void formatted.then( (nextText) => { 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 1c1c63553ab..3b82e0a92ac 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -62,8 +62,10 @@ export type PaneManagerOptions = { /** Resolved per hover so link-routing setting changes apply without recreating panes. */ // Why: required so dropping the wiring is a compile error — an optional hint with a // default would silently serve stale copy that no test can distinguish. - linkOpenHint: () => string + // Why: paneId-scoped because the hovered pane's host decides where its links can go. + linkOpenHint: (paneId: number) => string formatLinkTooltip?: ( + paneId: number, url: string, openLinkHint: string ) => string | null | undefined | Promise