Skip to content

Commit a7a5082

Browse files
Merge pull request #1055 from corbitsdev/corbitsdev/cl-7967-link-module-split
Split transcript link helpers into focused modules
2 parents 0c4036a + 18f33ab commit a7a5082

5 files changed

Lines changed: 479 additions & 394 deletions

File tree

‎src/tui/link-open.ts‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Opening: the openable-URL gate plus the browser opener behind it.
3+
*
4+
* openUrl is the single opener choke point: every armed-row and markdown
5+
* release path opens through it, and its isOpenableUrl check is the gate
6+
* that decides. The isOpenableUrl pre-filters in linkColumnHits and
7+
* followWrapChain stay as defense-in-depth — they keep non-http(s) targets
8+
* out of highlight and wrap-fusion geometry — and are deliberately not
9+
* consolidated into this one call site.
10+
*/
11+
import type { MouseEvent } from "@opentui/core";
12+
13+
/**
14+
* Only http(s) targets ever open. Markdown authors can point a link at any
15+
* scheme (`javascript:`, `file:`, `mailto:`), so the gate parses rather than
16+
* prefix-matching.
17+
*/
18+
export function isOpenableUrl(url: string): boolean {
19+
try {
20+
const protocol = new URL(url).protocol;
21+
return protocol === "http:" || protocol === "https:";
22+
} catch {
23+
return false;
24+
}
25+
}
26+
27+
export type UrlOpener = (url: string) => void;
28+
29+
/**
30+
* Argv for opening a URL with the platform handler, without a shell. Windows
31+
* must never route through `cmd /c start`: cmd.exe re-parses the assembled
32+
* command line, so `&`, `|` and `&&` in an attacker-influenceable transcript
33+
* URL would execute as command separators. `rundll32 url.dll,FileProtocolHandler`
34+
* takes the URL as a plain argv element instead.
35+
*/
36+
export function platformUrlCommand(platform: string, url: string): string[] {
37+
if (platform === "darwin") return ["open", url];
38+
if (platform === "win32")
39+
return ["rundll32", "url.dll,FileProtocolHandler", url];
40+
return ["xdg-open", url];
41+
}
42+
43+
/**
44+
* The open gesture: left press while Ctrl is held. Cmd on macOS is the
45+
* terminal's own OSC-8 click (it handles Cmd+click itself and the app never
46+
* sees the press); Ctrl is what SGR mouse reports carry on every platform.
47+
*/
48+
export function isUrlOpenClick(
49+
event: Pick<MouseEvent, "button" | "modifiers">,
50+
): boolean {
51+
return event.button === 0 && event.modifiers.ctrl === true;
52+
}
53+
54+
function defaultUrlOpener(url: string): void {
55+
const command = platformUrlCommand(process.platform, url);
56+
try {
57+
Bun.spawn(command, {
58+
stdout: "ignore",
59+
stderr: "ignore",
60+
stdin: "ignore",
61+
}).unref();
62+
} catch {
63+
// Fire-and-forget from a hover/click handler with no status line to
64+
// report to; a missing opener must not break the transcript.
65+
}
66+
}
67+
68+
let currentOpener: UrlOpener = defaultUrlOpener;
69+
70+
/** Test seam: swap the browser opener, `resetUrlOpener` restores it. */
71+
export function setUrlOpener(opener: UrlOpener): void {
72+
currentOpener = opener;
73+
}
74+
75+
export function resetUrlOpener(): void {
76+
currentOpener = defaultUrlOpener;
77+
}
78+
79+
/** Open an http(s) URL in the default browser; anything else is ignored. */
80+
export function openUrl(url: string): void {
81+
if (!isOpenableUrl(url)) return;
82+
try {
83+
currentOpener(url);
84+
} catch {
85+
// Same fire-and-forget contract as the default opener above.
86+
}
87+
}

‎src/tui/link-spans.ts‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/** URL spans: bare http(s) scanning plus splitting styled runs on hits. */
2+
export interface LinkSpan {
3+
readonly text: string;
4+
readonly fg: string;
5+
readonly bold?: boolean | undefined;
6+
readonly url: string | null;
7+
}
8+
9+
export interface LinkHit {
10+
readonly url: string;
11+
readonly start: number;
12+
readonly end: number;
13+
}
14+
15+
const URL_PATTERN = /https?:\/\/[^\s<>"'`\]]+/gi;
16+
const TRAILING_PUNCTUATION = new Set([
17+
".",
18+
",",
19+
";",
20+
":",
21+
"!",
22+
"?",
23+
"'",
24+
'"',
25+
"]",
26+
"}",
27+
">",
28+
]);
29+
30+
/** http(s) runs inside plain text, without trailing prose punctuation. */
31+
export function findLinks(text: string): LinkHit[] {
32+
const hits: LinkHit[] = [];
33+
URL_PATTERN.lastIndex = 0;
34+
for (const match of text.matchAll(URL_PATTERN)) {
35+
const end = trimUrlEnd(text, match.index, match.index + match[0].length);
36+
if (end > match.index)
37+
hits.push({ url: text.slice(match.index, end), start: match.index, end });
38+
}
39+
return hits;
40+
}
41+
42+
/**
43+
* The end of a URL match once prose punctuation is out: trailing sentence
44+
* punctuation never belongs to the link, and a closing paren only does when
45+
* the match opened one to balance it.
46+
*/
47+
export function trimUrlEnd(text: string, start: number, end: number): number {
48+
let trimmed = end;
49+
while (trimmed > start) {
50+
const tail = text[trimmed - 1];
51+
if (tail === undefined || !TRAILING_PUNCTUATION.has(tail)) break;
52+
trimmed -= 1;
53+
}
54+
let depth = 0;
55+
for (let i = start; i < trimmed; i += 1) {
56+
if (text[i] === "(") depth += 1;
57+
if (text[i] === ")") depth -= 1;
58+
}
59+
while (trimmed > start && text[trimmed - 1] === ")" && depth < 0) {
60+
trimmed -= 1;
61+
depth += 1;
62+
}
63+
return trimmed;
64+
}
65+
66+
/** Split styled segments so URL runs become their own spans. */
67+
export function splitLinkSpans(
68+
segments: readonly { text: string; fg: string; bold?: boolean | undefined }[],
69+
): LinkSpan[] {
70+
const spans: LinkSpan[] = [];
71+
for (const segment of segments) {
72+
spans.push(
73+
...sliceSpans(
74+
segment,
75+
findLinks(segment.text).map((hit): SliceHit => ({
76+
...hit,
77+
text: hit.url,
78+
})),
79+
),
80+
);
81+
}
82+
return spans;
83+
}
84+
85+
/** A hit with the exact text its span paints (trimmed of prose punctuation). */
86+
export interface SliceHit extends LinkHit {
87+
readonly text: string;
88+
}
89+
90+
/** Cut one segment on explicit hits; a hitless segment stays one null span. */
91+
export function sliceSpans(
92+
segment: { text: string; fg: string; bold?: boolean | undefined },
93+
hits: readonly SliceHit[],
94+
): LinkSpan[] {
95+
if (hits.length === 0) {
96+
return [
97+
{ text: segment.text, fg: segment.fg, bold: segment.bold, url: null },
98+
];
99+
}
100+
const spans: LinkSpan[] = [];
101+
let cursor = 0;
102+
for (const hit of hits) {
103+
if (hit.start > cursor)
104+
spans.push({
105+
text: segment.text.slice(cursor, hit.start),
106+
fg: segment.fg,
107+
bold: segment.bold,
108+
url: null,
109+
});
110+
spans.push({
111+
text: hit.text,
112+
fg: segment.fg,
113+
bold: segment.bold,
114+
url: hit.url,
115+
});
116+
cursor = hit.end;
117+
}
118+
if (cursor < segment.text.length)
119+
spans.push({
120+
text: segment.text.slice(cursor),
121+
fg: segment.fg,
122+
bold: segment.bold,
123+
url: null,
124+
});
125+
return spans;
126+
}

0 commit comments

Comments
 (0)