Link terminal file paths to workspace previews (#1174)

This commit is contained in:
Mohamed Boudra
2026-05-25 19:12:43 +08:00
committed by GitHub
parent 1e68283565
commit e40ad0c00e
12 changed files with 1098 additions and 7 deletions
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { detectTerminalLocalLinks } from "./terminal-local-link-parsing";
describe("detectTerminalLocalLinks", () => {
it("detects VS Code-style filename and line suffixes", () => {
expect(detectTerminalLocalLinks("file.ts:42")).toMatchObject([
{
path: { index: 0, text: "file.ts" },
suffix: { row: 42, col: undefined, rowEnd: undefined },
},
]);
});
it("detects line and column suffixes", () => {
expect(detectTerminalLocalLinks("src/file.ts:42:7")).toMatchObject([
{
path: { index: 0, text: "src/file.ts" },
suffix: { row: 42, col: 7, rowEnd: undefined },
},
]);
});
it("detects quoted Python traceback paths", () => {
expect(detectTerminalLocalLinks(' File "pkg/file.py", line 12')).toMatchObject([
{
path: { index: 8, text: "pkg/file.py" },
suffix: { row: 12 },
},
]);
});
it("detects paths without suffixes", () => {
expect(detectTerminalLocalLinks("changed packages/app/src/file.ts")).toMatchObject([
{
path: { index: 8, text: "packages/app/src/file.ts" },
suffix: undefined,
},
]);
});
});
@@ -0,0 +1,254 @@
/*
* Adapted from MIT-licensed upstream terminal link parsing.
* Copyright (c) Microsoft Corporation.
*/
export interface TerminalParsedLink {
path: TerminalLinkPartialRange;
prefix?: TerminalLinkPartialRange;
suffix?: TerminalLinkSuffix;
}
export interface TerminalLinkSuffix {
row: number | undefined;
col: number | undefined;
rowEnd: number | undefined;
colEnd: number | undefined;
suffix: TerminalLinkPartialRange;
}
export interface TerminalLinkPartialRange {
index: number;
text: string;
}
const linkSuffixRegexEol = generateLinkSuffixRegex(true);
const linkSuffixRegex = generateLinkSuffixRegex(false);
function generateLinkSuffixRegex(eolOnly: boolean): RegExp {
let rowIndex = 0;
let colIndex = 0;
let rowEndIndex = 0;
let colEndIndex = 0;
const row = () => `(?<row${rowIndex++}>\\d+)`;
const col = () => `(?<col${colIndex++}>\\d+)`;
const rowEnd = () => `(?<rowEnd${rowEndIndex++}>\\d+)`;
const colEnd = () => `(?<colEnd${colEndIndex++}>\\d+)`;
const eolSuffix = eolOnly ? "$" : "";
const clauses = [
`(?::|#| |['"],|, )${row()}([:.]${col()}(?:-(?:${rowEnd()}\\.)?${colEnd()})?)?${eolSuffix}`,
`['"]?(?:,? |: ?| on )lines? ${row()}(?:-${rowEnd()})?(?:,? (?:col(?:umn)?|characters?) ${col()}(?:-${colEnd()})?)?${eolSuffix}`,
`:? ?[\\[\\(]${row()}(?:(?:, ?|:)${col()})?[\\]\\)]${eolSuffix}`,
];
return new RegExp(`(${clauses.join("|").replace(/ /g, "[\u00A0 ]")})`, eolOnly ? undefined : "g");
}
export function getTerminalLinkSuffix(link: string): TerminalLinkSuffix | null {
return toLinkSuffix(linkSuffixRegexEol.exec(link));
}
function detectLinkSuffixes(line: string): TerminalLinkSuffix[] {
const results: TerminalLinkSuffix[] = [];
linkSuffixRegex.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = linkSuffixRegex.exec(line)) !== null) {
const suffix = toLinkSuffix(match);
if (!suffix) {
break;
}
results.push(suffix);
}
return results;
}
function toLinkSuffix(match: RegExpExecArray | null): TerminalLinkSuffix | null {
const groups = match?.groups;
if (!groups || match.length < 1) {
return null;
}
return {
row: parseIntOptional(groups.row0 || groups.row1 || groups.row2),
col: parseIntOptional(groups.col0 || groups.col1 || groups.col2),
rowEnd: parseIntOptional(groups.rowEnd0 || groups.rowEnd1 || groups.rowEnd2),
colEnd: parseIntOptional(groups.colEnd0 || groups.colEnd1 || groups.colEnd2),
suffix: { index: match.index, text: match[0] },
};
}
function parseIntOptional(value: string | undefined): number | undefined {
return value === undefined ? undefined : parseInt(value, 10);
}
const linkWithSuffixPathCharacters = /(?<path>(?:file:\/\/\/)?[^\s|<>[({][^\s|<>]*)$/;
const enum RegexPathConstants {
PathPrefix = "(?:\\.\\.?|\\~|file:\\/\\/)",
PathSeparatorClause = "\\/",
ExcludedPathCharactersClause = "[^\\0<>\\?\\s!`&*()'\":;\\\\]",
ExcludedStartPathCharactersClause = "[^\\0<>\\?\\s!`&*()\\[\\]'\":;\\\\]",
WinOtherPathPrefix = "\\.\\.?|\\~",
WinPathSeparatorClause = "(?:\\\\|\\/)",
WinExcludedPathCharactersClause = "[^\\0<>\\?\\|\\/\\s!`&*()'\":;]",
WinExcludedStartPathCharactersClause = "[^\\0<>\\?\\|\\/\\s!`&*()\\[\\]'\":;]",
}
const unixLocalLinkClause = `(?:(?:${RegexPathConstants.PathPrefix}|(?:${RegexPathConstants.ExcludedStartPathCharactersClause}${RegexPathConstants.ExcludedPathCharactersClause}*))?(?:${RegexPathConstants.PathSeparatorClause}(?:${RegexPathConstants.ExcludedPathCharactersClause})+)+)`;
const winDrivePrefix = "(?:\\\\\\\\\\?\\\\|file:\\/\\/\\/)?[a-zA-Z]:";
const winLocalLinkClause = `(?:(?:(?:${winDrivePrefix}|${RegexPathConstants.WinOtherPathPrefix})|(?:${RegexPathConstants.WinExcludedStartPathCharactersClause}${RegexPathConstants.WinExcludedPathCharactersClause}*))?(?:${RegexPathConstants.WinPathSeparatorClause}(?:${RegexPathConstants.WinExcludedPathCharactersClause})+)+)`;
export function detectTerminalLocalLinks(line: string): TerminalParsedLink[] {
const results = detectLinksViaSuffix(line);
insertNonConflicting(results, detectPathsNoSuffix(line, unixLocalLinkClause));
insertNonConflicting(results, detectPathsNoSuffix(line, winLocalLinkClause));
return results;
}
function detectLinksViaSuffix(line: string): TerminalParsedLink[] {
const results: TerminalParsedLink[] = [];
const suffixes = detectLinkSuffixes(line);
for (const suffix of suffixes) {
results.push(...detectLinksForSuffix(line, suffix));
}
return results;
}
function detectLinksForSuffix(line: string, suffix: TerminalLinkSuffix): TerminalParsedLink[] {
const beforeSuffix = line.substring(0, suffix.suffix.index);
const possiblePathMatch = beforeSuffix.match(linkWithSuffixPathCharacters);
if (!possiblePathMatch?.groups?.path || possiblePathMatch.index === undefined) {
return [];
}
const pathWithPrefix = trimPathPrefix({
path: possiblePathMatch.groups.path,
startIndex: possiblePathMatch.index,
suffix,
});
if (!pathWithPrefix) {
return [];
}
const pathIndex = pathWithPrefix.startIndex + (pathWithPrefix.prefix?.text.length ?? 0);
const links: TerminalParsedLink[] = [
{
path: {
index: pathIndex,
text: pathWithPrefix.path,
},
prefix: pathWithPrefix.prefix,
suffix,
},
];
for (const match of pathWithPrefix.path.matchAll(/(?<bracket>[[(])/g)) {
const bracket = match.groups?.bracket;
if (!bracket) {
continue;
}
const nextCharacter = pathWithPrefix.path[match.index + bracket.length];
if (nextCharacter === "]" || nextCharacter === ")") {
continue;
}
links.push({
path: {
index: pathIndex + match.index + 1,
text: pathWithPrefix.path.substring(match.index + bracket.length),
},
prefix: pathWithPrefix.prefix,
suffix,
});
}
return links;
}
function trimPathPrefix(input: { path: string; startIndex: number; suffix: TerminalLinkSuffix }): {
path: string;
startIndex: number;
prefix?: TerminalLinkPartialRange;
} | null {
const prefixMatch = input.path.match(/^(?<prefix>['"]+)/);
if (!prefixMatch?.groups?.prefix) {
return { path: input.path, startIndex: input.startIndex };
}
const prefix: TerminalLinkPartialRange = {
index: input.startIndex,
text: prefixMatch.groups.prefix,
};
const path = input.path.substring(prefix.text.length);
if (path.trim().length === 0) {
return null;
}
const trimPrefixAmount = getTrimPrefixAmount(prefix.text, input.suffix);
if (trimPrefixAmount === 0) {
return { path, startIndex: input.startIndex, prefix };
}
prefix.index += trimPrefixAmount;
prefix.text = prefix.text[prefix.text.length - 1] ?? prefix.text;
return { path, startIndex: input.startIndex + trimPrefixAmount, prefix };
}
function getTrimPrefixAmount(prefixText: string, suffix: TerminalLinkSuffix): number {
const suffixQuote = suffix.suffix.text[0];
if (
prefixText.length > 1 &&
suffixQuote?.match(/['"]/) &&
prefixText[prefixText.length - 1] === suffixQuote
) {
return prefixText.length - 1;
}
return 0;
}
function detectPathsNoSuffix(line: string, clause: string): TerminalParsedLink[] {
const results: TerminalParsedLink[] = [];
const regex = new RegExp(clause, "g");
let match: RegExpExecArray | null;
while ((match = regex.exec(line)) !== null) {
let text = match[0];
let index = match.index;
if (!text) {
break;
}
if (
((line.startsWith("--- a/") || line.startsWith("+++ b/")) && index === 4) ||
(line.startsWith("diff --git") && (text.startsWith("a/") || text.startsWith("b/")))
) {
text = text.substring(2);
index += 2;
}
results.push({
path: { index, text },
prefix: undefined,
suffix: undefined,
});
}
return results;
}
function insertNonConflicting(list: TerminalParsedLink[], newItems: TerminalParsedLink[]): void {
for (const item of newItems) {
const start = item.path.index;
const end = item.path.index + item.path.text.length;
const hasConflict = list.some((existing) => {
const existingStart = existing.path.index;
const existingEnd =
existing.suffix?.suffix.index !== undefined
? existing.suffix.suffix.index + existing.suffix.suffix.text.length
: existing.path.index + existing.path.text.length;
return start < existingEnd && end > existingStart;
});
if (!hasConflict) {
list.push(item);
}
}
list.sort((left, right) => left.path.index - right.path.index);
}
@@ -0,0 +1,207 @@
import type { IBufferCell, Terminal } from "@xterm/xterm";
import { describe, expect, it, vi } from "vitest";
import { createTerminalLocalFileLinkProvider } from "./terminal-local-link-provider";
describe("createTerminalLocalFileLinkProvider", () => {
it("resolves before exposing a local file link", async () => {
const terminal = createTerminal(["file.ts:42"]);
const resolveLink = vi.fn(async () => ({ path: "/repo/src/file.ts", lineStart: 42 }));
const openLink = vi.fn();
const provider = createTerminalLocalFileLinkProvider(terminal, { resolveLink, openLink });
const links = await provideLinks(provider, 1);
expect(resolveLink).toHaveBeenCalledWith({
text: "file.ts:42",
path: "file.ts",
lineStart: 42,
});
expect(links).toHaveLength(1);
expect(links?.[0]?.text).toBe("file.ts:42");
});
it("decorates the full parsed link span", async () => {
const terminal = createTerminal(["echo README.md:5"]);
const provider = createTerminalLocalFileLinkProvider(terminal, {
resolveLink: vi.fn(async () => ({ path: "/repo/README.md", lineStart: 5 })),
openLink: vi.fn(),
});
const [link] = (await provideLinks(provider, 1)) ?? [];
expect(link?.range).toEqual({
start: { x: 6, y: 1 },
end: { x: 16, y: 1 },
});
});
it("opens resolved links with assistant-style disposition semantics", async () => {
const terminal = createTerminal(["src/file.ts:42"]);
const target = { path: "/repo/src/file.ts", lineStart: 42 };
const openLink = vi.fn();
const provider = createTerminalLocalFileLinkProvider(terminal, {
resolveLink: vi.fn(async () => target),
openLink,
});
const [link] = (await provideLinks(provider, 1)) ?? [];
link?.activate({ preventDefault: vi.fn(), ctrlKey: true } as unknown as MouseEvent, link.text);
expect(openLink).toHaveBeenCalledWith(target, "side", expect.anything());
});
it("does not expose unresolved candidates as links", async () => {
const terminal = createTerminal(["missing.ts:42"]);
const provider = createTerminalLocalFileLinkProvider(terminal, {
resolveLink: vi.fn(async () => null),
openLink: vi.fn(),
});
await expect(provideLinks(provider, 1)).resolves.toBeUndefined();
});
});
function provideLinks(
provider: ReturnType<typeof createTerminalLocalFileLinkProvider>,
bufferLineNumber: number,
) {
return new Promise<Parameters<Parameters<typeof provider.provideLinks>[1]>[0]>((resolve) => {
provider.provideLinks(bufferLineNumber, resolve);
});
}
function createTerminal(lines: string[]): Terminal {
const bufferLines = lines.map((line) => new FakeBufferLine(line));
return {
cols: 80,
buffer: {
active: {
length: bufferLines.length,
getLine: (index: number) => bufferLines[index],
getNullCell: () => new FakeBufferCell(""),
},
},
} as unknown as Terminal;
}
class FakeBufferLine {
readonly isWrapped = false;
readonly length: number;
constructor(private readonly text: string) {
this.length = text.length;
}
getCell(x: number, cell?: IBufferCell): IBufferCell | undefined {
const value = this.text[x] ?? "";
if (cell instanceof FakeBufferCell) {
cell.setValue(value);
return cell as unknown as IBufferCell;
}
return new FakeBufferCell(value) as unknown as IBufferCell;
}
translateToString(): string {
return this.text;
}
}
class FakeBufferCell {
constructor(private value: string) {}
setValue(value: string): void {
this.value = value;
}
getChars(): string {
return this.value;
}
getWidth(): number {
return this.value ? 1 : 0;
}
getCode(): number {
return this.value.codePointAt(0) ?? 0;
}
getFgColorMode(): number {
return 0;
}
getBgColorMode(): number {
return 0;
}
getFgColor(): number {
return 0;
}
getBgColor(): number {
return 0;
}
isAttributeDefault(): boolean {
return true;
}
isFgDefault(): boolean {
return true;
}
isBgDefault(): boolean {
return true;
}
isFgRGB(): boolean {
return false;
}
isBgRGB(): boolean {
return false;
}
isFgPalette(): boolean {
return false;
}
isBgPalette(): boolean {
return false;
}
isBold(): boolean {
return false;
}
isItalic(): boolean {
return false;
}
isDim(): boolean {
return false;
}
isUnderline(): boolean {
return false;
}
isBlink(): boolean {
return false;
}
isInverse(): boolean {
return false;
}
isInvisible(): boolean {
return false;
}
isStrikethrough(): boolean {
return false;
}
isOverline(): boolean {
return false;
}
}
@@ -0,0 +1,336 @@
/*
* Adapted from MIT-licensed upstream terminal link provider behavior.
* Copyright (c) Microsoft Corporation.
*/
import type { IBufferCell, IBufferRange, ILink, ILinkProvider, Terminal } from "@xterm/xterm";
import {
detectTerminalLocalLinks,
type TerminalLinkSuffix,
type TerminalParsedLink,
} from "./terminal-local-link-parsing";
export interface TerminalLocalFileLinkSource {
text: string;
path: string;
lineStart?: number;
lineEnd?: number;
}
export interface TerminalLocalFileLinkTarget {
path: string;
lineStart?: number;
lineEnd?: number;
}
export interface TerminalLocalFileLinkProviderOptions {
resolveLink: (source: TerminalLocalFileLinkSource) => Promise<TerminalLocalFileLinkTarget | null>;
openLink: (
target: TerminalLocalFileLinkTarget,
disposition: "main" | "side",
event: MouseEvent,
) => void;
}
const MAX_LINE_LENGTH = 2_000;
const MAX_LINK_LENGTH = 500;
const MAX_RESOLVED_LINKS_IN_LINE = 10;
export function createTerminalLocalFileLinkProvider(
terminal: Terminal,
options: TerminalLocalFileLinkProviderOptions,
): ILinkProvider {
return new TerminalLocalFileLinkProvider(terminal, options);
}
class TerminalLocalFileLinkProvider implements ILinkProvider {
private readonly activeRequests = new Map<number, Promise<ILink[]>>();
constructor(
private readonly terminal: Terminal,
private readonly options: TerminalLocalFileLinkProviderOptions,
) {}
async provideLinks(
bufferLineNumber: number,
callback: (links: ILink[] | undefined) => void,
): Promise<void> {
let activeRequest = this.activeRequests.get(bufferLineNumber);
if (activeRequest) {
callback(await activeRequest);
return;
}
activeRequest = this.provideLinksForLine(bufferLineNumber);
this.activeRequests.set(bufferLineNumber, activeRequest);
const links = await activeRequest;
this.activeRequests.delete(bufferLineNumber);
callback(links.length > 0 ? links : undefined);
}
private async provideLinksForLine(bufferLineNumber: number): Promise<ILink[]> {
const windowed = getWindowedLineContent(this.terminal, bufferLineNumber - 1);
if (!windowed || windowed.text.length === 0 || windowed.text.length > MAX_LINE_LENGTH) {
return [];
}
const parsedLinks = detectTerminalLocalLinks(windowed.text);
const links: ILink[] = [];
let resolvedLinkCount = 0;
for (const parsedLink of parsedLinks) {
if (parsedLink.path.text.length > MAX_LINK_LENGTH) {
continue;
}
const source = toLinkSource(windowed.text, parsedLink);
if (!source) {
continue;
}
const target = await this.options.resolveLink(source);
if (!target) {
continue;
}
const range = toBufferRange({
terminal: this.terminal,
startLine: windowed.startLine,
startIndex: parsedLink.prefix?.index ?? parsedLink.path.index,
endIndex: getParsedLinkEndIndex(parsedLink),
});
if (!range) {
continue;
}
links.push(createLocalFileLink({ range, source, target, options: this.options }));
resolvedLinkCount += 1;
if (resolvedLinkCount >= MAX_RESOLVED_LINKS_IN_LINE) {
break;
}
}
return links;
}
}
function createLocalFileLink(input: {
range: IBufferRange;
source: TerminalLocalFileLinkSource;
target: TerminalLocalFileLinkTarget;
options: TerminalLocalFileLinkProviderOptions;
}): ILink {
return {
range: input.range,
text: input.source.text,
decorations: {
pointerCursor: true,
underline: true,
},
activate: (event) => {
event.preventDefault();
const disposition = event.metaKey || event.ctrlKey ? "side" : "main";
input.options.openLink(input.target, disposition, event);
},
};
}
function toLinkSource(
lineText: string,
parsedLink: TerminalParsedLink,
): TerminalLocalFileLinkSource | null {
const path = trimLikelyTrailingPunctuation(parsedLink.path.text);
if (!path || path.length !== parsedLink.path.text.length) {
return null;
}
const lineStart = parsedLink.suffix?.row;
const lineEnd = parsedLink.suffix?.rowEnd;
const text = formatLinkSourceText({ path, suffix: parsedLink.suffix });
if (!text) {
return null;
}
const rawLinkText = lineText.slice(
parsedLink.prefix?.index ?? parsedLink.path.index,
getParsedLinkEndIndex(parsedLink),
);
if (!rawLinkText.trim()) {
return null;
}
return {
text,
path,
...(lineStart ? { lineStart } : {}),
...(lineEnd && lineStart && lineEnd >= lineStart ? { lineEnd } : {}),
};
}
function formatLinkSourceText(input: { path: string; suffix?: TerminalLinkSuffix }): string | null {
const { path, suffix } = input;
if (!suffix?.row) {
return path;
}
let text = `${path}:${suffix.row}`;
if (suffix.col) {
text += `:${suffix.col}`;
}
if (suffix.rowEnd) {
text += `-${suffix.rowEnd}`;
if (suffix.colEnd) {
text += `:${suffix.colEnd}`;
}
}
return text;
}
function trimLikelyTrailingPunctuation(value: string): string {
return value.replace(/[\][["'.]+$/, "");
}
function getParsedLinkEndIndex(parsedLink: TerminalParsedLink): number {
if (parsedLink.suffix) {
return parsedLink.suffix.suffix.index + parsedLink.suffix.suffix.text.length;
}
return parsedLink.path.index + parsedLink.path.text.length;
}
function getWindowedLineContent(
terminal: Terminal,
requestedLine: number,
): { text: string; startLine: number } | null {
let startLine = requestedLine;
let endLine = requestedLine;
const line = terminal.buffer.active.getLine(requestedLine);
if (!line) {
return null;
}
const lineStrings: string[] = [];
let contextLength = 0;
while (
startLine > 0 &&
terminal.buffer.active.getLine(startLine)?.isWrapped &&
contextLength < MAX_LINK_LENGTH
) {
startLine -= 1;
const previous = terminal.buffer.active.getLine(startLine);
if (!previous) {
break;
}
contextLength += previous.translateToString(true).length;
}
for (let y = startLine; y <= endLine; y += 1) {
const current = terminal.buffer.active.getLine(y);
if (current) {
lineStrings.push(current.translateToString(true));
}
}
contextLength = 0;
while (
endLine + 1 < terminal.buffer.active.length &&
terminal.buffer.active.getLine(endLine + 1)?.isWrapped &&
contextLength < MAX_LINK_LENGTH
) {
endLine += 1;
const next = terminal.buffer.active.getLine(endLine);
if (!next) {
break;
}
const nextText = next.translateToString(true);
contextLength += nextText.length;
lineStrings.push(nextText);
}
return {
text: lineStrings.join(""),
startLine,
};
}
function toBufferRange(input: {
terminal: Terminal;
startLine: number;
startIndex: number;
endIndex: number;
}): IBufferRange | null {
const start = mapStringOffsetToBuffer(input.terminal, input.startLine, input.startIndex);
if (!start) {
return null;
}
const end = mapStringOffsetToBuffer(input.terminal, input.startLine, input.endIndex);
if (!start || !end) {
return null;
}
return {
start: { x: start.x + 1, y: start.y + 1 },
end: { x: end.x, y: end.y + 1 },
};
}
function mapStringOffsetToBuffer(
terminal: Terminal,
startLine: number,
offset: number,
): { y: number; x: number } | null {
const buffer = terminal.buffer.active;
const cell = buffer.getNullCell();
let y = startLine;
let charsRemaining = offset;
while (true) {
const line = buffer.getLine(y);
if (!line) {
return null;
}
for (let column = 0; column < line.length; column += 1) {
if (charsRemaining <= 0) {
return { y, x: column };
}
const currentCell = line.getCell(column, cell) as IBufferCell | undefined;
const chars = currentCell?.getChars() ?? "";
const width = currentCell?.getWidth() ?? 0;
if (width) {
charsRemaining -= chars.length || 1;
if (
isWrappedWideCharacterContinuation({
buffer,
cell,
lineLength: line.length,
column,
y,
chars,
})
) {
charsRemaining += 1;
}
}
if (charsRemaining <= 0) {
return { y, x: column + width };
}
}
if (charsRemaining <= 0) {
return { y, x: line.length };
}
y += 1;
}
}
function isWrappedWideCharacterContinuation(input: {
buffer: Terminal["buffer"]["active"];
cell: IBufferCell;
lineLength: number;
column: number;
y: number;
chars: string;
}): boolean {
if (input.column !== input.lineLength - 1 || input.chars !== "") {
return false;
}
const nextLine = input.buffer.getLine(input.y + 1);
const nextCell = nextLine?.getCell(0, input.cell);
return Boolean(nextLine?.isWrapped && nextCell?.getWidth() === 2);
}