authorgravatar for 73941998+UtkarshUsername@users.noreply.github.comUtkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> 2026-09-09 00:04:51+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 11:34:51-07:00
log772ea1473a4a8f8fb0e0a5c9ba6d7a2ef14eac6e
treed4d6aafe9a542501c1e358bbd1ad791adae4190b
parent5d14c0e9686d8bedf47dd93dd3811fd639255c7e
signature Signed by PGP key B5690EEEBB952194

fix(web): honor terminal link browser overrides (#10060)


6 files changed, 184 insertions(+), 57 deletions(-)

apps/web/src/components/ThreadTerminalDrawer.tsx+2-2
...@@ -57,7 +57,7 @@ import {...@@ -57,7 +57,7 @@ import {
57} from "~/terminal/ghostty/surface";57} from "~/terminal/ghostty/surface";
58import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core";58import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core";
59import { useOpenInPreferredEditor } from "../editorPreferences";59import { useOpenInPreferredEditor } from "../editorPreferences";
60import { isTerminalLinkActivation, isTerminalUrl, resolvePathLinkTarget } from "../terminal-links";60import { isTerminalUrl, resolvePathLinkTarget } from "../terminal-links";
61import {61import {
62 isDiffToggleShortcut,62 isDiffToggleShortcut,
63 isTerminalClearShortcut,63 isTerminalClearShortcut,
...@@ -782,7 +782,6 @@ export function TerminalViewport({...@@ -782,7 +782,6 @@ export function TerminalViewport({
782 }782 }
783783
784 function handleLinkActivate(text: string, event: MouseEvent): void {784 function handleLinkActivate(text: string, event: MouseEvent): void {
785 if (!isTerminalLinkActivation(event)) return;
786 const latestTerminal = terminalRef.current;785 const latestTerminal = terminalRef.current;
787 if (!latestTerminal) return;786 if (!latestTerminal) return;
788 if (isTerminalUrl(text)) {787 if (isTerminalUrl(text)) {
...@@ -803,6 +802,7 @@ export function TerminalViewport({...@@ -803,6 +802,7 @@ export function TerminalViewport({
803 threadRef,802 threadRef,
804 openPreview,803 openPreview,
805 fallbackToBrowser,804 fallbackToBrowser,
805 forceBrowser: event.metaKey || event.ctrlKey,
806 }).catch((error: unknown) => {806 }).catch((error: unknown) => {
807 toastManager.add(807 toastManager.add(
808 stackedThreadToast({808 stackedThreadToast({
apps/web/src/components/preview/openTerminalLinkInPreview.test.ts+22
...@@ -88,6 +88,7 @@ describe("openTerminalLinkInPreview", () => {...@@ -88,6 +88,7 @@ describe("openTerminalLinkInPreview", () => {
88 threadRef,88 threadRef,
89 openPreview,89 openPreview,
90 fallbackToBrowser,90 fallbackToBrowser,
91 forceBrowser: false,
91 }),92 }),
92 ).rejects.toBe(failure);93 ).rejects.toBe(failure);
93 expect(fallbackToBrowser).not.toHaveBeenCalled();94 expect(fallbackToBrowser).not.toHaveBeenCalled();
...@@ -105,6 +106,7 @@ describe("openTerminalLinkInPreview", () => {...@@ -105,6 +106,7 @@ describe("openTerminalLinkInPreview", () => {
105 threadRef,106 threadRef,
106 openPreview,107 openPreview,
107 fallbackToBrowser,108 fallbackToBrowser,
109 forceBrowser: false,
108 });110 });
109111
110 expect(fallbackToBrowser).toHaveBeenCalledOnce();112 expect(fallbackToBrowser).toHaveBeenCalledOnce();
...@@ -120,6 +122,7 @@ describe("openTerminalLinkInPreview", () => {...@@ -120,6 +122,7 @@ describe("openTerminalLinkInPreview", () => {
120 threadRef,122 threadRef,
121 openPreview,123 openPreview,
122 fallbackToBrowser,124 fallbackToBrowser,
125 forceBrowser: false,
123 });126 });
124127
125 expect(openPreview).toHaveBeenCalledOnce();128 expect(openPreview).toHaveBeenCalledOnce();
...@@ -141,6 +144,7 @@ describe("openTerminalLinkInPreview", () => {...@@ -141,6 +144,7 @@ describe("openTerminalLinkInPreview", () => {
141 threadRef,144 threadRef,
142 openPreview,145 openPreview,
143 fallbackToBrowser: vi.fn(),146 fallbackToBrowser: vi.fn(),
147 forceBrowser: false,
144 });148 });
145149
146 await vi.waitFor(() => expect(browserDefaultsMocks.resolve).toHaveBeenCalledOnce());150 await vi.waitFor(() => expect(browserDefaultsMocks.resolve).toHaveBeenCalledOnce());
...@@ -170,6 +174,7 @@ describe("openTerminalLinkInPreview", () => {...@@ -170,6 +174,7 @@ describe("openTerminalLinkInPreview", () => {
170 threadRef,174 threadRef,
171 openPreview: async () => AsyncResult.failure(cause),175 openPreview: async () => AsyncResult.failure(cause),
172 fallbackToBrowser,176 fallbackToBrowser,
177 forceBrowser: false,
173 });178 });
174179
175 expect(fallbackToBrowser).toHaveBeenCalledOnce();180 expect(fallbackToBrowser).toHaveBeenCalledOnce();
...@@ -194,9 +199,26 @@ describe("openTerminalLinkInPreview", () => {...@@ -194,9 +199,26 @@ describe("openTerminalLinkInPreview", () => {
194 threadRef,199 threadRef,
195 openPreview: async () => AsyncResult.failure(Cause.interrupt()),200 openPreview: async () => AsyncResult.failure(Cause.interrupt()),
196 fallbackToBrowser,201 fallbackToBrowser,
202 forceBrowser: false,
197 });203 });
198204
199 expect(reportError).not.toHaveBeenCalled();205 expect(reportError).not.toHaveBeenCalled();
200 expect(fallbackToBrowser).not.toHaveBeenCalled();206 expect(fallbackToBrowser).not.toHaveBeenCalled();
201 });207 });
208
209 it("opens in the system browser when Ctrl or Command is held", async () => {
210 const fallbackToBrowser = vi.fn();
211 const openPreview = vi.fn(async () => AsyncResult.success(snapshot));
212
213 await openTerminalLinkInPreview({
214 url: "https://example.com/docs",
215 threadRef,
216 openPreview,
217 fallbackToBrowser,
218 forceBrowser: true,
219 });
220
221 expect(fallbackToBrowser).toHaveBeenCalledOnce();
222 expect(openPreview).not.toHaveBeenCalled();
223 });
202});224});
apps/web/src/components/preview/openTerminalLinkInPreview.ts+5-5
...@@ -34,19 +34,19 @@ interface OpenTerminalLinkInPreviewInput<E> {...@@ -34,19 +34,19 @@ interface OpenTerminalLinkInPreviewInput<E> {
34 readonly threadRef: ScopedThreadRef;34 readonly threadRef: ScopedThreadRef;
35 readonly openPreview: OpenPreviewMutation<E>;35 readonly openPreview: OpenPreviewMutation<E>;
36 readonly fallbackToBrowser: () => void;36 readonly fallbackToBrowser: () => void;
37 /** Cmd/Ctrl-click bypasses the preference and opens in the system browser. */
38 readonly forceBrowser: boolean;
37}39}
3840
39/**41/**
40 * Opens a terminal hyperlink where the "Open links in" setting says. Terminal42 * Opens a terminal hyperlink where the "Open links in" setting says, unless a
41 * links are activated with the platform modifier already held, so unlike chat43 * Cmd/Ctrl-click explicitly requests the system browser.
42 * links the modifier cannot double as the system-browser override; the setting
43 * alone decides, and the system browser is the fallback whenever the in-app
44 * one cannot take the URL.
45 */44 */
46export async function openTerminalLinkInPreview<E>(45export async function openTerminalLinkInPreview<E>(
47 input: OpenTerminalLinkInPreviewInput<E>,46 input: OpenTerminalLinkInPreviewInput<E>,
48): Promise<void> {47): Promise<void> {
49 const supportsPreview =48 const supportsPreview =
49 !input.forceBrowser &&
50 isWebUrl(input.url) &&50 isWebUrl(input.url) &&
51 isPreviewSupportedInRuntime() &&51 isPreviewSupportedInRuntime() &&
52 input.threadRef.threadId.length > 0 &&52 input.threadRef.threadId.length > 0 &&
apps/web/src/components/settings/IntegrationsSettings.tsx+1-1
...@@ -516,7 +516,7 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean })...@@ -516,7 +516,7 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean })
516 return (516 return (
517 <SettingsRow517 <SettingsRow
518 {...searchableSetting("browser-link-target")}518 {...searchableSetting("browser-link-target")}
519 description="Where links in the chat and terminal open. Hold ⌘ or Ctrl while clicking a chat link to open it in your default browser either way."519 description="Where links in the chat and terminal open. Hold ⌘ or Ctrl while clicking a link to open it in your default browser either way."
520 resetAction={520 resetAction={
521 !disabled && linkTarget !== DEFAULT_BROWSER_LINK_TARGET ? (521 !disabled && linkTarget !== DEFAULT_BROWSER_LINK_TARGET ? (
522 <SettingResetButton522 <SettingResetButton
apps/web/src/terminal/ghostty/surface.test.ts+80-15
...@@ -12,7 +12,6 @@ import {...@@ -12,7 +12,6 @@ import {
12 isTerminalCompositionCommitInput,12 isTerminalCompositionCommitInput,
13 isTerminalCompositionKey,13 isTerminalCompositionKey,
14 isTerminalCopyShortcut,14 isTerminalCopyShortcut,
15 isTerminalLinkPointerGesture,
16 isTerminalPasteShortcut,15 isTerminalPasteShortcut,
17 loadTerminalFontFamily,16 loadTerminalFontFamily,
18 primeTerminalCopyInput,17 primeTerminalCopyInput,
...@@ -168,7 +167,7 @@ describe("GhosttyTerminalSurface visibility", () => {...@@ -168,7 +167,7 @@ describe("GhosttyTerminalSurface visibility", () => {
168 resize() {167 resize() {
169 for (const callback of resizeCallbacks) callback();168 for (const callback of resizeCallbacks) callback();
170 },169 },
171 pointer(type: string, clientX: number, buttons: number) {170 pointer(type: string, clientX: number, buttons: number, shiftKey = false) {
172 canvas.dispatchEvent(171 canvas.dispatchEvent(
173 Object.assign(new Event(type, { cancelable: true }), {172 Object.assign(new Event(type, { cancelable: true }), {
174 clientX,173 clientX,
...@@ -176,6 +175,7 @@ describe("GhosttyTerminalSurface visibility", () => {...@@ -176,6 +175,7 @@ describe("GhosttyTerminalSurface visibility", () => {
176 pointerId: 1,175 pointerId: 1,
177 button: 0,176 button: 0,
178 buttons,177 buttons,
178 shiftKey,
179 }),179 }),
180 );180 );
181 },181 },
...@@ -280,6 +280,84 @@ describe("GhosttyTerminalSurface visibility", () => {...@@ -280,6 +280,84 @@ describe("GhosttyTerminalSurface visibility", () => {
280 expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false);280 expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false);
281 });281 });
282282
283 it("starts a selection when dragging from a link", async () => {
284 const harness = createHarness();
285 const onLinkActivate = vi.fn();
286 const surface = await harness.create({ onLinkActivate });
287 surface.write("https://example.com");
288 harness.flushFrame();
289
290 harness.pointer("pointerdown", 5, 1);
291 harness.pointer("pointermove", 37, 1);
292 harness.pointer("pointerup", 37, 0);
293
294 expect(onLinkActivate).not.toHaveBeenCalled();
295 expect(surface.getSelection()).toBe("https");
296 });
297
298 it("keeps a link click active through slight pointer movement", async () => {
299 const harness = createHarness();
300 const onLinkActivate = vi.fn();
301 const surface = await harness.create({ onLinkActivate });
302 surface.write("https://example.com");
303 harness.flushFrame();
304
305 harness.pointer("pointerdown", 5, 1);
306 harness.pointer("pointermove", 6, 1);
307 harness.pointer("pointerup", 6, 0);
308
309 expect(onLinkActivate).toHaveBeenCalledOnce();
310 });
311
312 it("uses repeated link clicks for word and line selection", async () => {
313 const harness = createHarness();
314 const onLinkActivate = vi.fn();
315 const surface = await harness.create({ onLinkActivate });
316 surface.write("https://example.com tail");
317 harness.flushFrame();
318
319 harness.pointer("pointerdown", 5, 1);
320 harness.pointer("pointerup", 5, 0);
321 harness.pointer("pointerdown", 5, 1);
322 harness.pointer("pointerup", 5, 0);
323 expect(onLinkActivate).toHaveBeenCalledOnce();
324 expect(surface.getSelection()).not.toBe("");
325
326 harness.pointer("pointerdown", 5, 1);
327 harness.pointer("pointerup", 5, 0);
328 expect(onLinkActivate).toHaveBeenCalledOnce();
329 expect(surface.getSelection()).toBe("https://example.com tail");
330 });
331
332 it("uses Shift drags over links for selection", async () => {
333 const harness = createHarness();
334 const onLinkActivate = vi.fn();
335 const surface = await harness.create({ onLinkActivate });
336 surface.write("https://example.com");
337 harness.flushFrame();
338
339 harness.pointer("pointerdown", 5, 1, true);
340 harness.pointer("pointermove", 37, 1, true);
341 harness.pointer("pointerup", 37, 0, true);
342 expect(onLinkActivate).not.toHaveBeenCalled();
343 expect(surface.getSelection()).toBe("https");
344 });
345
346 it("does not activate a link replaced before pointer release", async () => {
347 const harness = createHarness();
348 const onLinkActivate = vi.fn();
349 const surface = await harness.create({ onLinkActivate });
350 surface.write("https://first.example");
351 harness.flushFrame();
352
353 harness.pointer("pointerdown", 5, 1);
354 surface.write("\x1b[2J\x1b[Hhttps://second.example");
355 harness.flushFrame();
356 harness.pointer("pointerup", 5, 0);
357
358 expect(onLinkActivate).not.toHaveBeenCalled();
359 });
360
283 it("stops zero-size mounts and repaints when the same size returns", async () => {361 it("stops zero-size mounts and repaints when the same size returns", async () => {
284 const harness = createHarness();362 const harness = createHarness();
285 const surface = await harness.create();363 const surface = await harness.create();
...@@ -890,19 +968,6 @@ describe("terminalWheelArrowData", () => {...@@ -890,19 +968,6 @@ describe("terminalWheelArrowData", () => {
890 });968 });
891});969});
892970
893describe("isTerminalLinkPointerGesture", () => {
894 it("uses Command on macOS and Control elsewhere", () => {
895 expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, "MacIntel")).toBe(true);
896 expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, "MacIntel")).toBe(false);
897 expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, "Linux x86_64")).toBe(
898 true,
899 );
900 expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, "Linux x86_64")).toBe(
901 false,
902 );
903 });
904});
905
906describe("advanceTerminalSelectionClickSequence", () => {971describe("advanceTerminalSelectionClickSequence", () => {
907 it("recognizes stationary double and triple pointer presses without PointerEvent.detail", () => {972 it("recognizes stationary double and triple pointer presses without PointerEvent.detail", () => {
908 const first = advanceTerminalSelectionClickSequence(null, {973 const first = advanceTerminalSelectionClickSequence(null, {
apps/web/src/terminal/ghostty/surface.ts+74-34
...@@ -249,6 +249,19 @@ export interface TerminalLinkWithRange {...@@ -249,6 +249,19 @@ export interface TerminalLinkWithRange {
249 readonly range: GhosttyCellRange;249 readonly range: GhosttyCellRange;
250}250}
251251
252function isSameTerminalLink(
253 left: TerminalLinkWithRange,
254 right: TerminalLinkWithRange | null,
255): boolean {
256 return (
257 right?.text === left.text &&
258 right.range.start.x === left.range.start.x &&
259 right.range.start.y === left.range.start.y &&
260 right.range.end.x === left.range.end.x &&
261 right.range.end.y === left.range.end.y
262 );
263}
264
252function terminalColumnAtOffset(row: GhosttySnapshot["rowData"][number], offset: number): number {265function terminalColumnAtOffset(row: GhosttySnapshot["rowData"][number], offset: number): number {
253 for (let column = 0; column < row.cells.length; column += 1) {266 for (let column = 0; column < row.cells.length; column += 1) {
254 const nextOffset = terminalColumnOffset(row, column + 1);267 const nextOffset = terminalColumnOffset(row, column + 1);
...@@ -467,15 +480,6 @@ export function terminalWheelArrowData(rows: number, applicationCursorKeys: bool...@@ -467,15 +480,6 @@ export function terminalWheelArrowData(rows: number, applicationCursorKeys: bool
467 return sequence.repeat(Math.abs(rows));480 return sequence.repeat(Math.abs(rows));
468}481}
469482
470export function isTerminalLinkPointerGesture(
471 event: Pick<MouseEvent, "ctrlKey" | "metaKey">,
472 platform = navigator.platform,
473): boolean {
474 return isMacPlatform(platform)
475 ? event.metaKey && !event.ctrlKey
476 : event.ctrlKey && !event.metaKey;
477}
478
479export function ghosttyMouseButton(button: number): number | null {483export function ghosttyMouseButton(button: number): number | null {
480 switch (button) {484 switch (button) {
481 case 0:485 case 0:
...@@ -500,6 +504,8 @@ export interface TerminalSelectionClickSequence {...@@ -500,6 +504,8 @@ export interface TerminalSelectionClickSequence {
500 readonly y: number;504 readonly y: number;
501}505}
502506
507const TERMINAL_LINK_DRAG_THRESHOLD_PX = 4;
508
503export function advanceTerminalSelectionClickSequence(509export function advanceTerminalSelectionClickSequence(
504 previous: TerminalSelectionClickSequence | null,510 previous: TerminalSelectionClickSequence | null,
505 event: Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">,511 event: Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">,
...@@ -507,7 +513,8 @@ export function advanceTerminalSelectionClickSequence(...@@ -507,7 +513,8 @@ export function advanceTerminalSelectionClickSequence(
507 const repeats =513 const repeats =
508 previous !== null &&514 previous !== null &&
509 event.timeStamp - previous.time <= SELECTION_MULTI_CLICK_INTERVAL_MS &&515 event.timeStamp - previous.time <= SELECTION_MULTI_CLICK_INTERVAL_MS &&
510 Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <= 4;516 Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <=
517 TERMINAL_LINK_DRAG_THRESHOLD_PX;
511 return {518 return {
512 count: repeats ? (previous.count >= 3 ? 1 : previous.count + 1) : 1,519 count: repeats ? (previous.count >= 3 ? 1 : previous.count + 1) : 1,
513 time: event.timeStamp,520 time: event.timeStamp,
...@@ -592,9 +599,14 @@ export class GhosttyTerminalSurface {...@@ -592,9 +599,14 @@ export class GhosttyTerminalSurface {
592 private mouseReportingPointerId: number | null = null;599 private mouseReportingPointerId: number | null = null;
593 private mouseReportingButton: number | null = null;600 private mouseReportingButton: number | null = null;
594 private linkActivationPointerId: number | null = null;601 private linkActivationPointerId: number | null = null;
602 private linkActivationLink: TerminalLinkWithRange | null = null;
603 private linkActivationOrigin: {
604 x: number;
605 y: number;
606 clickCount: number;
607 } | null = null;
595 private hoveredLink: TerminalLinkWithRange | null = null;608 private hoveredLink: TerminalLinkWithRange | null = null;
596 private hoverPointer: { x: number; y: number } | null = null;609 private hoverPointer: { x: number; y: number } | null = null;
597 private linkModifierActive = false;
598 private selectionClickSequence: TerminalSelectionClickSequence | null = null;610 private selectionClickSequence: TerminalSelectionClickSequence | null = null;
599 private selectionMoved = false;611 private selectionMoved = false;
600 private composing = false;612 private composing = false;
...@@ -1018,7 +1030,6 @@ export class GhosttyTerminalSurface {...@@ -1018,7 +1030,6 @@ export class GhosttyTerminalSurface {
1018 }1030 }
10191031
1020 private readonly onKeyDown = (event: KeyboardEvent) => {1032 private readonly onKeyDown = (event: KeyboardEvent) => {
1021 this.updateLinkModifier(event);
1022 // Presses handled outside the terminal must also swallow their release:1033 // Presses handled outside the terminal must also swallow their release:
1023 // beforeKey runs side effects (keybindings, navigation sends), so it cannot1034 // beforeKey runs side effects (keybindings, navigation sends), so it cannot
1024 // be consulted again on keyup, and Kitty report-event-types sessions would1035 // be consulted again on keyup, and Kitty report-event-types sessions would
...@@ -1121,7 +1132,6 @@ export class GhosttyTerminalSurface {...@@ -1121,7 +1132,6 @@ export class GhosttyTerminalSurface {
1121 };1132 };
11221133
1123 private readonly onKeyUp = (event: KeyboardEvent) => {1134 private readonly onKeyUp = (event: KeyboardEvent) => {
1124 this.updateLinkModifier(event);
1125 if (this.suppressedKeyCodes.delete(event.code)) return;1135 if (this.suppressedKeyCodes.delete(event.code)) return;
1126 if (isTerminalCompositionKey(event, this.composing)) {1136 if (isTerminalCompositionKey(event, this.composing)) {
1127 return;1137 return;
...@@ -1143,7 +1153,6 @@ export class GhosttyTerminalSurface {...@@ -1143,7 +1153,6 @@ export class GhosttyTerminalSurface {
11431153
1144 private readonly onBlur = () => {1154 private readonly onBlur = () => {
1145 this.focused = false;1155 this.focused = false;
1146 this.linkModifierActive = false;
1147 this.refreshHoveredLink();1156 this.refreshHoveredLink();
1148 // Suppressions survive blur deliberately: a shortcut that moves focus (for1157 // Suppressions survive blur deliberately: a shortcut that moves focus (for
1149 // example terminal-toggle) must still swallow its own keyup if focus comes1158 // example terminal-toggle) must still swallow its own keyup if focus comes
...@@ -1264,21 +1273,39 @@ export class GhosttyTerminalSurface {...@@ -1264,21 +1273,39 @@ export class GhosttyTerminalSurface {
1264 return;1273 return;
1265 }1274 }
1266 if (event.button !== 0) return;1275 if (event.button !== 0) return;
1267 if (isTerminalLinkPointerGesture(event)) {1276 const clickCount = this.recordSelectionClick(event);
1277 const link = this.linkAt(event.clientX, event.clientY);
1278 if (link && !event.shiftKey && clickCount === 1) {
1268 event.preventDefault();1279 event.preventDefault();
1269 event.stopPropagation();1280 event.stopPropagation();
1270 this.linkActivationPointerId = event.pointerId;1281 this.linkActivationPointerId = event.pointerId;
1282 this.linkActivationLink = link;
1283 this.linkActivationOrigin = {
1284 x: event.clientX,
1285 y: event.clientY,
1286 clickCount,
1287 };
1271 this.canvas.setPointerCapture(event.pointerId);1288 this.canvas.setPointerCapture(event.pointerId);
1272 return;1289 return;
1273 }1290 }
1274 this.clearHoveredLink();1291 this.clearHoveredLink();
1275 const cell = this.cellAt(event.clientX, event.clientY);1292 this.beginSelection(event, clickCount);
1276 this.selectionMoved = false;1293 this.canvas.setPointerCapture(event.pointerId);
1294 };
1295
1296 private recordSelectionClick(
1297 event: Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">,
1298 ): number {
1277 this.selectionClickSequence = advanceTerminalSelectionClickSequence(1299 this.selectionClickSequence = advanceTerminalSelectionClickSequence(
1278 this.selectionClickSequence,1300 this.selectionClickSequence,
1279 event,1301 event,
1280 );1302 );
1281 const clickCount = this.selectionClickSequence.count;1303 return this.selectionClickSequence.count;
1304 }
1305
1306 private beginSelection(event: { clientX: number; clientY: number }, clickCount: number): void {
1307 const cell = this.cellAt(event.clientX, event.clientY);
1308 this.selectionMoved = false;
1282 this.selectionMode = clickCount >= 3 ? "line" : clickCount === 2 ? "word" : "cell";1309 this.selectionMode = clickCount >= 3 ? "line" : clickCount === 2 ? "word" : "cell";
1283 const range =1310 const range =
1284 this.selectionMode === "line"1311 this.selectionMode === "line"
...@@ -1306,12 +1333,31 @@ export class GhosttyTerminalSurface {...@@ -1306,12 +1333,31 @@ export class GhosttyTerminalSurface {
1306 }1333 }
1307 }1334 }
1308 this.forceFullRender = true;1335 this.forceFullRender = true;
1309 this.canvas.setPointerCapture(event.pointerId);
1310 this.requestRender();1336 this.requestRender();
1311 };1337 }
13121338
1313 private readonly onPointerMove = (event: PointerEvent) => {1339 private readonly onPointerMove = (event: PointerEvent) => {
1314 if (this.linkActivationPointerId === event.pointerId) return;1340 if (this.linkActivationPointerId === event.pointerId) {
1341 const origin = this.linkActivationOrigin;
1342 if (
1343 origin === null ||
1344 Math.hypot(event.clientX - origin.x, event.clientY - origin.y) <=
1345 TERMINAL_LINK_DRAG_THRESHOLD_PX
1346 ) {
1347 return;
1348 }
1349 this.linkActivationPointerId = null;
1350 this.linkActivationLink = null;
1351 this.linkActivationOrigin = null;
1352 this.clearHoveredLink();
1353 this.beginSelection(
1354 {
1355 clientX: origin.x,
1356 clientY: origin.y,
1357 },
1358 origin.clickCount,
1359 );
1360 }
1315 // Hover motion is only reportable in any-event tracking (DEC 1003); normal and1361 // Hover motion is only reportable in any-event tracking (DEC 1003); normal and
1316 // button-event tracking never report motion without a captured pressed button.1362 // button-event tracking never report motion without a captured pressed button.
1317 const anyEventTracking = this.synchronizeMouseTrackingState();1363 const anyEventTracking = this.synchronizeMouseTrackingState();
...@@ -1321,7 +1367,6 @@ export class GhosttyTerminalSurface {...@@ -1321,7 +1367,6 @@ export class GhosttyTerminalSurface {
1321 ) {1367 ) {
1322 event.preventDefault();1368 event.preventDefault();
1323 this.hoverPointer = { x: event.clientX, y: event.clientY };1369 this.hoverPointer = { x: event.clientX, y: event.clientY };
1324 this.linkModifierActive = isTerminalLinkPointerGesture(event);
1325 // A drag whose press was already sent to the terminal application cannot1370 // A drag whose press was already sent to the terminal application cannot
1326 // turn into link activation midway through, so link feedback would lie.1371 // turn into link activation midway through, so link feedback would lie.
1327 this.setHoveredLink(null);1372 this.setHoveredLink(null);
...@@ -1396,14 +1441,6 @@ export class GhosttyTerminalSurface {...@@ -1396,14 +1441,6 @@ export class GhosttyTerminalSurface {
13961441
1397 private updateHoverCursor(event: PointerEvent): void {1442 private updateHoverCursor(event: PointerEvent): void {
1398 this.hoverPointer = { x: event.clientX, y: event.clientY };1443 this.hoverPointer = { x: event.clientX, y: event.clientY };
1399 this.linkModifierActive = isTerminalLinkPointerGesture(event);
1400 this.refreshHoveredLink();
1401 }
1402
1403 private updateLinkModifier(event: Pick<KeyboardEvent, "ctrlKey" | "metaKey">): void {
1404 const active = isTerminalLinkPointerGesture(event);
1405 if (active === this.linkModifierActive) return;
1406 this.linkModifierActive = active;
1407 this.refreshHoveredLink();1444 this.refreshHoveredLink();
1408 }1445 }
14091446
...@@ -1420,7 +1457,7 @@ export class GhosttyTerminalSurface {...@@ -1420,7 +1457,7 @@ export class GhosttyTerminalSurface {
14201457
1421 private refreshHoveredLink(): void {1458 private refreshHoveredLink(): void {
1422 const pointer = this.hoverPointer;1459 const pointer = this.hoverPointer;
1423 const link = pointer && this.linkModifierActive ? this.linkAt(pointer.x, pointer.y) : null;1460 const link = pointer ? this.linkAt(pointer.x, pointer.y) : null;
1424 this.setHoveredLink(link);1461 this.setHoveredLink(link);
1425 }1462 }
14261463
...@@ -1444,13 +1481,17 @@ export class GhosttyTerminalSurface {...@@ -1444,13 +1481,17 @@ export class GhosttyTerminalSurface {
1444 if (this.linkActivationPointerId === event.pointerId) {1481 if (this.linkActivationPointerId === event.pointerId) {
1445 event.preventDefault();1482 event.preventDefault();
1446 event.stopPropagation();1483 event.stopPropagation();
1484 const link = this.linkActivationLink;
1447 this.linkActivationPointerId = null;1485 this.linkActivationPointerId = null;
1486 this.linkActivationLink = null;
1487 this.linkActivationOrigin = null;
1448 if (this.canvas.hasPointerCapture(event.pointerId)) {1488 if (this.canvas.hasPointerCapture(event.pointerId)) {
1449 this.canvas.releasePointerCapture(event.pointerId);1489 this.canvas.releasePointerCapture(event.pointerId);
1450 }1490 }
1451 if (event.type !== "pointercancel") {1491 if (event.type !== "pointercancel") {
1452 const link = this.linkAt(event.clientX, event.clientY);1492 if (link && isSameTerminalLink(link, this.linkAt(event.clientX, event.clientY))) {
1453 if (link) this.options.onLinkActivate(link.text, event);1493 this.options.onLinkActivate(link.text, event);
1494 }
1454 }1495 }
1455 return;1496 return;
1456 }1497 }
...@@ -1467,7 +1508,6 @@ export class GhosttyTerminalSurface {...@@ -1467,7 +1508,6 @@ export class GhosttyTerminalSurface {
1467 this.clearHoveredLink();1508 this.clearHoveredLink();
1468 } else {1509 } else {
1469 this.hoverPointer = { x: event.clientX, y: event.clientY };1510 this.hoverPointer = { x: event.clientX, y: event.clientY };
1470 this.linkModifierActive = isTerminalLinkPointerGesture(event);
1471 this.refreshHoveredLink();1511 this.refreshHoveredLink();
1472 }1512 }
1473 return;1513 return;