Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

/**
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand All @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 {
Expand All @@ -33,7 +31,6 @@ function createMockTerminal(
} as unknown as Terminal;
}

// Helper to extract links from callback
function getLinks(
provider: FilePathLinkProvider,
lineNumber: number,
Expand Down Expand Up @@ -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 },
Expand All @@ -200,34 +196,31 @@ 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 },
]);
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);
Expand All @@ -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);
Expand All @@ -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 },
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading