mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Link terminal file paths to workspace previews (#1174)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user