Route terminal links based on pane ownership, not global state (#12233)
* 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 <help@stably.ai> * 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 <help@stably.ai>
This commit is contained in:
parent
93a2ad8fd8
commit
db5325204f
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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<PtyTransport, 'getPtyId' | 'getRuntimeEnvironmentId' | 'getConnectionId'>
|
||||
|
||||
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' }
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MouseEvent, 'metaKey' | 'ctrlKey'> &
|
||||
Partial<Pick<MouseEvent, 'button' | 'shiftKey' | 'preventDefault' | 'stopPropagation'>>
|
||||
|
|
@ -29,6 +30,7 @@ export function handleOscLink(
|
|||
event: TerminalLinkEvent | undefined,
|
||||
deps: Pick<LinkHandlerDeps, 'worktreeId' | 'worktreePath'> &
|
||||
Partial<Pick<LinkHandlerDeps, 'runtimeEnvironmentId' | 'startupCwd' | 'terminalHomePath'>> & {
|
||||
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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<boolean> | 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 })
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<string | null> {
|
||||
const labeledUrl = await resolveLocalhostHttpLinkDisplayUrl(url)
|
||||
async function formatTerminalUrlTooltip(
|
||||
url: string,
|
||||
openLinkHint: string,
|
||||
sourceOwner: HttpLinkSourceOwner
|
||||
): Promise<string | null> {
|
||||
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<string, boolean>()
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -160,12 +160,17 @@ function localhostLabelRouteForHttpLink(
|
|||
return localhostLabelRouteForTerminalLink(url, state, sourceOwner?.kind === 'local', sourceScan)
|
||||
}
|
||||
|
||||
export async function resolveLocalhostHttpLinkDisplayUrl(url: string): Promise<string | null> {
|
||||
export async function resolveLocalhostHttpLinkDisplayUrl(
|
||||
url: string,
|
||||
sourceOwner?: HttpLinkSourceOwner
|
||||
): Promise<string | null> {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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<string | null | undefined>
|
||||
|
|
|
|||
Loading…
Reference in New Issue