| author | |
| committer | |
| log | 883e1a3cd9d70f6906a8eb1458a7a05d1d9e0d55 |
| tree | ae7f4b3df0c2734646b121ae85e1d04408edf974 |
| parent | 8287f2c3a771f4a88f781e9c98830692b186c5c3 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 28 insertions(+), 23 deletions(-)
apps/web/src/components/ThreadTerminalDrawer.tsx+2-2| ... | @@ -43,7 +43,7 @@ import { | ... | @@ -43,7 +43,7 @@ import { |
| 43 | } from "~/terminal/ghostty/surface"; | 43 | } from "~/terminal/ghostty/surface"; |
| 44 | import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; | 44 | import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; |
| 45 | import { useOpenInPreferredEditor } from "../editorPreferences"; | 45 | import { useOpenInPreferredEditor } from "../editorPreferences"; |
| 46 | import { isTerminalLinkActivation, resolvePathLinkTarget } from "../terminal-links"; | 46 | import { isTerminalLinkActivation, isTerminalUrl, resolvePathLinkTarget } from "../terminal-links"; |
| 47 | import { | 47 | import { |
| 48 | isDiffToggleShortcut, | 48 | isDiffToggleShortcut, |
| 49 | isTerminalClearShortcut, | 49 | isTerminalClearShortcut, |
| ... | @@ -750,7 +750,7 @@ export function TerminalViewport({ | ... | @@ -750,7 +750,7 @@ export function TerminalViewport({ |
| 750 | if (!isTerminalLinkActivation(event)) return; | 750 | if (!isTerminalLinkActivation(event)) return; |
| 751 | const latestTerminal = terminalRef.current; | 751 | const latestTerminal = terminalRef.current; |
| 752 | if (!latestTerminal) return; | 752 | if (!latestTerminal) return; |
| 753 | if (/^https?:\/\//u.test(text)) { | 753 | if (isTerminalUrl(text)) { |
| 754 | if (!localApi) { | 754 | if (!localApi) { |
| 755 | writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser."); | 755 | writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser."); |
| 756 | return; | 756 | return; |
apps/web/src/terminal-links.test.ts+19| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | collectWrappedTerminalLinkLine, | 4 | collectWrappedTerminalLinkLine, |
| 5 | extractTerminalLinks, | 5 | extractTerminalLinks, |
| 6 | isTerminalLinkActivation, | 6 | isTerminalLinkActivation, |
| 7 | isTerminalUrl, | ||
| 7 | resolvePathLinkTarget, | 8 | resolvePathLinkTarget, |
| 8 | resolveWrappedTerminalLinkRange, | 9 | resolveWrappedTerminalLinkRange, |
| 9 | wrappedTerminalLinkRangeIntersectsBufferLine, | 10 | wrappedTerminalLinkRangeIntersectsBufferLine, |
| ... | @@ -37,6 +38,24 @@ describe("extractTerminalLinks", () => { | ... | @@ -37,6 +38,24 @@ describe("extractTerminalLinks", () => { |
| 37 | ]); | 38 | ]); |
| 38 | }); | 39 | }); |
| 39 | 40 | ||
| 41 | it("classifies uppercase schemes as URLs at activation time too", () => { | ||
| 42 | expect(isTerminalUrl("HTTPS://example.com/docs")).toBe(true); | ||
| 43 | expect(isTerminalUrl("Http://example.com")).toBe(true); | ||
| 44 | expect(isTerminalUrl("src/components/main.ts")).toBe(false); | ||
| 45 | expect(isTerminalUrl("httpsdocs/readme.md")).toBe(false); | ||
| 46 | }); | ||
| 47 | |||
| 48 | it("finds URLs regardless of scheme casing", () => { | ||
| 49 | expect(extractTerminalLinks("open HTTPS://example.com/docs")).toEqual([ | ||
| 50 | { | ||
| 51 | kind: "url", | ||
| 52 | text: "HTTPS://example.com/docs", | ||
| 53 | start: 5, | ||
| 54 | end: 29, | ||
| 55 | }, | ||
| 56 | ]); | ||
| 57 | }); | ||
| 58 | |||
| 40 | it("trims trailing punctuation from links", () => { | 59 | it("trims trailing punctuation from links", () => { |
| 41 | const line = "(https://example.com/docs), ./src/main.ts:12."; | 60 | const line = "(https://example.com/docs), ./src/main.ts:12."; |
| 42 | expect(extractTerminalLinks(line)).toEqual([ | 61 | expect(extractTerminalLinks(line)).toEqual([ |
apps/web/src/terminal-links.ts+6-2| ... | @@ -36,7 +36,7 @@ export interface WrappedTerminalLinkLine { | ... | @@ -36,7 +36,7 @@ export interface WrappedTerminalLinkLine { |
| 36 | segments: ReadonlyArray<WrappedTerminalLinkLineSegment>; | 36 | segments: ReadonlyArray<WrappedTerminalLinkLineSegment>; |
| 37 | } | 37 | } |
| 38 | 38 | ||
| 39 | const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g; | 39 | const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/giu; |
| 40 | const FILE_PATH_PATTERN = | 40 | const FILE_PATH_PATTERN = |
| 41 | /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:[\\/]|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g; | 41 | /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:[\\/]|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g; |
| 42 | const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; | 42 | const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; |
| ... | @@ -80,7 +80,7 @@ function collectMatches( | ... | @@ -80,7 +80,7 @@ function collectMatches( |
| 80 | 80 | ||
| 81 | const trimmed = trimClosingDelimiters(raw); | 81 | const trimmed = trimClosingDelimiters(raw); |
| 82 | if (trimmed.length === 0) continue; | 82 | if (trimmed.length === 0) continue; |
| 83 | if (kind === "path" && /^https?:\/\//i.test(trimmed)) continue; | 83 | if (kind === "path" && isTerminalUrl(trimmed)) continue; |
| 84 | 84 | ||
| 85 | const candidate: TerminalLinkMatch = { | 85 | const candidate: TerminalLinkMatch = { |
| 86 | kind, | 86 | kind, |
| ... | @@ -172,6 +172,10 @@ export function extractTerminalLinks(line: string): TerminalLinkMatch[] { | ... | @@ -172,6 +172,10 @@ export function extractTerminalLinks(line: string): TerminalLinkMatch[] { |
| 172 | return [...urlMatches, ...pathMatches].toSorted((a, b) => a.start - b.start); | 172 | return [...urlMatches, ...pathMatches].toSorted((a, b) => a.start - b.start); |
| 173 | } | 173 | } |
| 174 | 174 | ||
| 175 | export function isTerminalUrl(value: string): boolean { | ||
| 176 | return /^https?:\/\//iu.test(value); | ||
| 177 | } | ||
| 178 | |||
| 175 | export function collectWrappedTerminalLinkLine( | 179 | export function collectWrappedTerminalLinkLine( |
| 176 | bufferLineNumber: number, | 180 | bufferLineNumber: number, |
| 177 | getLine: (bufferLineIndex: number) => TerminalBufferLineLike | null | undefined, | 181 | getLine: (bufferLineIndex: number) => TerminalBufferLineLike | null | undefined, |
apps/web/src/terminal/ghostty/surface.test.ts-8| ... | @@ -20,7 +20,6 @@ import { | ... | @@ -20,7 +20,6 @@ import { |
| 20 | resolveTerminalMouseTrackingState, | 20 | resolveTerminalMouseTrackingState, |
| 21 | shouldBlinkTerminalCursor, | 21 | shouldBlinkTerminalCursor, |
| 22 | shouldReportTerminalMouse, | 22 | shouldReportTerminalMouse, |
| 23 | shouldShowTerminalLinkHover, | ||
| 24 | terminalGridCellAt, | 23 | terminalGridCellAt, |
| 25 | terminalScrollbarGeometry, | 24 | terminalScrollbarGeometry, |
| 26 | terminalScrollbarOffsetAtPointer, | 25 | terminalScrollbarOffsetAtPointer, |
| ... | @@ -438,13 +437,6 @@ describe("application mouse reporting", () => { | ... | @@ -438,13 +437,6 @@ describe("application mouse reporting", () => { |
| 438 | motionData: "\u001b[<35;8;4M", | 437 | motionData: "\u001b[<35;8;4M", |
| 439 | }); | 438 | }); |
| 440 | }); | 439 | }); |
| 441 | |||
| 442 | it("only shows link hover during mouse tracking when the link modifier is held", () => { | ||
| 443 | expect(shouldShowTerminalLinkHover(false, false)).toBe(true); | ||
| 444 | expect(shouldShowTerminalLinkHover(false, true)).toBe(true); | ||
| 445 | expect(shouldShowTerminalLinkHover(true, false)).toBe(false); | ||
| 446 | expect(shouldShowTerminalLinkHover(true, true)).toBe(true); | ||
| 447 | }); | ||
| 448 | }); | 440 | }); |
| 449 | 441 | ||
| 450 | describe("terminal font resolution", () => { | 442 | describe("terminal font resolution", () => { |
apps/web/src/terminal/ghostty/surface.ts+1-11| ... | @@ -483,13 +483,6 @@ export function isTerminalLinkPointerGesture( | ... | @@ -483,13 +483,6 @@ export function isTerminalLinkPointerGesture( |
| 483 | : event.ctrlKey && !event.metaKey; | 483 | : event.ctrlKey && !event.metaKey; |
| 484 | } | 484 | } |
| 485 | 485 | ||
| 486 | export function shouldShowTerminalLinkHover( | ||
| 487 | mouseTracking: boolean, | ||
| 488 | linkModifierActive: boolean, | ||
| 489 | ): boolean { | ||
| 490 | return !mouseTracking || linkModifierActive; | ||
| 491 | } | ||
| 492 | |||
| 493 | export function ghosttyMouseButton(button: number): number | null { | 486 | export function ghosttyMouseButton(button: number): number | null { |
| 494 | switch (button) { | 487 | switch (button) { |
| 495 | case 0: | 488 | case 0: |
| ... | @@ -1409,10 +1402,7 @@ export class GhosttyTerminalSurface { | ... | @@ -1409,10 +1402,7 @@ export class GhosttyTerminalSurface { |
| 1409 | 1402 | ||
| 1410 | private refreshHoveredLink(): void { | 1403 | private refreshHoveredLink(): void { |
| 1411 | const pointer = this.hoverPointer; | 1404 | const pointer = this.hoverPointer; |
| 1412 | const link = | 1405 | const link = pointer && this.linkModifierActive ? this.linkAt(pointer.x, pointer.y) : null; |
| 1413 | pointer && shouldShowTerminalLinkHover(this.core.isMouseTracking(), this.linkModifierActive) | ||
| 1414 | ? this.linkAt(pointer.x, pointer.y) | ||
| 1415 | : null; | ||
| 1416 | this.setHoveredLink(link); | 1406 | this.setHoveredLink(link); |
| 1417 | } | 1407 | } |
| 1418 | 1408 |