diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index f6df5912ecdd1..dafb07f354437 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -187,6 +187,25 @@ describe("extractTerminalLinks", () => { ]); }); + it.each([ + "connected to redis://127.0.0.1:6379/0", + "connected to postgres://localhost:5432/app", + "uploaded s3://bucket/reports/today.csv", + " at main (file:///Users/jane/app/src/index.mjs:3:7)", + "+redis://localhost:6379/0", + "-redis://localhost:6379/0", + ])("does not turn part of a non-web URL into an editor link: %s", (line) => { + expect(extractTerminalLinks(line)).toEqual([]); + }); + + it("keeps file links beside a non-web URL", () => { + const line = "src/db.ts:4 redis://localhost:6379/0 C:/app/src/db.ts:9"; + expect(extractTerminalLinks(line)).toEqual([ + { kind: "path", text: "src/db.ts:4", start: 0, end: 11 }, + { kind: "path", text: "C:/app/src/db.ts:9", start: 37, end: 55 }, + ]); + }); + it("keeps a question mark inside a filesystem path", () => { const path = "/tmp/report?draft"; expect(extractTerminalLinks(`wrote ${path}`)).toEqual([ diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 1c078d86cd20f..68bd4c8d5a835 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -41,6 +41,9 @@ const TRAILING_PATH_PUNCTUATION_PATTERN = /[.,;:!?]+$/; // A question mark is legal in filenames; only exclude query-bearing request targets. const REQUEST_ROUTE_PATTERN = /(?:^|[\s"'`])(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|CONNECT|TRACE|proxying)\s+["'`]?(?:\/|[A-Za-z0-9_-]+\/)[^\s?]*\?\S*/g; +// The tail of `redis://host/0` or `file:///a.mjs` is not a path. One-letter schemes stay drives, +// a diff's `+` or `-` may lead, and the lookbehind keeps runs from being rescanned at every suffix. +const SCHEME_URL_PATTERN = /(?]+/gu; function trimClosingDelimiters(value: string, kind: TerminalLinkKind): string { let output = value.replace( @@ -110,10 +113,10 @@ export function extractTerminalLinks(line: string): TerminalLinkMatch[] { const promptMatches: TerminalLinkMatch[] = promptPath ? [{ kind: "path", text: promptPath, start: 3, end: 3 + promptPath.length }] : []; - const requestRoutes = Array.from(line.matchAll(REQUEST_ROUTE_PATTERN), (match) => ({ - start: match.index, - end: match.index + match[0].length, - })); + const nonPathRanges = Array.from( + [...line.matchAll(REQUEST_ROUTE_PATTERN), ...line.matchAll(SCHEME_URL_PATTERN)], + (match) => ({ start: match.index, end: match.index + match[0].length }), + ); const urlMatches = collectMatches(line, "url", URL_PATTERN, promptMatches); const quotedMatches = collectMatches(line, "path", QUOTED_FILE_PATH_PATTERN, [ ...promptMatches, @@ -126,7 +129,7 @@ export function extractTerminalLinks(line: string): TerminalLinkMatch[] { ]); return [...promptMatches, ...urlMatches, ...quotedMatches, ...pathMatches] .filter( - (match) => match.kind === "url" || !requestRoutes.some((route) => overlaps(match, route)), + (match) => match.kind === "url" || !nonPathRanges.some((range) => overlaps(match, range)), ) .toSorted((a, b) => a.start - b.start); }