diff --git a/packages/app/src/components/terminal-emulator.native.tsx b/packages/app/src/components/terminal-emulator.native.tsx index 57dcfd573..29851551f 100644 --- a/packages/app/src/components/terminal-emulator.native.tsx +++ b/packages/app/src/components/terminal-emulator.native.tsx @@ -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; onPendingModifiersConsumed?: () => Promise | void; onInputModeChange?: (state: TerminalInputModeState) => Promise | void; + onResolveLocalFileLink?: ( + source: TerminalLocalFileLinkSource, + ) => Promise | TerminalLocalFileLinkTarget | null; + onOpenLocalFileLink?: ( + target: TerminalLocalFileLinkTarget, + disposition: "main" | "side", + ) => Promise | 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) => { + 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, ) => { + 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( diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index d0a6ff1f4..db8802ffb 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -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; onPendingModifiersConsumed?: () => Promise | void; onInputModeChange?: (state: TerminalInputModeState) => Promise | void; + onResolveLocalFileLink?: ( + source: TerminalLocalFileLinkSource, + ) => Promise | TerminalLocalFileLinkTarget | null; + onOpenLocalFileLink?: ( + target: TerminalLocalFileLinkTarget, + disposition: "main" | "side", + ) => Promise | 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 }); diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 102a4b2a7..1f1be0107 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -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 => { + 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} diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 9da878502..9d6ba1af5 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -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} /> ); } diff --git a/packages/app/src/terminal/local-links/terminal-local-link-parsing.test.ts b/packages/app/src/terminal/local-links/terminal-local-link-parsing.test.ts new file mode 100644 index 000000000..0dcf1e311 --- /dev/null +++ b/packages/app/src/terminal/local-links/terminal-local-link-parsing.test.ts @@ -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, + }, + ]); + }); +}); diff --git a/packages/app/src/terminal/local-links/terminal-local-link-parsing.ts b/packages/app/src/terminal/local-links/terminal-local-link-parsing.ts new file mode 100644 index 000000000..1abfefe51 --- /dev/null +++ b/packages/app/src/terminal/local-links/terminal-local-link-parsing.ts @@ -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 = () => `(?\\d+)`; + const col = () => `(?\\d+)`; + const rowEnd = () => `(?\\d+)`; + const colEnd = () => `(?\\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 = /(?(?: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(/(?[[(])/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(/^(?['"]+)/); + 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); +} diff --git a/packages/app/src/terminal/local-links/terminal-local-link-provider.test.ts b/packages/app/src/terminal/local-links/terminal-local-link-provider.test.ts new file mode 100644 index 000000000..479b457cd --- /dev/null +++ b/packages/app/src/terminal/local-links/terminal-local-link-provider.test.ts @@ -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, + bufferLineNumber: number, +) { + return new Promise[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; + } +} diff --git a/packages/app/src/terminal/local-links/terminal-local-link-provider.ts b/packages/app/src/terminal/local-links/terminal-local-link-provider.ts new file mode 100644 index 000000000..9f68d4b24 --- /dev/null +++ b/packages/app/src/terminal/local-links/terminal-local-link-provider.ts @@ -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; + 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>(); + + constructor( + private readonly terminal: Terminal, + private readonly options: TerminalLocalFileLinkProviderOptions, + ) {} + + async provideLinks( + bufferLineNumber: number, + callback: (links: ILink[] | undefined) => void, + ): Promise { + 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 { + 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); +} diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index e92068a4e..6a6748b71 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -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 }; diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 323bc6364..3735aa7dc 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -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; onPendingModifiersConsumed?: () => Promise | void; onOpenExternalUrl?: (url: string) => Promise | void; + onResolveLocalFileLink?: ( + source: TerminalLocalFileLinkSource, + ) => Promise | TerminalLocalFileLinkTarget | null; + onOpenLocalFileLink?: ( + target: TerminalLocalFileLinkTarget, + disposition: "main" | "side", + ) => Promise | void; onInputModeChange?: (state: TerminalInputModeState) => Promise | 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(); }, }; diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts index da0b29bb5..b14db453c 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts @@ -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, + 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 { + 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; diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts index f7f0b09a7..9f9827f9e 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts @@ -2,4 +2,4 @@ // Do not edit by hand. export const terminalEmulatorWebViewHtml = - '\n\n \n \n \n \n \n \n \n'; + '\n\n \n \n \n \n \n \n \n';