Merge pull request #419 from HuanLinOTO/feat/terminal-ctrl-click-url
feat(terminal): open URLs with Ctrl+Click (Cmd+Click on mac)
This commit is contained in:
@@ -42,6 +42,11 @@ import { api, type SessionScope, type TerminalDepsStatus } from './api.ts'
|
||||
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
|
||||
import { isDarkScheme, subscribeColorScheme, effectiveTokenValue, tokenValue } from './theme.ts'
|
||||
import { resolveTerminalFont } from './terminal-font.ts'
|
||||
import {
|
||||
buildTerminalLinks,
|
||||
shouldActivateTerminalLink,
|
||||
openTerminalUrl,
|
||||
} from './terminal-links.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** How many consecutive unreasoned failures before showing the error banner. */
|
||||
@@ -129,6 +134,41 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
})
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
// Ctrl+Click (Cmd+Click on mac) opens http(s) URLs printed in the
|
||||
// pty stream — a plain click is left for xterm's text-selection
|
||||
// gesture. Only http(s) is dispatched; file:// / mailto: / etc. are
|
||||
// underlined for visibility but rejected at activation. See
|
||||
// terminal-links.ts for the line scanner, modifier gate and scheme
|
||||
// guard.
|
||||
const linkProvider = term.registerLinkProvider({
|
||||
provideLinks: (lineNumber, callback) => {
|
||||
// xterm's `provideLinks` hands us a 1-based buffer line number
|
||||
// (its own built-in ILinkProvider does `buffer.lines.get(e - 1)`,
|
||||
// i.e. the public `bufferLineNumber` is 1-based while `getLine`
|
||||
// takes a 0-based index). Passing `lineNumber` straight through
|
||||
// would fetch the row *below* the one xterm asked us to scan, so
|
||||
// the URL text would come from the wrong row while `range.y` still
|
||||
// pointed at the requested row — links landed one line too high.
|
||||
const line = term.buffer.active.getLine(lineNumber - 1)
|
||||
if (line === undefined) {
|
||||
callback(undefined)
|
||||
return
|
||||
}
|
||||
const descriptors = buildTerminalLinks(line.translateToString(true), lineNumber)
|
||||
if (descriptors.length === 0) {
|
||||
callback(undefined)
|
||||
return
|
||||
}
|
||||
callback(descriptors.map(descriptor => ({
|
||||
range: descriptor.range,
|
||||
text: descriptor.text,
|
||||
activate: (event) => {
|
||||
if (!shouldActivateTerminalLink(event)) return
|
||||
openTerminalUrl(descriptor.text)
|
||||
},
|
||||
})))
|
||||
},
|
||||
})
|
||||
// Re-theme in place when the app's scheme flips (tokens + palette).
|
||||
const applyTheme = (): void => {
|
||||
term.options.theme = xtermTheme()
|
||||
@@ -313,6 +353,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
socket.send(JSON.stringify({ type: 'park' }))
|
||||
}
|
||||
socket?.close()
|
||||
linkProvider.dispose()
|
||||
term.dispose()
|
||||
connectRef.current = null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Terminal URL hyperlinks: xterm's `registerLinkProvider` is fed per-line
|
||||
* link descriptors built from the pty stream, so URLs printed by any tool
|
||||
* become hoverable / clickable spans.
|
||||
*
|
||||
* To stay out of the user's way (a terminal's primary interaction is text
|
||||
* selection, not browsing), a click only activates when the user holds
|
||||
* Ctrl (Win/Linux) or Cmd (mac) — a plain click is left for xterm's
|
||||
* normal selection handling. Only http(s) URLs are dispatched to the
|
||||
* browser; other schemes (`file://`, `mailto:`, `javascript:`, …) are
|
||||
* underlined for visibility but rejected at activation, so a `file://`
|
||||
* URL printed by a tool stays inert instead of being handed to
|
||||
* `window.open`.
|
||||
*
|
||||
* Kept as a pure module (no xterm import) so the regex, the line
|
||||
* scanner, the modifier gate and the scheme guard are unit-testable
|
||||
* without mounting a terminal. `buildTerminalLinks` returns plain-object
|
||||
* descriptors whose shape matches xterm's `ILink` minus the `activate`
|
||||
* callback — the caller attaches `activate` (which closes over the
|
||||
* event modifier check + `openTerminalUrl`) so this module never
|
||||
* imports xterm types.
|
||||
*/
|
||||
|
||||
/** Scheme allowlist for activation. Only http(s) is opened externally. */
|
||||
const OPENABLE_SCHEMES = new Set(['http:', 'https:'])
|
||||
|
||||
/**
|
||||
* The URL pattern used to scan each terminal line.
|
||||
*
|
||||
* A word boundary (`\b`) guards the leading scheme so `notttps://…` does
|
||||
* not match. The character class excludes ASCII whitespace and the
|
||||
* wrapping punctuation that shells commonly emit around URLs (quotes,
|
||||
* angle brackets, brackets/braces, pipes, backslashes, backticks) so a
|
||||
* URL printed as `"https://example.com"` or `<https://example.com>` does
|
||||
* not drag the wrapping character into the link target.
|
||||
*
|
||||
* Carries the `g` flag so `findTerminalUrlsInLine` can iterate every
|
||||
* match on a line; callers must reset `lastIndex` before reuse (the
|
||||
* helper does this defensively).
|
||||
*/
|
||||
export const TERMINAL_URL_REGEX = /\bhttps?:\/\/[^\s"'<>\[\]{}|\\^`]+/gi
|
||||
|
||||
/** A URL match in a terminal line: the text and its 0-based start offset. */
|
||||
export interface TerminalUrlMatch {
|
||||
/** 0-based start index of the URL within the line string. */
|
||||
start: number
|
||||
/** The matched URL text. */
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip trailing closing parens that are not balanced by an opening paren
|
||||
* earlier in the URL.
|
||||
*
|
||||
* The regex's character class keeps `(` and `)` (Wikipedia-style URLs
|
||||
* carry real parens: `…/Python_(programming_language)`), so a URL wrapped
|
||||
* in parens by a shell (`(https://example.com)`) captures the trailing
|
||||
* `)`. Balanced pairs are kept intact; only the unmatched excess is
|
||||
* trimmed, so:
|
||||
* - `https://example.com)` → `https://example.com`
|
||||
* - `https://en.wikipedia.org/wiki/Python_(programming_language)` → unchanged
|
||||
* - `https://example.com/(` → unchanged (more openers than closers; the
|
||||
* trailing `(` is the URL's own, not a wrapper)
|
||||
*/
|
||||
function trimUnbalancedTrailingParens(url: string): string {
|
||||
let opens = 0
|
||||
let closers = 0
|
||||
for (let i = 0; i < url.length; i += 1) {
|
||||
const ch = url[i]
|
||||
if (ch === '(') opens += 1
|
||||
else if (ch === ')') closers += 1
|
||||
}
|
||||
const excess = closers - opens
|
||||
if (excess <= 0) return url
|
||||
let end = url.length
|
||||
let stripped = 0
|
||||
while (end > 0 && url[end - 1] === ')' && stripped < excess) {
|
||||
end -= 1
|
||||
stripped += 1
|
||||
}
|
||||
return url.slice(0, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every http(s) URL in a line of terminal text, in source order
|
||||
* with 0-based start offsets. A link provider maps these to buffer
|
||||
* ranges for xterm's `registerLinkProvider`.
|
||||
*
|
||||
* Trailing unmatched closing parens are trimmed from each match (see
|
||||
* {@link trimUnbalancedTrailingParens}), so a URL wrapped in parens by
|
||||
* a shell opens without the trailing `)`, while Wikipedia-style URLs
|
||||
* with balanced parens stay intact.
|
||||
*
|
||||
* Resets the regex's `lastIndex` before and after the scan so a
|
||||
* previous partial iteration can't desynchronize a later one (the
|
||||
* regex carries the `g` flag and is module-shared).
|
||||
*/
|
||||
export function findTerminalUrlsInLine(line: string): TerminalUrlMatch[] {
|
||||
TERMINAL_URL_REGEX.lastIndex = 0
|
||||
const matches: TerminalUrlMatch[] = []
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = TERMINAL_URL_REGEX.exec(line)) !== null) {
|
||||
const trimmed = trimUnbalancedTrailingParens(m[0])
|
||||
if (trimmed.length > 0) {
|
||||
matches.push({ start: m.index, text: trimmed })
|
||||
}
|
||||
}
|
||||
TERMINAL_URL_REGEX.lastIndex = 0
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* A buffer range in xterm's coordinate system. xterm's cell `x` is
|
||||
* 1-based (1..cols) and `y` is the buffer line number; the `end` is
|
||||
* inclusive (the last cell covered by the link). Structurally
|
||||
* compatible with xterm's `IBufferRange` so the caller can pass it
|
||||
* straight through without importing xterm types here.
|
||||
*/
|
||||
export interface TerminalLinkRange {
|
||||
start: { x: number; y: number }
|
||||
end: { x: number; y: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* A link descriptor: a buffer range plus the URL text. The caller
|
||||
* (the link provider in `TerminalView`) attaches the `activate`
|
||||
* callback, which closes over the modifier gate and `openTerminalUrl`.
|
||||
*/
|
||||
export interface TerminalLinkDescriptor {
|
||||
range: TerminalLinkRange
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Build link descriptors for every URL found in a terminal line.
|
||||
*
|
||||
* @param lineText - the line's text (e.g. from
|
||||
* `IBufferLine.translateToString(true)`).
|
||||
* @param lineNumber - the buffer line number xterm passed to
|
||||
* `ILinkProvider.provideLinks` (used as the `y` of every range; URLs
|
||||
* never span wrapped lines because each wrapped row is its own
|
||||
* buffer line).
|
||||
* @returns descriptors in source order; empty when the line has no URL.
|
||||
*/
|
||||
export function buildTerminalLinks(lineText: string, lineNumber: number): TerminalLinkDescriptor[] {
|
||||
return findTerminalUrlsInLine(lineText).map(({ start, text: url }) => ({
|
||||
range: {
|
||||
// xterm's cell `x` is 1-based; `start` is 0-based from
|
||||
// translateToString, so the first URL character sits at x = start + 1.
|
||||
start: { x: start + 1, y: lineNumber },
|
||||
// `end` is inclusive: the last URL character is at 0-based
|
||||
// (start + url.length - 1), which is 1-based (start + url.length).
|
||||
end: { x: start + url.length, y: lineNumber },
|
||||
},
|
||||
text: url,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a click on a terminal link should activate (open the URL).
|
||||
*
|
||||
* Mirrors what every modern terminal does (Windows Terminal, iTerm2, the
|
||||
* VSCode integrated terminal): a plain click stays a text-selection
|
||||
* gesture, and only Ctrl (Win/Linux) or Cmd (mac) hands the URL to the
|
||||
* browser. The modifier is read off the activating `MouseEvent`, not
|
||||
* tracked separately, so a key-up between hover and click never
|
||||
* desynchronizes the gate.
|
||||
*/
|
||||
export function shouldActivateTerminalLink(event: MouseEvent): boolean {
|
||||
return event.ctrlKey || event.metaKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a URL matched in the terminal, with a scheme guard so a printed
|
||||
* `file://` or anything that slipped past the regex cannot reach
|
||||
* `window.open`. The URL is constructed via `new URL(...)` which throws
|
||||
* on malformed input; the catch makes the function total so the xterm
|
||||
* handler never throws into the terminal's event loop.
|
||||
*
|
||||
* @returns `true` when the URL was dispatched to `window.open`, `false`
|
||||
* when it was rejected (bad URL, disallowed scheme, no `window`).
|
||||
*/
|
||||
export function openTerminalUrl(uri: string): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(uri)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (!OPENABLE_SCHEMES.has(url.protocol)) return false
|
||||
window.open(url.toString(), '_blank', 'noopener,noreferrer')
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Terminal URL hyperlink helpers: the regex that scopes xterm's
|
||||
* `registerLinkProvider`, the per-line scanner that turns matches into
|
||||
* buffer ranges, the modifier gate that keeps a plain click a
|
||||
* text-selection gesture, and the scheme guard that routes only http(s)
|
||||
* to `window.open`. Pure-module unit tests — no xterm mount.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
TERMINAL_URL_REGEX,
|
||||
findTerminalUrlsInLine,
|
||||
buildTerminalLinks,
|
||||
shouldActivateTerminalLink,
|
||||
openTerminalUrl,
|
||||
} from '../src/client/terminal-links.ts'
|
||||
|
||||
/** Reset the regex's lastIndex between tests (it carries the `g` flag). */
|
||||
function reset(): void {
|
||||
TERMINAL_URL_REGEX.lastIndex = 0
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
reset()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
/** Build a fake MouseEvent with only the modifier fields the gate reads. */
|
||||
function fakeEvent(mods: { ctrlKey?: boolean; metaKey?: boolean } = {}): MouseEvent {
|
||||
return {
|
||||
ctrlKey: mods.ctrlKey ?? false,
|
||||
metaKey: mods.metaKey ?? false,
|
||||
} as MouseEvent
|
||||
}
|
||||
|
||||
describe('TERMINAL_URL_REGEX', () => {
|
||||
it('matches a bare http(s) URL', () => {
|
||||
expect('https://example.com/path?x=1#frag'.match(TERMINAL_URL_REGEX))
|
||||
.toEqual(['https://example.com/path?x=1#frag'])
|
||||
expect('http://localhost:3000/'.match(TERMINAL_URL_REGEX))
|
||||
.toEqual(['http://localhost:3000/'])
|
||||
})
|
||||
|
||||
it('does not match a scheme that is not http(s)', () => {
|
||||
expect('file:///etc/passwd'.match(TERMINAL_URL_REGEX)).toBeNull()
|
||||
expect('mailto:foo@bar.com'.match(TERMINAL_URL_REGEX)).toBeNull()
|
||||
expect('ssh://host/path'.match(TERMINAL_URL_REGEX)).toBeNull()
|
||||
expect('javascript:alert(1)'.match(TERMINAL_URL_REGEX)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not match a scheme glued to a word character (word-boundary guard)', () => {
|
||||
// Regression guard: a tool printing `nothttps://example.com` should not
|
||||
// surface a clickable link — the word boundary rejects the embedded scheme.
|
||||
expect('nothttps://example.com'.match(TERMINAL_URL_REGEX)).toBeNull()
|
||||
expect('xhttps://example.com'.match(TERMINAL_URL_REGEX)).toBeNull()
|
||||
})
|
||||
|
||||
it('matches when preceded by a non-word character (quote, bracket, whitespace)', () => {
|
||||
expect('"https://example.com"'.match(TERMINAL_URL_REGEX))
|
||||
.toEqual(['https://example.com'])
|
||||
expect('<https://example.com>'.match(TERMINAL_URL_REGEX))
|
||||
.toEqual(['https://example.com'])
|
||||
expect('[see https://example.com for more]'.match(TERMINAL_URL_REGEX))
|
||||
.toEqual(['https://example.com'])
|
||||
})
|
||||
|
||||
it('excludes trailing wrapping punctuation from the matched URL', () => {
|
||||
// Shells commonly wrap URLs in quotes / angle brackets / parentheses;
|
||||
// the closing wrapper must not become part of the link target. The
|
||||
// character class itself rejects quotes, angle brackets, square and
|
||||
// curly braces; round parens are kept (Wikipedia URLs carry real
|
||||
// parens) and trimmed by `findTerminalUrlsInLine` instead (covered
|
||||
// in its own describe block below).
|
||||
expect('"https://example.com"'.match(TERMINAL_URL_REGEX)?.[0]).toBe('https://example.com')
|
||||
expect('<https://example.com>'.match(TERMINAL_URL_REGEX)?.[0]).toBe('https://example.com')
|
||||
expect('[https://example.com]'.match(TERMINAL_URL_REGEX)?.[0]).toBe('https://example.com')
|
||||
expect('{https://example.com}'.match(TERMINAL_URL_REGEX)?.[0]).toBe('https://example.com')
|
||||
expect('`https://example.com`'.match(TERMINAL_URL_REGEX)?.[0]).toBe('https://example.com')
|
||||
})
|
||||
|
||||
it('stops at whitespace (a URL never contains a space)', () => {
|
||||
expect('run https://example.com now'.match(TERMINAL_URL_REGEX))
|
||||
.toEqual(['https://example.com'])
|
||||
})
|
||||
|
||||
it('finds multiple URLs on the same line (global flag)', () => {
|
||||
const matches = 'see https://a.com and http://b.com/path'.match(TERMINAL_URL_REGEX)
|
||||
expect(matches).toEqual(['https://a.com', 'http://b.com/path'])
|
||||
})
|
||||
|
||||
it('keeps the path, query and fragment intact', () => {
|
||||
const url = 'https://example.com/a/b/c?d=1&e=2#section-3'
|
||||
expect(url.match(TERMINAL_URL_REGEX)?.[0]).toBe(url)
|
||||
})
|
||||
|
||||
it('does not match plain text without a scheme', () => {
|
||||
expect('example.com'.match(TERMINAL_URL_REGEX)).toBeNull()
|
||||
expect('see the docs at example.com/path'.match(TERMINAL_URL_REGEX)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('findTerminalUrlsInLine', () => {
|
||||
it('returns an empty array when the line has no URL', () => {
|
||||
expect(findTerminalUrlsInLine('just some text')).toEqual([])
|
||||
expect(findTerminalUrlsInLine('')).toEqual([])
|
||||
expect(findTerminalUrlsInLine('file:///etc/passwd')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns the URL text and its 0-based start offset', () => {
|
||||
expect(findTerminalUrlsInLine('see https://example.com now'))
|
||||
.toEqual([{ start: 4, text: 'https://example.com' }])
|
||||
expect(findTerminalUrlsInLine('https://example.com'))
|
||||
.toEqual([{ start: 0, text: 'https://example.com' }])
|
||||
})
|
||||
|
||||
it('returns every URL on the line in source order', () => {
|
||||
expect(findTerminalUrlsInLine('https://a.com and http://b.com/path')).toEqual([
|
||||
{ start: 0, text: 'https://a.com' },
|
||||
{ start: 18, text: 'http://b.com/path' },
|
||||
])
|
||||
})
|
||||
|
||||
it('trims wrapping punctuation from the matched text', () => {
|
||||
expect(findTerminalUrlsInLine('"https://example.com"')).toEqual([
|
||||
{ start: 1, text: 'https://example.com' },
|
||||
])
|
||||
expect(findTerminalUrlsInLine('<https://example.com>')).toEqual([
|
||||
{ start: 1, text: 'https://example.com' },
|
||||
])
|
||||
})
|
||||
|
||||
it('strips trailing closing parens not balanced by an opening paren', () => {
|
||||
// Shell-wrapped URLs: `(https://example.com)` — the regex captures the
|
||||
// trailing `)` (round parens are kept so Wikipedia-style URLs work),
|
||||
// and findTerminalUrlsInLine trims the unmatched excess.
|
||||
expect(findTerminalUrlsInLine('(https://example.com)')).toEqual([
|
||||
{ start: 1, text: 'https://example.com' },
|
||||
])
|
||||
expect(findTerminalUrlsInLine('see (https://example.com) here')).toEqual([
|
||||
{ start: 5, text: 'https://example.com' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps balanced parens inside Wikipedia-style URLs intact', () => {
|
||||
const url = 'https://en.wikipedia.org/wiki/Python_(programming_language)'
|
||||
expect(findTerminalUrlsInLine(`see ${url}`)).toEqual([{ start: 4, text: url }])
|
||||
// Trailing balanced closer is part of the URL, not a wrapper.
|
||||
expect(findTerminalUrlsInLine(url)).toEqual([{ start: 0, text: url }])
|
||||
})
|
||||
|
||||
it('strips only the unmatched excess when the URL has both its own parens and a wrapper closer', () => {
|
||||
// URL has 1 opening and 1 closing paren of its own, plus a wrapper
|
||||
// closing paren: `https://en.wikipedia.org/wiki/Foo_(bar))`
|
||||
// → opens=1, closers=2, excess=1 → strip one trailing `)`.
|
||||
expect(findTerminalUrlsInLine('https://en.wikipedia.org/wiki/Foo_(bar))')).toEqual([
|
||||
{ start: 0, text: 'https://en.wikipedia.org/wiki/Foo_(bar)' },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not carry lastIndex state across calls (the g flag is module-shared)', () => {
|
||||
// First call leaves lastIndex set; a second call on a different line
|
||||
// must still scan from the start.
|
||||
findTerminalUrlsInLine('https://a.com')
|
||||
expect(findTerminalUrlsInLine('https://b.com')).toEqual([
|
||||
{ start: 0, text: 'https://b.com' },
|
||||
])
|
||||
// And a third call after a partial match must also reset cleanly.
|
||||
TERMINAL_URL_REGEX.exec('https://c.com') // leaves lastIndex past the match
|
||||
expect(findTerminalUrlsInLine('https://d.com')).toEqual([
|
||||
{ start: 0, text: 'https://d.com' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTerminalLinks', () => {
|
||||
it('returns an empty array when the line has no URL', () => {
|
||||
expect(buildTerminalLinks('plain text', 5)).toEqual([])
|
||||
expect(buildTerminalLinks('', 0)).toEqual([])
|
||||
})
|
||||
|
||||
it('builds a descriptor with xterm 1-based x coords and inclusive end', () => {
|
||||
// Line: "see https://example.com now"
|
||||
// 0123456789...
|
||||
// URL starts at 0-based index 4, length 19 ('https://example.com').
|
||||
// xterm x is 1-based: start cell = 4+1 = 5, last cell = 4+19 = 23.
|
||||
expect(buildTerminalLinks('see https://example.com now', 7)).toEqual([
|
||||
{
|
||||
range: { start: { x: 5, y: 7 }, end: { x: 23, y: 7 } },
|
||||
text: 'https://example.com',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('places a URL at the start of the line at x=1', () => {
|
||||
expect(buildTerminalLinks('https://example.com', 3)).toEqual([
|
||||
{
|
||||
range: { start: { x: 1, y: 3 }, end: { x: 19, y: 3 } },
|
||||
text: 'https://example.com',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the buffer line number passed in as the range y', () => {
|
||||
const links = buildTerminalLinks('https://example.com', 42)
|
||||
expect(links[0]?.range.start.y).toBe(42)
|
||||
expect(links[0]?.range.end.y).toBe(42)
|
||||
})
|
||||
|
||||
it('builds one descriptor per URL on a multi-URL line, in source order', () => {
|
||||
const links = buildTerminalLinks('https://a.com http://b.com', 1)
|
||||
expect(links).toHaveLength(2)
|
||||
expect(links[0]?.text).toBe('https://a.com')
|
||||
expect(links[1]?.text).toBe('http://b.com')
|
||||
// Non-overlapping ranges, in order.
|
||||
expect(links[0]!.range.end.x).toBeLessThan(links[1]!.range.start.x)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldActivateTerminalLink', () => {
|
||||
it('returns false for a plain click (no modifier)', () => {
|
||||
expect(shouldActivateTerminalLink(fakeEvent())).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when Ctrl is held (Win/Linux convention)', () => {
|
||||
expect(shouldActivateTerminalLink(fakeEvent({ ctrlKey: true }))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when Cmd is held (mac convention)', () => {
|
||||
expect(shouldActivateTerminalLink(fakeEvent({ metaKey: true }))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when both modifiers are held (defensive — some remotes send both)', () => {
|
||||
expect(shouldActivateTerminalLink(fakeEvent({ ctrlKey: true, metaKey: true }))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when only Shift / Alt are held (those are not open modifiers)', () => {
|
||||
expect(shouldActivateTerminalLink(fakeEvent())).toBe(false)
|
||||
// Construct with shiftKey/altKey only; the gate ignores them.
|
||||
const event = { ctrlKey: false, metaKey: false, shiftKey: true, altKey: true } as MouseEvent
|
||||
expect(shouldActivateTerminalLink(event)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openTerminalUrl', () => {
|
||||
it('opens an http URL in a new tab with noopener,noreferrer', () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
expect(openTerminalUrl('http://example.com')).toBe(true)
|
||||
expect(open).toHaveBeenCalledWith('http://example.com/', '_blank', 'noopener,noreferrer')
|
||||
})
|
||||
|
||||
it('opens an https URL', () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
expect(openTerminalUrl('https://example.com/path?x=1')).toBe(true)
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
'https://example.com/path?x=1',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects file:// (printed by tools, never handed to window.open)', () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
expect(openTerminalUrl('file:///etc/passwd')).toBe(false)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects mailto:', () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
expect(openTerminalUrl('mailto:foo@bar.com')).toBe(false)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects javascript: (defense-in-depth even though the regex skips it)', () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
expect(openTerminalUrl('javascript:alert(1)')).toBe(false)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a malformed URL string without throwing', () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
expect(openTerminalUrl('https://')).toBe(false)
|
||||
expect(openTerminalUrl('not a url at all')).toBe(false)
|
||||
expect(openTerminalUrl('')).toBe(false)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user