Files
apes-sidebar/src/client/terminal-links.ts
T
多玩幻灵qwq 0314fd9b93 Merge pull request #419 from HuanLinOTO/feat/terminal-ctrl-click-url
feat(terminal): open URLs with Ctrl+Click (Cmd+Click on mac)
2026-08-27 21:44:32 +08:00

195 lines
7.5 KiB
TypeScript

/**
* 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
}