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

View File

@@ -14,6 +14,10 @@ import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
} from "../terminal/local-links/terminal-local-link-provider";
import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
@@ -48,6 +52,13 @@ interface TerminalEmulatorProps {
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
onResolveLocalFileLink?: (
source: TerminalLocalFileLinkSource,
) => Promise<TerminalLocalFileLinkTarget | null> | TerminalLocalFileLinkTarget | null;
onOpenLocalFileLink?: (
target: TerminalLocalFileLinkTarget,
disposition: "main" | "side",
) => Promise<void> | void;
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
@@ -74,7 +85,13 @@ type BridgeInboundMessage =
| { type: "setTheme"; streamKey: string; theme: ITheme }
| { type: "setScrollback"; streamKey: string; lines: number }
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean };
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean }
| {
type: "resolveLocalFileLinkResponse";
streamKey: string;
requestId: number;
target: TerminalLocalFileLinkTarget | null;
};
type BridgeOutboundMessage =
| { type: "bridgeReady" }
@@ -93,6 +110,18 @@ type BridgeOutboundMessage =
| { type: "pendingModifiersConsumed"; streamKey: string }
| { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
| { type: "openExternalUrl"; streamKey: string; url: string }
| {
type: "resolveLocalFileLink";
streamKey: string;
requestId: number;
source: TerminalLocalFileLinkSource;
}
| {
type: "openLocalFileLink";
streamKey: string;
target: TerminalLocalFileLinkTarget;
disposition: "main" | "side";
}
| { type: "swipeLeft"; streamKey: string }
| { type: "swipeRight"; streamKey: string }
| { type: "debug"; message: string; details?: unknown };
@@ -149,6 +178,8 @@ export default function TerminalEmulator({
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onRendererReadyChange,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
@@ -188,6 +219,8 @@ export default function TerminalEmulator({
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onSwipeLeft,
onSwipeRight,
});
@@ -198,6 +231,8 @@ export default function TerminalEmulator({
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onSwipeLeft,
onSwipeRight,
};
@@ -399,10 +434,41 @@ export default function TerminalEmulator({
[clearBridgeReadyTimeout, clearRendererReadyTimeout],
);
const resolveLocalFileLink = useCallback(
async (message: Extract<BridgeOutboundMessage, { type: "resolveLocalFileLink" }>) => {
try {
const target = await (callbacksRef.current.onResolveLocalFileLink?.(message.source) ??
null);
sendToWebView({
type: "resolveLocalFileLinkResponse",
streamKey: message.streamKey,
requestId: message.requestId,
target,
});
} catch {
sendToWebView({
type: "resolveLocalFileLinkResponse",
streamKey: message.streamKey,
requestId: message.requestId,
target: null,
});
}
},
[sendToWebView],
);
const handleTerminalMessage = useCallback(
(
message: Exclude<BridgeOutboundMessage, { type: "bridgeReady" } | { type: "rendererReady" }>,
) => {
if (message.type === "resolveLocalFileLink") {
void resolveLocalFileLink(message);
return;
}
if (message.type === "openLocalFileLink") {
callbacksRef.current.onOpenLocalFileLink?.(message.target, message.disposition);
return;
}
switch (message.type) {
case "input":
callbacksRef.current.onInput?.(message.data);
@@ -438,7 +504,7 @@ export default function TerminalEmulator({
break;
}
},
[],
[resolveLocalFileLink],
);
const handleMessage = useCallback(

View File

@@ -23,6 +23,10 @@ import {
TerminalEmulatorRuntime,
type TerminalOutputData,
} from "../terminal/runtime/terminal-emulator-runtime";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
} from "../terminal/local-links/terminal-local-link-provider";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus";
@@ -136,6 +140,13 @@ interface TerminalEmulatorProps {
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
onResolveLocalFileLink?: (
source: TerminalLocalFileLinkSource,
) => Promise<TerminalLocalFileLinkTarget | null> | TerminalLocalFileLinkTarget | null;
onOpenLocalFileLink?: (
target: TerminalLocalFileLinkTarget,
disposition: "main" | "side",
) => Promise<void> | void;
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
@@ -203,6 +214,8 @@ export default function TerminalEmulator({
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onRendererReadyChange,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
@@ -231,6 +244,8 @@ export default function TerminalEmulator({
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
});
mountCallbacksRef.current = {
onInput,
@@ -238,6 +253,8 @@ export default function TerminalEmulator({
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
};
const initialSnapshotRef = useRef(initialSnapshot);
initialSnapshotRef.current = initialSnapshot;
@@ -470,10 +487,20 @@ export default function TerminalEmulator({
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onOpenExternalUrl: openExternalUrl,
},
});
}, [onInput, onInputModeChange, onPendingModifiersConsumed, onResize, onTerminalKey]);
}, [
onInput,
onInputModeChange,
onOpenLocalFileLink,
onPendingModifiersConsumed,
onResolveLocalFileLink,
onResize,
onTerminalKey,
]);
useEffect(() => {
runtimeRef.current?.setPendingModifiers({ pendingModifiers });

View File

@@ -38,6 +38,16 @@ import {
type TerminalRendererReadyChange,
} from "@/utils/terminal-renderer-readiness";
import { useAppSettings } from "@/hooks/use-settings";
import { classifyForResolution, fetchDaemonResolution } from "@/assistant-file-links/resolver";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
} from "@/terminal/local-links/terminal-local-link-provider";
import {
normalizeWorkspaceFileLocation,
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
interface TerminalPaneProps {
serverId: string;
@@ -46,6 +56,7 @@ interface TerminalPaneProps {
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
onOpenFileExplorer: () => void;
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
}
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
@@ -157,6 +168,7 @@ export function TerminalPane({
isWorkspaceFocused,
isPaneFocused,
onOpenFileExplorer,
onOpenWorkspaceFile,
}: TerminalPaneProps) {
const isAppVisible = useAppVisible();
const { theme } = useUnistyles();
@@ -630,6 +642,42 @@ export function TerminalPane({
const handleInputModeChange = useCallback((state: TerminalInputModeState) => {
inputModeRef.current = state;
}, []);
const handleResolveLocalFileLink = useCallback(
async (source: TerminalLocalFileLinkSource): Promise<TerminalLocalFileLinkTarget | null> => {
const resolution = classifyForResolution(
{ href: source.text, text: source.text, sourceType: "inline-code" },
{ workspaceRoot: cwd },
);
if (resolution.kind === "resolved") {
return resolution.value.kind === "file" ? resolution.value.target : null;
}
if (!client) {
return null;
}
try {
return await fetchDaemonResolution({
ambiguousQuery: resolution.ambiguousQuery,
token: resolution.token,
target: resolution.target,
workspaceRoot: cwd,
getDirectorySuggestions: (input) => client.getDirectorySuggestions(input),
});
} catch {
return null;
}
},
[client, cwd],
);
const handleOpenLocalFileLink = useCallback(
(target: TerminalLocalFileLinkTarget, disposition: OpenFileDisposition) => {
const location = normalizeWorkspaceFileLocation(target);
if (!location) {
return;
}
onOpenWorkspaceFile({ location, disposition });
},
[onOpenWorkspaceFile],
);
const toggleModifier = useCallback(
(modifier: keyof ModifierState) => {
@@ -712,6 +760,8 @@ export function TerminalPane({
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}
onInputModeChange={handleInputModeChange}
onResolveLocalFileLink={handleResolveLocalFileLink}
onOpenLocalFileLink={handleOpenLocalFileLink}
onPendingModifiersConsumed={handlePendingModifiersConsumed}
pendingModifiers={modifiers}
focusRequestToken={focusRequestToken}

View File

@@ -70,7 +70,7 @@ function useTerminalPanelDescriptor(
}
function TerminalPanel() {
const { serverId, workspaceId, target } = usePaneContext();
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
const { isWorkspaceFocused, isPaneFocused } = usePaneFocus();
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
const workspaceDirectory = workspaceAuthority.ok
@@ -115,6 +115,7 @@ function TerminalPanel() {
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onOpenFileExplorer={handleOpenFileExplorer}
onOpenWorkspaceFile={openFileInWorkspace}
/>
);
}

View File

@@ -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,
},
]);
});
});

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -65,6 +65,9 @@ vi.mock("@xterm/xterm", () => ({
terminalConstructorOptions.values.push(options);
}
loadAddon(): void {}
registerLinkProvider(): { dispose: () => void } {
return { dispose: () => undefined };
}
open(): void {}
onData(): { dispose: () => void } {
return { dispose: () => undefined };

View File

@@ -23,6 +23,11 @@ import {
shouldInterceptDomTerminalKey,
} from "@/utils/terminal-keys";
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
import {
createTerminalLocalFileLinkProvider,
type TerminalLocalFileLinkSource,
type TerminalLocalFileLinkTarget,
} from "../local-links/terminal-local-link-provider";
export type TerminalOutputData = Uint8Array;
@@ -46,6 +51,13 @@ export interface TerminalEmulatorRuntimeCallbacks {
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onOpenExternalUrl?: (url: string) => Promise<void> | void;
onResolveLocalFileLink?: (
source: TerminalLocalFileLinkSource,
) => Promise<TerminalLocalFileLinkTarget | null> | TerminalLocalFileLinkTarget | null;
onOpenLocalFileLink?: (
target: TerminalLocalFileLinkTarget,
disposition: "main" | "side",
) => Promise<void> | void;
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
}
@@ -221,6 +233,17 @@ export class TerminalEmulatorRuntime {
void this.callbacks.onOpenExternalUrl?.(uri);
}),
);
const localFileLinkProvider = terminal.registerLinkProvider(
createTerminalLocalFileLinkProvider(terminal, {
resolveLink: async (source) => {
const target = await this.callbacks.onResolveLocalFileLink?.(source);
return target ?? null;
},
openLink: (target, disposition) => {
void this.callbacks.onOpenLocalFileLink?.(target, disposition);
},
}),
);
terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 }));
terminal.loadAddon(new ClipboardAddon());
try {
@@ -524,6 +547,7 @@ export class TerminalEmulatorRuntime {
disposeImageAddon();
},
disposeTerminal: () => {
localFileLinkProvider.dispose();
terminal.dispose();
},
};

View File

@@ -7,6 +7,10 @@ import {
encodeTerminalOutput,
TerminalEmulatorRuntime,
} from "../runtime/terminal-emulator-runtime";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
} from "../local-links/terminal-local-link-provider";
interface MountMessage {
type: "mount";
@@ -30,7 +34,13 @@ type InboundMessage =
| { type: "setTheme"; streamKey: string; theme: ITheme }
| { type: "setScrollback"; streamKey: string; lines: number }
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean };
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean }
| {
type: "resolveLocalFileLinkResponse";
streamKey: string;
requestId: number;
target: TerminalLocalFileLinkTarget | null;
};
type OutboundMessage =
| { type: "bridgeReady" }
@@ -49,6 +59,18 @@ type OutboundMessage =
| { type: "pendingModifiersConsumed"; streamKey: string }
| { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
| { type: "openExternalUrl"; streamKey: string; url: string }
| {
type: "resolveLocalFileLink";
streamKey: string;
requestId: number;
source: TerminalLocalFileLinkSource;
}
| {
type: "openLocalFileLink";
streamKey: string;
target: TerminalLocalFileLinkTarget;
disposition: "main" | "side";
}
| { type: "swipeLeft"; streamKey: string }
| { type: "swipeRight"; streamKey: string }
| { type: "debug"; message: string; details?: unknown };
@@ -109,6 +131,11 @@ body,
class TerminalWebViewBridge {
private runtime: TerminalEmulatorRuntime | null = null;
private streamKey: string | null = null;
private nextLocalFileLinkRequestId = 1;
private readonly pendingLocalFileLinkResolutions = new Map<
number,
(target: TerminalLocalFileLinkTarget | null) => void
>();
private swipeGesturesEnabled = false;
private trackingSwipe = false;
private activePointerId: number | null = null;
@@ -150,11 +177,18 @@ class TerminalWebViewBridge {
if (!this.matches(message.streamKey)) {
return;
}
if (message.type === "resolveLocalFileLinkResponse") {
this.resolveLocalFileLinkRequest(message.requestId, message.target);
return;
}
this.receiveMounted(message);
}
private receiveMounted(
message: Exclude<InboundMessage, MountMessage | { type: "unmount" }>,
message: Exclude<
InboundMessage,
MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" }
>,
): void {
switch (message.type) {
case "writeOutput":
@@ -211,6 +245,14 @@ class TerminalWebViewBridge {
sendToNative({ type: "inputModeChange", streamKey: message.streamKey, state }),
onOpenExternalUrl: (url) =>
sendToNative({ type: "openExternalUrl", streamKey: message.streamKey, url }),
onResolveLocalFileLink: (source) => this.requestLocalFileLinkResolution(source),
onOpenLocalFileLink: (target, disposition) =>
sendToNative({
type: "openLocalFileLink",
streamKey: message.streamKey,
target,
disposition,
}),
},
});
runtime.setPendingModifiers({ pendingModifiers: message.pendingModifiers });
@@ -232,6 +274,7 @@ class TerminalWebViewBridge {
this.runtime.unmount();
this.runtime = null;
this.streamKey = null;
this.resolveAllLocalFileLinkRequests(null);
if (previousStreamKey && (!streamKey || streamKey === previousStreamKey)) {
sendToNative({ type: "rendererReady", streamKey: previousStreamKey, isReady: false });
}
@@ -241,6 +284,46 @@ class TerminalWebViewBridge {
return this.streamKey === streamKey;
}
private requestLocalFileLinkResolution(
source: TerminalLocalFileLinkSource,
): Promise<TerminalLocalFileLinkTarget | null> {
const streamKey = this.streamKey;
if (!streamKey) {
return Promise.resolve(null);
}
const requestId = this.nextLocalFileLinkRequestId++;
return new Promise((resolve) => {
this.pendingLocalFileLinkResolutions.set(requestId, resolve);
sendToNative({
type: "resolveLocalFileLink",
streamKey,
requestId,
source,
});
});
}
private resolveLocalFileLinkRequest(
requestId: number,
target: TerminalLocalFileLinkTarget | null,
): void {
const resolve = this.pendingLocalFileLinkResolutions.get(requestId);
if (!resolve) {
return;
}
this.pendingLocalFileLinkResolutions.delete(requestId);
resolve(target);
}
private resolveAllLocalFileLinkRequests(target: TerminalLocalFileLinkTarget | null): void {
const requests = Array.from(this.pendingLocalFileLinkResolutions.values());
this.pendingLocalFileLinkResolutions.clear();
for (const resolve of requests) {
resolve(target);
}
}
private handlePointerDown = (event: PointerEvent): void => {
if (!this.swipeGesturesEnabled || !event.isPrimary) {
return;

File diff suppressed because one or more lines are too long