diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 4f216287283..024572fc7e3 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -3,7 +3,6 @@ import { ClipboardAddon } from "@xterm/addon-clipboard"; import { FitAddon } from "@xterm/addon-fit"; import { ImageAddon } from "@xterm/addon-image"; import { Unicode11Addon } from "@xterm/addon-unicode11"; -import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import type { ITheme } from "@xterm/xterm"; import { Terminal as XTerm } from "@xterm/xterm"; @@ -13,7 +12,7 @@ import { toXtermTheme } from "renderer/stores/theme/utils"; import { isAppHotkey } from "shared/hotkeys"; import { builtInThemes, DEFAULT_THEME_ID } from "shared/themes"; import { RESIZE_DEBOUNCE_MS, TERMINAL_OPTIONS } from "./config"; -import { FilePathLinkProvider } from "./FilePathLinkProvider"; +import { FilePathLinkProvider, UrlLinkProvider } from "./link-providers"; import { suppressQueryResponses } from "./suppressQueryResponses"; /** @@ -103,17 +102,6 @@ export function createTerminalInstance( const xterm = new XTerm(options); const fitAddon = new FitAddon(); - const webLinksAddon = new WebLinksAddon((event, uri) => { - // Only open URLs on CMD+click (Mac) or Ctrl+click (Windows/Linux) - if (!event.metaKey && !event.ctrlKey) { - return; - } - event.preventDefault(); - trpcClient.external.openUrl.mutate(uri).catch((error) => { - console.error("[Terminal] Failed to open URL:", uri, error); - }); - }); - const clipboardAddon = new ClipboardAddon(); const unicode11Addon = new Unicode11Addon(); const imageAddon = new ImageAddon(); @@ -123,7 +111,6 @@ export function createTerminalInstance( xterm.loadAddon(fitAddon); const renderer = loadRenderer(xterm); - xterm.loadAddon(webLinksAddon); xterm.loadAddon(clipboardAddon); xterm.loadAddon(unicode11Addon); xterm.loadAddon(imageAddon); @@ -140,6 +127,13 @@ export function createTerminalInstance( const cleanupQuerySuppression = suppressQueryResponses(xterm); + const urlLinkProvider = new UrlLinkProvider(xterm, (_event, uri) => { + trpcClient.external.openUrl.mutate(uri).catch((error) => { + console.error("[Terminal] Failed to open URL:", uri, error); + }); + }); + xterm.registerLinkProvider(urlLinkProvider); + const filePathLinkProvider = new FilePathLinkProvider( xterm, (_event, path, line, column) => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/FilePathLinkProvider.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/file-path-link-provider.test.ts similarity index 92% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/FilePathLinkProvider.test.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/file-path-link-provider.test.ts index 3d919b93d8f..5a733a3a364 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/FilePathLinkProvider.test.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/file-path-link-provider.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it, mock } from "bun:test"; import type { IBufferLine, ILink, Terminal } from "@xterm/xterm"; -import { FilePathLinkProvider } from "./FilePathLinkProvider"; +import { FilePathLinkProvider } from "./file-path-link-provider"; -// Helper to create mock buffer lines function createMockLine(text: string, isWrapped = false): IBufferLine { return { translateToString: () => text, @@ -13,7 +12,6 @@ function createMockLine(text: string, isWrapped = false): IBufferLine { } as unknown as IBufferLine; } -// Helper to create a mock terminal with given lines function createMockTerminal( lines: Array<{ text: string; isWrapped?: boolean }>, ): Terminal { @@ -33,7 +31,6 @@ function createMockTerminal( } as unknown as Terminal; } -// Helper to extract links from callback function getLinks( provider: FilePathLinkProvider, lineNumber: number, @@ -183,7 +180,6 @@ describe("FilePathLinkProvider", () => { describe("wrapped lines - forward looking (next line)", () => { it("should detect path that spans current line and wrapped next line", async () => { - // Simulate: "/path/to/very/long/fi" + "le/name.ts" const terminal = createMockTerminal([ { text: "/path/to/very/long/fi" }, { text: "le/name.ts", isWrapped: true }, @@ -200,26 +196,24 @@ describe("FilePathLinkProvider", () => { }); it("should calculate correct range for multi-line path starting on current line", async () => { - // Line 1 is 21 chars, Line 2 is 10 chars const terminal = createMockTerminal([ - { text: "/path/to/very/long/fi" }, // 21 chars - { text: "le/name.ts", isWrapped: true }, // 10 chars + { text: "/path/to/very/long/fi" }, + { text: "le/name.ts", isWrapped: true }, ]); const onOpen = mock(); const provider = new FilePathLinkProvider(terminal, onOpen); const links = await getLinks(provider, 1); - expect(links[0].range.start.x).toBe(1); // Start at column 1 - expect(links[0].range.start.y).toBe(1); // Line 1 - expect(links[0].range.end.x).toBe(11); // Ends at column 11 on line 2 (10 chars + 1) - expect(links[0].range.end.y).toBe(2); // Line 2 + expect(links[0].range.start.x).toBe(1); + expect(links[0].range.start.y).toBe(1); + expect(links[0].range.end.x).toBe(11); + expect(links[0].range.end.y).toBe(2); }); }); describe("wrapped lines - backward looking (previous line)", () => { it("should detect path from previous line when current line is wrapped", async () => { - // Simulate: "/path/to/very/long/fi" + "le/name.ts" const terminal = createMockTerminal([ { text: "/path/to/very/long/fi" }, { text: "le/name.ts", isWrapped: true }, @@ -227,7 +221,6 @@ describe("FilePathLinkProvider", () => { const onOpen = mock(); const provider = new FilePathLinkProvider(terminal, onOpen); - // When scanning line 2 (the wrapped line), it should find the full path const links = await getLinks(provider, 2); expect(links.length).toBe(1); @@ -244,7 +237,6 @@ describe("FilePathLinkProvider", () => { const onOpen = mock(); const provider = new FilePathLinkProvider(terminal, onOpen); - // Scan from line 2 (the wrapped line) const links = await getLinks(provider, 2); expect(links.length).toBe(1); @@ -254,7 +246,6 @@ describe("FilePathLinkProvider", () => { describe("three-line wrapping", () => { it("should handle path spanning three lines when scanned from middle", async () => { - // This tests when current line is wrapped AND next line is also wrapped const terminal = createMockTerminal([ { text: "/path/to/ve" }, { text: "ry/long/dir", isWrapped: true }, @@ -263,7 +254,6 @@ describe("FilePathLinkProvider", () => { const onOpen = mock(); const provider = new FilePathLinkProvider(terminal, onOpen); - // Scan from middle line const links = await getLinks(provider, 2); expect(links.length).toBe(1); @@ -275,7 +265,7 @@ describe("FilePathLinkProvider", () => { it("should not combine lines that are not wrapped", async () => { const terminal = createMockTerminal([ { text: "/path/one.ts" }, - { text: "/path/two.ts", isWrapped: false }, // Real newline, not wrapped + { text: "/path/two.ts", isWrapped: false }, ]); const onOpen = mock(); const provider = new FilePathLinkProvider(terminal, onOpen); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/file-path-link-provider.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/file-path-link-provider.ts new file mode 100644 index 00000000000..2d7e000c93d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/file-path-link-provider.ts @@ -0,0 +1,81 @@ +import type { Terminal } from "@xterm/xterm"; +import { parseLineColumnPath } from "line-column-path"; +import { + type LinkMatch, + MultiLineLinkProvider, +} from "./multi-line-link-provider"; + +export class FilePathLinkProvider extends MultiLineLinkProvider { + private readonly FILE_PATH_PATTERN = + /((?:~|\.{1,2})?\/[^\s:()]+|(?:\.?[a-zA-Z0-9_-]+\/)+[a-zA-Z0-9_\-.]+)(?::(\d+))?(?::(\d+))?/g; + + constructor( + terminal: Terminal, + private readonly onOpen: ( + event: MouseEvent, + path: string, + line?: number, + column?: number, + ) => void, + ) { + super(terminal); + } + + protected getPattern(): RegExp { + return new RegExp(this.FILE_PATH_PATTERN.source, "g"); + } + + protected shouldSkipMatch(match: LinkMatch): boolean { + const { + text: matchText, + index: matchIndex, + combinedText, + regexMatch, + } = match; + const filePath = regexMatch[1]; + + if ( + matchText.startsWith("http://") || + matchText.startsWith("https://") || + matchText.startsWith("ftp://") || + (matchIndex > 0 && + combinedText[matchIndex - 1] === ":" && + (matchText.startsWith("//") || matchText.startsWith("http"))) + ) { + return true; + } + + if (/^v?\d+\.\d+(\.\d+)*$/.test(filePath)) { + return true; + } + + const contextStart = Math.max(0, matchIndex - 30); + const contextEnd = matchIndex + matchText.length; + const context = combinedText.substring(contextStart, contextEnd); + if (/@\d+\.\d+/.test(context)) { + return true; + } + + if (/^\d+(:\d+)*$/.test(matchText)) { + return true; + } + + return false; + } + + protected handleActivation(event: MouseEvent, text: string): void { + if (!event.metaKey && !event.ctrlKey) { + return; + } + + event.preventDefault(); + + const parsed = parseLineColumnPath(text); + + if (!parsed.file) { + return; + } + + this.onOpen(event, parsed.file, parsed.line, parsed.column); + } +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/index.ts new file mode 100644 index 00000000000..fca6aa68c10 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/index.ts @@ -0,0 +1,6 @@ +export { FilePathLinkProvider } from "./file-path-link-provider"; +export { + type LinkMatch, + MultiLineLinkProvider, +} from "./multi-line-link-provider"; +export { UrlLinkProvider } from "./url-link-provider"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/FilePathLinkProvider.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/multi-line-link-provider.ts similarity index 53% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/FilePathLinkProvider.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/multi-line-link-provider.ts index 12960c3c8cf..b1d1a94e1e2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/FilePathLinkProvider.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/multi-line-link-provider.ts @@ -1,19 +1,36 @@ import type { ILink, ILinkProvider, Terminal } from "@xterm/xterm"; -import { parseLineColumnPath } from "line-column-path"; - -export class FilePathLinkProvider implements ILinkProvider { - private readonly FILE_PATH_PATTERN = - /((?:~|\.{1,2})?\/[^\s:()]+|(?:\.?[a-zA-Z0-9_-]+\/)+[a-zA-Z0-9_\-.]+)(?::(\d+))?(?::(\d+))?/; - - constructor( - private readonly terminal: Terminal, - private readonly onOpen: ( - event: MouseEvent, - path: string, - line?: number, - column?: number, - ) => void, - ) {} + +export interface LinkMatch { + text: string; + index: number; + end: number; + combinedText: string; + regexMatch: RegExpMatchArray; +} + +/** + * Abstract base class for terminal link providers that handles links spanning + * up to 3 wrapped lines (previous + current + next). Links spanning 4+ wrapped + * lines will be truncated. + */ +export abstract class MultiLineLinkProvider implements ILinkProvider { + constructor(protected readonly terminal: Terminal) {} + + protected abstract getPattern(): RegExp; + protected abstract shouldSkipMatch(match: LinkMatch): boolean; + protected abstract handleActivation( + event: MouseEvent, + text: string, + regexMatch: RegExpMatchArray, + ): void; + + /** + * Optional hook to transform a match before creating the link. + * Useful for stripping trailing characters. Return null to skip the match. + */ + protected transformMatch(match: LinkMatch): LinkMatch | null { + return match; + } provideLinks( bufferLineNumber: number, @@ -30,77 +47,55 @@ export class FilePathLinkProvider implements ILinkProvider { const lineLength = lineText.length; const isCurrentLineWrapped = line.isWrapped; - // Check previous line if current line is a wrapped continuation const prevLine = isCurrentLineWrapped ? this.terminal.buffer.active.getLine(lineIndex - 1) : null; const prevLineText = prevLine ? prevLine.translateToString(true) : ""; const prevLineLength = prevLineText.length; - // Check if the next line is a wrapped continuation of this line const nextLine = this.terminal.buffer.active.getLine(lineIndex + 1); const nextLineIsWrapped = nextLine?.isWrapped ?? false; const nextLineText = nextLineIsWrapped && nextLine ? nextLine.translateToString(true) : ""; - // Combined text for matching paths that may span wrap points - // Format: [prevLine] + currentLine + [nextLine] const combinedText = prevLineText + lineText + nextLineText; - const currentLineOffset = prevLineLength; // Offset where current line starts in combined text + const currentLineOffset = prevLineLength; const links: ILink[] = []; - const regex = new RegExp(this.FILE_PATH_PATTERN, "g"); + const regex = this.getPattern(); for (const match of combinedText.matchAll(regex)) { const matchText = match[0]; - const filePath = match[1]; const matchIndex = match.index ?? 0; const matchEnd = matchIndex + matchText.length; - // Only process matches that overlap with the current line - // Skip if match is entirely in previous line or entirely in next line const currentLineStart = currentLineOffset; const currentLineEnd = currentLineOffset + lineLength; if (matchEnd <= currentLineStart || matchIndex >= currentLineEnd) { - // Match doesn't touch current line, skip it continue; } - // Skip URLs - if ( - matchText.startsWith("http://") || - matchText.startsWith("https://") || - matchText.startsWith("ftp://") || - (matchIndex > 0 && - combinedText[matchIndex - 1] === ":" && - (matchText.startsWith("//") || matchText.startsWith("http"))) - ) { - continue; - } - - // Skip version strings (v1.2.3 format) - if (/^v?\d+\.\d+(\.\d+)*$/.test(filePath)) { - continue; - } + let linkMatch: LinkMatch | null = { + text: matchText, + index: matchIndex, + end: matchEnd, + combinedText, + regexMatch: match, + }; - // Skip npm package references (@version context) - const contextStart = Math.max(0, matchIndex - 30); - const contextEnd = matchIndex + matchText.length; - const context = combinedText.substring(contextStart, contextEnd); - if (/@\d+\.\d+/.test(context)) { + if (this.shouldSkipMatch(linkMatch)) { continue; } - // Skip pure numbers - if (/^\d+(:\d+)*$/.test(matchText)) { + linkMatch = this.transformMatch(linkMatch); + if (!linkMatch) { continue; } - // Calculate the link range across potentially multiple lines const range = this.calculateLinkRange( - matchIndex, - matchEnd, + linkMatch.index, + linkMatch.end, prevLineLength, lineLength, bufferLineNumber, @@ -110,9 +105,9 @@ export class FilePathLinkProvider implements ILinkProvider { links.push({ range, - text: matchText, + text: linkMatch.text, activate: (event: MouseEvent, text: string) => { - this.handleActivation(event, text); + this.handleActivation(event, text, match); }, }); } @@ -132,7 +127,6 @@ export class FilePathLinkProvider implements ILinkProvider { const currentLineStart = prevLineLength; const currentLineEnd = prevLineLength + lineLength; - // Determine which lines the match spans const startsInPrevLine = isCurrentLineWrapped && matchIndex < currentLineStart; const endsInNextLine = nextLineIsWrapped && matchEnd > currentLineEnd; @@ -143,25 +137,20 @@ export class FilePathLinkProvider implements ILinkProvider { let endX: number; if (startsInPrevLine) { - // Match starts in previous line startY = bufferLineNumber - 1; startX = matchIndex + 1; } else { - // Match starts in current line startY = bufferLineNumber; startX = matchIndex - currentLineStart + 1; } if (endsInNextLine) { - // Match ends in next line endY = bufferLineNumber + 1; endX = matchEnd - currentLineEnd + 1; } else if (matchEnd <= currentLineStart) { - // Match ends in previous line (shouldn't happen due to earlier filter) endY = bufferLineNumber - 1; endX = matchEnd + 1; } else { - // Match ends in current line endY = bufferLineNumber; endX = matchEnd - currentLineStart + 1; } @@ -171,20 +160,4 @@ export class FilePathLinkProvider implements ILinkProvider { end: { x: endX, y: endY }, }; } - - private handleActivation(event: MouseEvent, text: string): void { - if (!event.metaKey && !event.ctrlKey) { - return; - } - - event.preventDefault(); - - const parsed = parseLineColumnPath(text); - - if (!parsed.file) { - return; - } - - this.onOpen(event, parsed.file, parsed.line, parsed.column); - } } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.test.ts new file mode 100644 index 00000000000..a8ff1ef3bf7 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.test.ts @@ -0,0 +1,500 @@ +import { describe, expect, it, mock } from "bun:test"; +import type { IBufferLine, ILink, Terminal } from "@xterm/xterm"; +import { UrlLinkProvider } from "./url-link-provider"; + +function createMockLine(text: string, isWrapped = false): IBufferLine { + return { + translateToString: () => text, + isWrapped, + length: text.length, + getCell: mock(() => null), + getCells: mock(() => []), + } as unknown as IBufferLine; +} + +function createMockTerminal( + lines: Array<{ text: string; isWrapped?: boolean }>, +): Terminal { + const mockLines = lines.map((l) => + createMockLine(l.text, l.isWrapped ?? false), + ); + + return { + buffer: { + active: { + getLine: (index: number) => mockLines[index] ?? null, + }, + }, + element: { + style: { cursor: "" }, + }, + } as unknown as Terminal; +} + +function getLinks( + provider: UrlLinkProvider, + lineNumber: number, +): Promise { + return new Promise((resolve) => { + provider.provideLinks(lineNumber, (links) => { + resolve(links ?? []); + }); + }); +} + +describe("UrlLinkProvider", () => { + describe("basic URL detection", () => { + it("should detect https URLs", async () => { + const terminal = createMockTerminal([ + { text: "Visit https://example.com/path" }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(1); + expect(links[0].text).toBe("https://example.com/path"); + }); + + it("should detect http URLs", async () => { + const terminal = createMockTerminal([ + { text: "Visit http://example.com/path" }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(1); + expect(links[0].text).toBe("http://example.com/path"); + }); + + it("should detect URLs with query parameters", async () => { + const terminal = createMockTerminal([ + { text: "https://example.com/path?foo=bar&baz=qux" }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(1); + expect(links[0].text).toBe("https://example.com/path?foo=bar&baz=qux"); + }); + + it("should detect URLs with fragments", async () => { + const terminal = createMockTerminal([ + { text: "https://example.com/path#section" }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(1); + expect(links[0].text).toBe("https://example.com/path#section"); + }); + + it("should detect multiple URLs on one line", async () => { + const terminal = createMockTerminal([ + { text: "https://a.com and https://b.com" }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(2); + expect(links[0].text).toBe("https://a.com"); + expect(links[1].text).toBe("https://b.com"); + }); + + it("should detect URLs with port numbers", async () => { + const terminal = createMockTerminal([ + { text: "Server at http://localhost:3000/api" }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(1); + expect(links[0].text).toBe("http://localhost:3000/api"); + }); + + it("should handle URLs with parentheses (like Wikipedia)", async () => { + const terminal = createMockTerminal([ + { text: "https://en.wikipedia.org/wiki/URL_(disambiguation)" }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(1); + expect(links[0].text).toBe( + "https://en.wikipedia.org/wiki/URL_(disambiguation)", + ); + }); + + it("should strip trailing period from URL", async () => { + const terminal = createMockTerminal([ + { text: "See https://example.com." }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(1); + expect(links[0].text).toBe("https://example.com"); + }); + + it("should strip trailing comma from URL", async () => { + const terminal = createMockTerminal([ + { text: "Visit https://example.com, then continue." }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(1); + expect(links[0].text).toBe("https://example.com"); + }); + + it("should strip multiple trailing punctuation", async () => { + const terminal = createMockTerminal([ + { text: "Check https://example.com..." }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(1); + expect(links[0].text).toBe("https://example.com"); + }); + + it("should strip trailing exclamation and question marks", async () => { + const terminal = createMockTerminal([ + { text: "Is it https://example.com?" }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(1); + expect(links[0].text).toBe("https://example.com"); + }); + + it("should trim unbalanced trailing parenthesis", async () => { + const terminal = createMockTerminal([ + { text: "(see https://example.com)" }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(1); + expect(links[0].text).toBe("https://example.com"); + }); + + it("should keep balanced parentheses in URL", async () => { + const terminal = createMockTerminal([ + { text: "https://example.com/path(foo)" }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(1); + expect(links[0].text).toBe("https://example.com/path(foo)"); + }); + + it("should handle URL in parentheses with balanced parens inside", async () => { + const terminal = createMockTerminal([ + { text: "(see https://en.wikipedia.org/wiki/URL_(disambiguation))" }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(1); + expect(links[0].text).toBe( + "https://en.wikipedia.org/wiki/URL_(disambiguation)", + ); + }); + }); + + describe("wrapped lines - forward looking (next line)", () => { + it("should detect URL that spans current line and wrapped next line", async () => { + const terminal = createMockTerminal([ + { text: "https://example.com/ver" }, + { text: "y/long/path/here", isWrapped: true }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(1); + expect(links[0].text).toBe("https://example.com/very/long/path/here"); + expect(links[0].range.start.y).toBe(1); + expect(links[0].range.end.y).toBe(2); + }); + + it("should calculate correct range for multi-line URL starting on current line", async () => { + const terminal = createMockTerminal([ + { text: "https://example.com/ver" }, + { text: "y/long/path/here", isWrapped: true }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links[0].range.start.x).toBe(1); + expect(links[0].range.start.y).toBe(1); + expect(links[0].range.end.x).toBe(17); + expect(links[0].range.end.y).toBe(2); + }); + }); + + describe("wrapped lines - backward looking (previous line)", () => { + it("should detect URL from previous line when current line is wrapped", async () => { + const terminal = createMockTerminal([ + { text: "https://example.com/ver" }, + { text: "y/long/path/here", isWrapped: true }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 2); + + expect(links.length).toBe(1); + expect(links[0].text).toBe("https://example.com/very/long/path/here"); + expect(links[0].range.start.y).toBe(1); + expect(links[0].range.end.y).toBe(2); + }); + + it("should handle clicking on wrapped portion of URL", async () => { + const terminal = createMockTerminal([ + { text: "Visit https://github.com/" }, + { text: "anthropics/claude-code/issues", isWrapped: true }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 2); + + expect(links.length).toBe(1); + expect(links[0].text).toBe( + "https://github.com/anthropics/claude-code/issues", + ); + }); + }); + + describe("three-line wrapping", () => { + it("should handle URL spanning three lines when scanned from middle", async () => { + const terminal = createMockTerminal([ + { text: "https://exa" }, + { text: "mple.com/ve", isWrapped: true }, + { text: "ry/long/url", isWrapped: true }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 2); + + expect(links.length).toBe(1); + expect(links[0].text).toBe("https://example.com/very/long/url"); + }); + }); + + describe("non-wrapped lines", () => { + it("should not combine lines that are not wrapped", async () => { + const terminal = createMockTerminal([ + { text: "https://a.com" }, + { text: "https://b.com", isWrapped: false }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(1); + expect(links[0].text).toBe("https://a.com"); + }); + + it("should handle URLs on separate lines independently", async () => { + const terminal = createMockTerminal([ + { text: "https://a.com" }, + { text: "https://b.com", isWrapped: false }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links1 = await getLinks(provider, 1); + const links2 = await getLinks(provider, 2); + + expect(links1.length).toBe(1); + expect(links1[0].text).toBe("https://a.com"); + expect(links2.length).toBe(1); + expect(links2[0].text).toBe("https://b.com"); + }); + }); + + describe("handleActivation", () => { + it("should require metaKey (Cmd) or ctrlKey for activation", async () => { + const terminal = createMockTerminal([{ text: "https://example.com" }]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + const mockEvent = { + metaKey: false, + ctrlKey: false, + preventDefault: mock(), + } as unknown as MouseEvent; + + links[0].activate(mockEvent, "https://example.com"); + + expect(onOpen).not.toHaveBeenCalled(); + }); + + it("should activate with metaKey (Cmd)", async () => { + const terminal = createMockTerminal([{ text: "https://example.com" }]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + const mockEvent = { + metaKey: true, + ctrlKey: false, + preventDefault: mock(), + } as unknown as MouseEvent; + + links[0].activate(mockEvent, "https://example.com"); + + expect(onOpen).toHaveBeenCalled(); + expect(onOpen.mock.calls[0][1]).toBe("https://example.com"); + }); + + it("should activate with ctrlKey", async () => { + const terminal = createMockTerminal([{ text: "https://example.com" }]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + const mockEvent = { + metaKey: false, + ctrlKey: true, + preventDefault: mock(), + } as unknown as MouseEvent; + + links[0].activate(mockEvent, "https://example.com"); + + expect(onOpen).toHaveBeenCalled(); + }); + }); + + describe("ReDoS prevention", () => { + it("should handle pathological input without hanging", async () => { + // This input would cause catastrophic backtracking with nested quantifiers + // Old pattern: (?:[^\s<>[\]()'"]+|\([^\s<>[\]()'"]*\))+ + const maliciousInput = `https://${"a".repeat(100)}(`; + const terminal = createMockTerminal([{ text: maliciousInput }]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const start = performance.now(); + const links = await getLinks(provider, 1); + const elapsed = performance.now() - start; + + // Should complete in under 100ms (old pattern would take seconds/minutes) + expect(elapsed).toBeLessThan(100); + expect(links.length).toBe(1); + // Unbalanced paren is trimmed + expect(links[0].text).toBe(`https://${"a".repeat(100)}`); + }); + + it("should handle repeated parentheses pattern efficiently", async () => { + // Another ReDoS pattern: alternating parens + const input = `https://example.com/${"()".repeat(50)}`; + const terminal = createMockTerminal([{ text: input }]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const start = performance.now(); + const links = await getLinks(provider, 1); + const elapsed = performance.now() - start; + + expect(elapsed).toBeLessThan(100); + expect(links.length).toBe(1); + }); + + it("should handle long URL with unmatched open paren", async () => { + const input = `https://example.com/${"x".repeat(50)}(${"y".repeat(50)}`; + const terminal = createMockTerminal([{ text: input }]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const start = performance.now(); + const links = await getLinks(provider, 1); + const elapsed = performance.now() - start; + + expect(elapsed).toBeLessThan(100); + expect(links.length).toBe(1); + }); + }); + + describe("edge cases", () => { + it("should handle empty lines", async () => { + const terminal = createMockTerminal([{ text: "" }]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(0); + }); + + it("should handle line that doesn't exist", async () => { + const terminal = createMockTerminal([{ text: "Hello" }]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 999); + + expect(links.length).toBe(0); + }); + + it("should handle lines without URLs", async () => { + const terminal = createMockTerminal([ + { text: "This is just some text without links" }, + ]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(0); + }); + + it("should not match file paths as URLs", async () => { + const terminal = createMockTerminal([{ text: "/path/to/file.ts" }]); + const onOpen = mock(); + const provider = new UrlLinkProvider(terminal, onOpen); + + const links = await getLinks(provider, 1); + + expect(links.length).toBe(0); + }); + }); +}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.ts new file mode 100644 index 00000000000..ce07526e516 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.ts @@ -0,0 +1,78 @@ +import type { Terminal } from "@xterm/xterm"; +import { + type LinkMatch, + MultiLineLinkProvider, +} from "./multi-line-link-provider"; + +const TRAILING_PUNCTUATION = /[.,;:!?]+$/; + +function trimUnbalancedParens(url: string): string { + let openCount = 0; + let endIndex = url.length; + + for (let i = 0; i < url.length; i++) { + if (url[i] === "(") { + openCount++; + } else if (url[i] === ")") { + if (openCount > 0) { + openCount--; + } else { + endIndex = i; + break; + } + } + } + + let result = url.slice(0, endIndex); + + while (result.endsWith("(")) { + result = result.slice(0, -1); + } + + return result; +} + +export class UrlLinkProvider extends MultiLineLinkProvider { + private readonly URL_PATTERN = /\bhttps?:\/\/[^\s<>[\]'"]+/g; + + constructor( + terminal: Terminal, + private readonly onOpen: (event: MouseEvent, uri: string) => void, + ) { + super(terminal); + } + + protected getPattern(): RegExp { + return new RegExp(this.URL_PATTERN.source, "g"); + } + + protected shouldSkipMatch(_match: LinkMatch): boolean { + return false; + } + + protected transformMatch(match: LinkMatch): LinkMatch | null { + let text = match.text; + text = trimUnbalancedParens(text); + text = text.replace(TRAILING_PUNCTUATION, ""); + + if (text === match.text) { + return match; + } + + const charsRemoved = match.text.length - text.length; + return { + ...match, + text, + end: match.end - charsRemoved, + }; + } + + protected handleActivation(event: MouseEvent, text: string): void { + if (!event.metaKey && !event.ctrlKey) { + return; + } + + event.preventDefault(); + this.onOpen(event, text); + } +}