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 { TerminalState } from "@server/shared/messages";
|
||||||
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
|
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
|
||||||
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
|
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 { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html";
|
||||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||||
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||||
@@ -48,6 +52,13 @@ interface TerminalEmulatorProps {
|
|||||||
}) => Promise<void> | void;
|
}) => Promise<void> | void;
|
||||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||||
onInputModeChange?: (state: TerminalInputModeState) => 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;
|
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
|
||||||
pendingModifiers?: PendingTerminalModifiers;
|
pendingModifiers?: PendingTerminalModifiers;
|
||||||
focusRequestToken?: number;
|
focusRequestToken?: number;
|
||||||
@@ -74,7 +85,13 @@ type BridgeInboundMessage =
|
|||||||
| { type: "setTheme"; streamKey: string; theme: ITheme }
|
| { type: "setTheme"; streamKey: string; theme: ITheme }
|
||||||
| { type: "setScrollback"; streamKey: string; lines: number }
|
| { type: "setScrollback"; streamKey: string; lines: number }
|
||||||
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
|
| { 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 BridgeOutboundMessage =
|
||||||
| { type: "bridgeReady" }
|
| { type: "bridgeReady" }
|
||||||
@@ -93,6 +110,18 @@ type BridgeOutboundMessage =
|
|||||||
| { type: "pendingModifiersConsumed"; streamKey: string }
|
| { type: "pendingModifiersConsumed"; streamKey: string }
|
||||||
| { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
|
| { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
|
||||||
| { type: "openExternalUrl"; streamKey: string; url: string }
|
| { 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: "swipeLeft"; streamKey: string }
|
||||||
| { type: "swipeRight"; streamKey: string }
|
| { type: "swipeRight"; streamKey: string }
|
||||||
| { type: "debug"; message: string; details?: unknown };
|
| { type: "debug"; message: string; details?: unknown };
|
||||||
@@ -149,6 +178,8 @@ export default function TerminalEmulator({
|
|||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
onInputModeChange,
|
onInputModeChange,
|
||||||
|
onResolveLocalFileLink,
|
||||||
|
onOpenLocalFileLink,
|
||||||
onRendererReadyChange,
|
onRendererReadyChange,
|
||||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||||
focusRequestToken = 0,
|
focusRequestToken = 0,
|
||||||
@@ -188,6 +219,8 @@ export default function TerminalEmulator({
|
|||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
onInputModeChange,
|
onInputModeChange,
|
||||||
onRendererReadyChange,
|
onRendererReadyChange,
|
||||||
|
onResolveLocalFileLink,
|
||||||
|
onOpenLocalFileLink,
|
||||||
onSwipeLeft,
|
onSwipeLeft,
|
||||||
onSwipeRight,
|
onSwipeRight,
|
||||||
});
|
});
|
||||||
@@ -198,6 +231,8 @@ export default function TerminalEmulator({
|
|||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
onInputModeChange,
|
onInputModeChange,
|
||||||
onRendererReadyChange,
|
onRendererReadyChange,
|
||||||
|
onResolveLocalFileLink,
|
||||||
|
onOpenLocalFileLink,
|
||||||
onSwipeLeft,
|
onSwipeLeft,
|
||||||
onSwipeRight,
|
onSwipeRight,
|
||||||
};
|
};
|
||||||
@@ -399,10 +434,41 @@ export default function TerminalEmulator({
|
|||||||
[clearBridgeReadyTimeout, clearRendererReadyTimeout],
|
[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(
|
const handleTerminalMessage = useCallback(
|
||||||
(
|
(
|
||||||
message: Exclude<BridgeOutboundMessage, { type: "bridgeReady" } | { type: "rendererReady" }>,
|
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) {
|
switch (message.type) {
|
||||||
case "input":
|
case "input":
|
||||||
callbacksRef.current.onInput?.(message.data);
|
callbacksRef.current.onInput?.(message.data);
|
||||||
@@ -438,7 +504,7 @@ export default function TerminalEmulator({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[resolveLocalFileLink],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMessage = useCallback(
|
const handleMessage = useCallback(
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import {
|
|||||||
TerminalEmulatorRuntime,
|
TerminalEmulatorRuntime,
|
||||||
type TerminalOutputData,
|
type TerminalOutputData,
|
||||||
} from "../terminal/runtime/terminal-emulator-runtime";
|
} 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 type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||||
import { openExternalUrl } from "../utils/open-external-url";
|
import { openExternalUrl } from "../utils/open-external-url";
|
||||||
import { focusWithRetries } from "../utils/web-focus";
|
import { focusWithRetries } from "../utils/web-focus";
|
||||||
@@ -136,6 +140,13 @@ interface TerminalEmulatorProps {
|
|||||||
}) => Promise<void> | void;
|
}) => Promise<void> | void;
|
||||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||||
onInputModeChange?: (state: TerminalInputModeState) => 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;
|
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
|
||||||
pendingModifiers?: PendingTerminalModifiers;
|
pendingModifiers?: PendingTerminalModifiers;
|
||||||
focusRequestToken?: number;
|
focusRequestToken?: number;
|
||||||
@@ -203,6 +214,8 @@ export default function TerminalEmulator({
|
|||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
onInputModeChange,
|
onInputModeChange,
|
||||||
|
onResolveLocalFileLink,
|
||||||
|
onOpenLocalFileLink,
|
||||||
onRendererReadyChange,
|
onRendererReadyChange,
|
||||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||||
focusRequestToken = 0,
|
focusRequestToken = 0,
|
||||||
@@ -231,6 +244,8 @@ export default function TerminalEmulator({
|
|||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
onInputModeChange,
|
onInputModeChange,
|
||||||
|
onResolveLocalFileLink,
|
||||||
|
onOpenLocalFileLink,
|
||||||
});
|
});
|
||||||
mountCallbacksRef.current = {
|
mountCallbacksRef.current = {
|
||||||
onInput,
|
onInput,
|
||||||
@@ -238,6 +253,8 @@ export default function TerminalEmulator({
|
|||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
onInputModeChange,
|
onInputModeChange,
|
||||||
|
onResolveLocalFileLink,
|
||||||
|
onOpenLocalFileLink,
|
||||||
};
|
};
|
||||||
const initialSnapshotRef = useRef(initialSnapshot);
|
const initialSnapshotRef = useRef(initialSnapshot);
|
||||||
initialSnapshotRef.current = initialSnapshot;
|
initialSnapshotRef.current = initialSnapshot;
|
||||||
@@ -470,10 +487,20 @@ export default function TerminalEmulator({
|
|||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
onInputModeChange,
|
onInputModeChange,
|
||||||
|
onResolveLocalFileLink,
|
||||||
|
onOpenLocalFileLink,
|
||||||
onOpenExternalUrl: openExternalUrl,
|
onOpenExternalUrl: openExternalUrl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [onInput, onInputModeChange, onPendingModifiersConsumed, onResize, onTerminalKey]);
|
}, [
|
||||||
|
onInput,
|
||||||
|
onInputModeChange,
|
||||||
|
onOpenLocalFileLink,
|
||||||
|
onPendingModifiersConsumed,
|
||||||
|
onResolveLocalFileLink,
|
||||||
|
onResize,
|
||||||
|
onTerminalKey,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
|
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ import {
|
|||||||
type TerminalRendererReadyChange,
|
type TerminalRendererReadyChange,
|
||||||
} from "@/utils/terminal-renderer-readiness";
|
} from "@/utils/terminal-renderer-readiness";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
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 {
|
interface TerminalPaneProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -46,6 +56,7 @@ interface TerminalPaneProps {
|
|||||||
isWorkspaceFocused: boolean;
|
isWorkspaceFocused: boolean;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
onOpenFileExplorer: () => void;
|
onOpenFileExplorer: () => void;
|
||||||
|
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
||||||
@@ -157,6 +168,7 @@ export function TerminalPane({
|
|||||||
isWorkspaceFocused,
|
isWorkspaceFocused,
|
||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
onOpenFileExplorer,
|
onOpenFileExplorer,
|
||||||
|
onOpenWorkspaceFile,
|
||||||
}: TerminalPaneProps) {
|
}: TerminalPaneProps) {
|
||||||
const isAppVisible = useAppVisible();
|
const isAppVisible = useAppVisible();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -630,6 +642,42 @@ export function TerminalPane({
|
|||||||
const handleInputModeChange = useCallback((state: TerminalInputModeState) => {
|
const handleInputModeChange = useCallback((state: TerminalInputModeState) => {
|
||||||
inputModeRef.current = state;
|
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(
|
const toggleModifier = useCallback(
|
||||||
(modifier: keyof ModifierState) => {
|
(modifier: keyof ModifierState) => {
|
||||||
@@ -712,6 +760,8 @@ export function TerminalPane({
|
|||||||
onResize={handleTerminalResize}
|
onResize={handleTerminalResize}
|
||||||
onTerminalKey={handleTerminalKey}
|
onTerminalKey={handleTerminalKey}
|
||||||
onInputModeChange={handleInputModeChange}
|
onInputModeChange={handleInputModeChange}
|
||||||
|
onResolveLocalFileLink={handleResolveLocalFileLink}
|
||||||
|
onOpenLocalFileLink={handleOpenLocalFileLink}
|
||||||
onPendingModifiersConsumed={handlePendingModifiersConsumed}
|
onPendingModifiersConsumed={handlePendingModifiersConsumed}
|
||||||
pendingModifiers={modifiers}
|
pendingModifiers={modifiers}
|
||||||
focusRequestToken={focusRequestToken}
|
focusRequestToken={focusRequestToken}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ function useTerminalPanelDescriptor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TerminalPanel() {
|
function TerminalPanel() {
|
||||||
const { serverId, workspaceId, target } = usePaneContext();
|
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
|
||||||
const { isWorkspaceFocused, isPaneFocused } = usePaneFocus();
|
const { isWorkspaceFocused, isPaneFocused } = usePaneFocus();
|
||||||
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
|
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
|
||||||
const workspaceDirectory = workspaceAuthority.ok
|
const workspaceDirectory = workspaceAuthority.ok
|
||||||
@@ -115,6 +115,7 @@ function TerminalPanel() {
|
|||||||
isWorkspaceFocused={isWorkspaceFocused}
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
onOpenFileExplorer={handleOpenFileExplorer}
|
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);
|
terminalConstructorOptions.values.push(options);
|
||||||
}
|
}
|
||||||
loadAddon(): void {}
|
loadAddon(): void {}
|
||||||
|
registerLinkProvider(): { dispose: () => void } {
|
||||||
|
return { dispose: () => undefined };
|
||||||
|
}
|
||||||
open(): void {}
|
open(): void {}
|
||||||
onData(): { dispose: () => void } {
|
onData(): { dispose: () => void } {
|
||||||
return { dispose: () => undefined };
|
return { dispose: () => undefined };
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ import {
|
|||||||
shouldInterceptDomTerminalKey,
|
shouldInterceptDomTerminalKey,
|
||||||
} from "@/utils/terminal-keys";
|
} from "@/utils/terminal-keys";
|
||||||
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
|
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
|
||||||
|
import {
|
||||||
|
createTerminalLocalFileLinkProvider,
|
||||||
|
type TerminalLocalFileLinkSource,
|
||||||
|
type TerminalLocalFileLinkTarget,
|
||||||
|
} from "../local-links/terminal-local-link-provider";
|
||||||
|
|
||||||
export type TerminalOutputData = Uint8Array;
|
export type TerminalOutputData = Uint8Array;
|
||||||
|
|
||||||
@@ -46,6 +51,13 @@ export interface TerminalEmulatorRuntimeCallbacks {
|
|||||||
}) => Promise<void> | void;
|
}) => Promise<void> | void;
|
||||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||||
onOpenExternalUrl?: (url: string) => 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;
|
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +233,17 @@ export class TerminalEmulatorRuntime {
|
|||||||
void this.callbacks.onOpenExternalUrl?.(uri);
|
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 SearchAddon({ highlightLimit: 20_000 }));
|
||||||
terminal.loadAddon(new ClipboardAddon());
|
terminal.loadAddon(new ClipboardAddon());
|
||||||
try {
|
try {
|
||||||
@@ -524,6 +547,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
disposeImageAddon();
|
disposeImageAddon();
|
||||||
},
|
},
|
||||||
disposeTerminal: () => {
|
disposeTerminal: () => {
|
||||||
|
localFileLinkProvider.dispose();
|
||||||
terminal.dispose();
|
terminal.dispose();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
encodeTerminalOutput,
|
encodeTerminalOutput,
|
||||||
TerminalEmulatorRuntime,
|
TerminalEmulatorRuntime,
|
||||||
} from "../runtime/terminal-emulator-runtime";
|
} from "../runtime/terminal-emulator-runtime";
|
||||||
|
import type {
|
||||||
|
TerminalLocalFileLinkSource,
|
||||||
|
TerminalLocalFileLinkTarget,
|
||||||
|
} from "../local-links/terminal-local-link-provider";
|
||||||
|
|
||||||
interface MountMessage {
|
interface MountMessage {
|
||||||
type: "mount";
|
type: "mount";
|
||||||
@@ -30,7 +34,13 @@ type InboundMessage =
|
|||||||
| { type: "setTheme"; streamKey: string; theme: ITheme }
|
| { type: "setTheme"; streamKey: string; theme: ITheme }
|
||||||
| { type: "setScrollback"; streamKey: string; lines: number }
|
| { type: "setScrollback"; streamKey: string; lines: number }
|
||||||
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
|
| { 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 OutboundMessage =
|
||||||
| { type: "bridgeReady" }
|
| { type: "bridgeReady" }
|
||||||
@@ -49,6 +59,18 @@ type OutboundMessage =
|
|||||||
| { type: "pendingModifiersConsumed"; streamKey: string }
|
| { type: "pendingModifiersConsumed"; streamKey: string }
|
||||||
| { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
|
| { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
|
||||||
| { type: "openExternalUrl"; streamKey: string; url: string }
|
| { 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: "swipeLeft"; streamKey: string }
|
||||||
| { type: "swipeRight"; streamKey: string }
|
| { type: "swipeRight"; streamKey: string }
|
||||||
| { type: "debug"; message: string; details?: unknown };
|
| { type: "debug"; message: string; details?: unknown };
|
||||||
@@ -109,6 +131,11 @@ body,
|
|||||||
class TerminalWebViewBridge {
|
class TerminalWebViewBridge {
|
||||||
private runtime: TerminalEmulatorRuntime | null = null;
|
private runtime: TerminalEmulatorRuntime | null = null;
|
||||||
private streamKey: string | null = null;
|
private streamKey: string | null = null;
|
||||||
|
private nextLocalFileLinkRequestId = 1;
|
||||||
|
private readonly pendingLocalFileLinkResolutions = new Map<
|
||||||
|
number,
|
||||||
|
(target: TerminalLocalFileLinkTarget | null) => void
|
||||||
|
>();
|
||||||
private swipeGesturesEnabled = false;
|
private swipeGesturesEnabled = false;
|
||||||
private trackingSwipe = false;
|
private trackingSwipe = false;
|
||||||
private activePointerId: number | null = null;
|
private activePointerId: number | null = null;
|
||||||
@@ -150,11 +177,18 @@ class TerminalWebViewBridge {
|
|||||||
if (!this.matches(message.streamKey)) {
|
if (!this.matches(message.streamKey)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (message.type === "resolveLocalFileLinkResponse") {
|
||||||
|
this.resolveLocalFileLinkRequest(message.requestId, message.target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.receiveMounted(message);
|
this.receiveMounted(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
private receiveMounted(
|
private receiveMounted(
|
||||||
message: Exclude<InboundMessage, MountMessage | { type: "unmount" }>,
|
message: Exclude<
|
||||||
|
InboundMessage,
|
||||||
|
MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" }
|
||||||
|
>,
|
||||||
): void {
|
): void {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case "writeOutput":
|
case "writeOutput":
|
||||||
@@ -211,6 +245,14 @@ class TerminalWebViewBridge {
|
|||||||
sendToNative({ type: "inputModeChange", streamKey: message.streamKey, state }),
|
sendToNative({ type: "inputModeChange", streamKey: message.streamKey, state }),
|
||||||
onOpenExternalUrl: (url) =>
|
onOpenExternalUrl: (url) =>
|
||||||
sendToNative({ type: "openExternalUrl", streamKey: message.streamKey, 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 });
|
runtime.setPendingModifiers({ pendingModifiers: message.pendingModifiers });
|
||||||
@@ -232,6 +274,7 @@ class TerminalWebViewBridge {
|
|||||||
this.runtime.unmount();
|
this.runtime.unmount();
|
||||||
this.runtime = null;
|
this.runtime = null;
|
||||||
this.streamKey = null;
|
this.streamKey = null;
|
||||||
|
this.resolveAllLocalFileLinkRequests(null);
|
||||||
if (previousStreamKey && (!streamKey || streamKey === previousStreamKey)) {
|
if (previousStreamKey && (!streamKey || streamKey === previousStreamKey)) {
|
||||||
sendToNative({ type: "rendererReady", streamKey: previousStreamKey, isReady: false });
|
sendToNative({ type: "rendererReady", streamKey: previousStreamKey, isReady: false });
|
||||||
}
|
}
|
||||||
@@ -241,6 +284,46 @@ class TerminalWebViewBridge {
|
|||||||
return this.streamKey === streamKey;
|
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 => {
|
private handlePointerDown = (event: PointerEvent): void => {
|
||||||
if (!this.swipeGesturesEnabled || !event.isPrimary) {
|
if (!this.swipeGesturesEnabled || !event.isPrimary) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user