diff --git a/src/components/task/AuxTerminal.tsx b/src/components/task/AuxTerminal.tsx index a759cd1c..39f22a6e 100644 --- a/src/components/task/AuxTerminal.tsx +++ b/src/components/task/AuxTerminal.tsx @@ -112,7 +112,12 @@ export function AuxTerminal({ taskId, taskPath, active, autoFocus, onExited, onT // GH #58: mouse-reporting-proof Cmd/Ctrl+click opener — the user can run // a TUI in the scratch shell too (htop, an agent CLI by hand). See // TerminalPane / lib/termLinkOpener. - const disposeLinkOpener = attachCmdClickLinkOpener(term, host, openLink("capture")); + // urlsOnly: file-path open is a TerminalPane feature. Without this the + // opener would arm on path tokens here and swallow the click (dead in the + // scratch shell, and eaten from under a mouse-reporting TUI). + const disposeLinkOpener = attachCmdClickLinkOpener(term, host, (target) => { + if (target.kind === "url") openLink("capture")(target.uri); + }, { urlsOnly: true }); // Korean/CJK IME (WKWebView). WebKit composes via textarea `input` events // (insertText + insertReplacementText), not compositionstart/end, and // xterm drops the replacement events — so input gets mangled (안녕 → ㅇㄴ). diff --git a/src/components/task/TerminalPane.tsx b/src/components/task/TerminalPane.tsx index 9f456331..54f35d51 100644 --- a/src/components/task/TerminalPane.tsx +++ b/src/components/task/TerminalPane.tsx @@ -11,7 +11,9 @@ import { cn } from "@/lib/utils"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; -import { attachCmdClickLinkOpener } from "@/lib/termLinkOpener"; +import { attachCmdClickLinkOpener, registerPathLinkProvider, type ClickTarget } from "@/lib/termLinkOpener"; +import { resolvePathClick } from "@/lib/pathMatch"; +import { TerminalPathMenu } from "@/components/task/TerminalPathMenu"; import { openUrl } from "@tauri-apps/plugin-opener"; import { ClipboardAddon } from "@xterm/addon-clipboard"; import { Osc52Base64 } from "@/lib/osc52"; @@ -121,6 +123,15 @@ export function TerminalPane({ task, tab, active }: Props) { const searchInputRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [pathMenu, setPathMenu] = useState<{ x: number; y: number; candidates: string[]; line?: number; col?: number } | null>(null); + const openPathFile = useCallback((path: string, line?: number, col?: number) => { + useApp.getState().openPreviewTab(task.id, { + type: "edit", + path, + title: path.split("/").pop() || path, + revealAt: line ? { line, col } : undefined, + }); + }, [task.id]); const unlistenDataRef = useRef<(() => void) | null>(null); const unlistenExitRef = useRef<(() => void) | null>(null); const ptyRef = useRef(null); @@ -444,6 +455,10 @@ const captureArmedRef = useRef(false); term.loadAddon(new WebLinksAddon((event, uri) => { if (event.metaKey || event.ctrlKey) openLink("addon")(uri); })); + // Hover-underline for file-path references, mirroring the URL addon above. + const disposePathLinks = registerPathLinkProvider(term, (path, line, col, event) => { + if (event.metaKey || event.ctrlKey) handlePathTarget({ path, line, col }, event.clientX, event.clientY); + }); term.open(host); // GH #58: when the agent TUI enables xterm mouse reporting, the modified // click is consumed by the mouse pipeline before the addon's activation @@ -451,7 +466,24 @@ const captureArmedRef = useRef(false); // sees the gesture first, resolves the URL from the buffer itself, and // swallows the click so nothing double-fires. Must attach AFTER // term.open (it reads .xterm-screen geometry). - const disposeLinkOpener = attachCmdClickLinkOpener(term, host, openLink("capture")); + // GH #117: the same opener also resolves file-path references. + function handlePathTarget(target: { path: string; line?: number; col?: number }, x: number, y: number) { + ipc.taskListFilesForFinder(task.id) + .then(files => { + const matches = resolvePathClick(files, target.path); + if (matches.length === 1) { + openPathFile(matches[0], target.line, target.col); + } else { + setPathMenu({ x, y, candidates: matches, line: target.line, col: target.col }); + } + }) + .catch(() => useUI.getState().pushToast("Couldn't list files to open that path", "error")); + } + const onActivate = (target: ClickTarget, x: number, y: number) => { + if (target.kind === "url") openLink("capture")(target.uri); + else handlePathTarget(target, x, y); + }; + const disposeLinkOpener = attachCmdClickLinkOpener(term, host, onActivate); termRef.current = term; fitRef.current = fit; @@ -1612,6 +1644,7 @@ const captureArmedRef = useRef(false); ro.disconnect(); disposeCopyOnSelect(); disposeLinkOpener(); + disposePathLinks.dispose(); unregisterDrop(); disposeImeBridge(); unlistenDataRef.current?.(); @@ -2012,6 +2045,22 @@ const captureArmedRef = useRef(false); /> )}
+ {pathMenu && ( + { openPathFile(path, pathMenu.line, pathMenu.col); setPathMenu(null); }} + onClose={() => setPathMenu(null)} + onCloseAutoFocus={(e, picked) => { + // The anchor is an invisible, non-focusable div, so never let Radix + // return focus to it. On dismiss, hand focus back to the terminal; + // on a pick, leave it for the editor that just opened. + e.preventDefault(); + if (!picked) termRef.current?.focus(); + }} + /> + )} {searchOpen && (
void; + onClose: () => void; + // `picked` distinguishes a candidate selection from a dismiss (Escape / + // click-away), so the caller can route focus accordingly. + onCloseAutoFocus?: (e: Event, picked: boolean) => void; +}) { + const picked = useRef(false); + return ( + { if (!v) onClose(); }}> + + {/* invisible anchor at the click point; Radix positions the menu off it */} +
+ + onCloseAutoFocus(e, picked.current))}> + {candidates.length === 0 ? ( +
+ No matches +
+ ) : candidates.map(path => { + const name = path.split("/").pop() || path; + const dir = path.slice(0, path.length - name.length); + return ( + { picked.current = true; onPick(path); }}> + + {name} + {dir && ( + + {dir.replace(/\/$/, "")} + + )} + + ); + })} +
+ + ); +} diff --git a/src/lib/pathMatch.test.ts b/src/lib/pathMatch.test.ts new file mode 100644 index 00000000..50c7433a --- /dev/null +++ b/src/lib/pathMatch.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { normalizePath, matchesSuffix, resolvePathClick } from "./pathMatch"; + +describe("normalizePath", () => { + it("strips a leading ./", () => { + expect(normalizePath("./src/a.ts")).toBe("src/a.ts"); + }); + it("strips all leading ./ and / segments", () => { + expect(normalizePath(".//src/a.ts")).toBe("src/a.ts"); + expect(normalizePath("././src/a.ts")).toBe("src/a.ts"); + expect(normalizePath("/./src/a.ts")).toBe("src/a.ts"); + }); + it("strips a leading absolute slash", () => { + expect(normalizePath("/src/a.ts")).toBe("src/a.ts"); + }); + it("leaves ../ alone (parent traversal is meaningful)", () => { + expect(normalizePath("../src/a.ts")).toBe("../src/a.ts"); + }); + it("leaves an already-bare path untouched", () => { + expect(normalizePath("src/a.ts")).toBe("src/a.ts"); + }); +}); + +describe("matchesSuffix", () => { + it("matches an identical path", () => { + expect(matchesSuffix("src/file.ts", "src/file.ts")).toBe(true); + }); + it("matches a segment-boundary suffix", () => { + expect(matchesSuffix("foo/src/file.ts", "src/file.ts")).toBe(true); + }); + it("matches a bare filename against a deeper path", () => { + expect(matchesSuffix("foo/src/file.ts", "file.ts")).toBe(true); + }); + it("rejects a different leading segment", () => { + expect(matchesSuffix("abc/file.ts", "src/file.ts")).toBe(false); + }); + it("rejects a raw (non-segment-boundary) suffix", () => { + expect(matchesSuffix("foo/barfile.ts", "file.ts")).toBe(false); + }); + it("normalizes both sides before comparing", () => { + expect(matchesSuffix("/foo/src/file.ts", "./src/file.ts")).toBe(true); + }); + it("does not match when the candidate is shorter than the query", () => { + expect(matchesSuffix("file.ts", "src/file.ts")).toBe(false); + }); +}); + +describe("resolvePathClick", () => { + const files = ["src/app/dup.ts", "src/lib/dup.ts", "src/main.ts", "README.md"]; + + it("returns a single match (handler opens it directly)", () => { + expect(resolvePathClick(files, "main.ts")).toEqual(["src/main.ts"]); + }); + it("returns every duplicate-basename match (handler shows the picker)", () => { + expect(resolvePathClick(files, "dup.ts")).toEqual(["src/app/dup.ts", "src/lib/dup.ts"]); + }); + it("disambiguates a duplicate down to one when the click is dir-qualified", () => { + expect(resolvePathClick(files, "app/dup.ts")).toEqual(["src/app/dup.ts"]); + }); + it("returns nothing for an unknown path (handler shows the no-matches row)", () => { + expect(resolvePathClick(files, "nope.ts")).toEqual([]); + }); +}); diff --git a/src/lib/pathMatch.ts b/src/lib/pathMatch.ts new file mode 100644 index 00000000..5920d4fb --- /dev/null +++ b/src/lib/pathMatch.ts @@ -0,0 +1,17 @@ +// Matching a file-path fragment from terminal output against the workspace +// file list, on segment boundaries (not raw string suffix). + +export function normalizePath(p: string): string { + // Strip every leading "./" and "/" (but not "../", which is meaningful). + return p.replace(/^(?:\.?\/)+/, ""); +} + +export function matchesSuffix(candidate: string, clicked: string): boolean { + const c = normalizePath(candidate); + const q = normalizePath(clicked); + return c === q || c.endsWith("/" + q); +} + +export function resolvePathClick(files: string[], clicked: string): string[] { + return files.filter(f => matchesSuffix(f, clicked)); +} diff --git a/src/lib/termLinkOpener.test.ts b/src/lib/termLinkOpener.test.ts new file mode 100644 index 00000000..bff08a99 --- /dev/null +++ b/src/lib/termLinkOpener.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { PATH_TOKEN_RE, parsePathToken, scanPathTokens } from "./termLinkOpener"; + +const firstMatch = (s: string): string | null => PATH_TOKEN_RE.exec(s)?.[0] ?? null; +const scan = (s: string): string[] => scanPathTokens(s).map(t => t.raw); + +describe("PATH_TOKEN_RE", () => { + it("matches a dir-qualified path", () => { + expect(firstMatch("src/index.ts")).toBe("src/index.ts"); + }); + it("matches a bare filename with a letter-led extension", () => { + expect(firstMatch("file.ts")).toBe("file.ts"); + }); + it("matches a multi-dotted filename", () => { + expect(firstMatch("file.min.js")).toBe("file.min.js"); + }); + it("captures a trailing :line:col", () => { + expect(firstMatch("app.tsx:45:2")).toBe("app.tsx:45:2"); + }); + it("captures a trailing :line", () => { + expect(firstMatch("src/a.ts:12")).toBe("src/a.ts:12"); + }); + it("does not match a bare version string", () => { + expect(firstMatch("1.2.3")).toBeNull(); + expect(firstMatch("v1.2.3")).toBeNull(); + }); + it("does not match an extension-less, slash-less word", () => { + expect(firstMatch("README")).toBeNull(); + expect(firstMatch("just words here")).toBeNull(); + }); + it("matches an @ filename (retina asset)", () => { + expect(firstMatch("logo@2x.png")).toBe("logo@2x.png"); + }); + it("stays fast on a long slash-less/dot-less blob (no O(n^2) backtracking)", () => { + // A wrapped base64/hash line can reach tens of thousands of chars; the + // hover-underline pass runs this regex on every row, so a quadratic blowup + // would stall the main thread. Bounded segment runs keep it linear. + const blob = "a".repeat(40000); + const start = performance.now(); + expect(firstMatch(blob)).toBeNull(); + expect(performance.now() - start).toBeLessThan(100); + }); +}); + +describe("scanPathTokens", () => { + it("skips a schemed URL (WebLinksAddon owns it) but keeps a nearby path", () => { + expect(scan("https://example.com/a/b.ts and src/file.ts:10")).toEqual(["src/file.ts:10"]); + }); + it("skips an scp-style host:path git remote entirely", () => { + expect(scan("git@github.com:dancras/termic.git clone")).toEqual([]); + }); + it("keeps a :line:col position ref (colon before a digit is not a connector)", () => { + expect(scan("error at src/app.ts:45:2 today")).toEqual(["src/app.ts:45:2"]); + }); + it("keeps a path before a prose colon ((path): description)", () => { + expect(scan("see (my/path.ts): description here")).toEqual(["my/path.ts"]); + expect(scan("file.ts: some description")).toEqual(["file.ts"]); + }); + it("keeps @ filenames and scoped-package paths", () => { + expect(scan("retina logo@2x.png and node_modules/@types/node/index.d.ts")) + .toEqual(["logo@2x.png", "node_modules/@types/node/index.d.ts"]); + }); + it("still underlines bare domains (addon does not; they are path-shaped)", () => { + expect(scan("bare example.com/page utils.ts")).toEqual(["example.com/page", "utils.ts"]); + }); + it("stays fast on a long connector-heavy blob (bounded first run)", () => { + const blob = ("x".repeat(300) + ":").repeat(200) + "@".repeat(40000); + const start = performance.now(); + scan(blob); + expect(performance.now() - start).toBeLessThan(150); + }); +}); + +describe("parsePathToken", () => { + it("splits path, line, and col", () => { + expect(parsePathToken("src/file.ts:123:5")).toEqual({ path: "src/file.ts", line: 123, col: 5 }); + }); + it("splits path and line with no col", () => { + expect(parsePathToken("src/file.ts:123")).toEqual({ path: "src/file.ts", line: 123, col: undefined }); + }); + it("returns just the path when there is no trailing line", () => { + expect(parsePathToken("src/file.ts")).toEqual({ path: "src/file.ts" }); + }); + it("handles a bare filename", () => { + expect(parsePathToken("file.ts")).toEqual({ path: "file.ts" }); + }); +}); diff --git a/src/lib/termLinkOpener.ts b/src/lib/termLinkOpener.ts index edb5809e..22f24189 100644 --- a/src/lib/termLinkOpener.ts +++ b/src/lib/termLinkOpener.ts @@ -11,14 +11,76 @@ // from the buffer, open it, and swallow the gesture so neither xterm's mouse // reporting nor the addon double-handles it. The addon stays loaded for // hover-underline and as the opener when no TUI is intercepting. +// +// GH #117: the opener also resolves file-path references. Their hover-underline +// can't reuse WebLinksAddon: its link computer runs every match through +// `new URL()`, which throws for scheme-less paths and drops them. +// registerPathLinkProvider below uses xterm's link API directly. -import type { Terminal } from "@xterm/xterm"; +import type { Terminal, ILink, IDisposable } from "@xterm/xterm"; // Slightly looser than the WebLinksAddon regex; trailing punctuation that // prose tends to glue onto a URL is trimmed after matching. const URL_RE = /https?:\/\/[^\s"'`<>{}|\\^\[\]]+/g; +// File-path-like token: dir-qualified, or a bare filename with a letter-led +// extension (so version strings like "1.2.3" don't match), plus optional +// trailing :line[:col]. `@` is a valid path char so retina assets +// (`logo@2x.png`) and scoped packages (`@types/node`) resolve; the cost is a +// bare `user@host` email underlining and resolving to nothing (harmless). +// Each segment run is bounded ({1,255}, the max filename length) so a long +// slash-less/dot-less blob (base64, a hash) can't drive the regex into O(n^2) +// backtracking and stall the hover-underline pass. +export const PATH_TOKEN_RE = /(?:(?:[\w.@-]{1,255}\/)+[\w.@-]{1,255}|[\w.@-]{1,255}\.[A-Za-z]\w{0,9})(?::\d+(?::\d+)?)?/; + +// Single scan that recognises three things so a fragment of one can't leak as +// another (e.g. the `host/path.ts` inside a URL, or the two halves of an scp +// remote, being mistaken for paths). Only the `path` group is ours: +// url - a schemed URL. WebLinksAddon draws its own hover-underline, so we +// consume-and-skip it here to avoid a double underline. Scheme bounded +// ({0,15}) so it can't backtrack on a long slash-less run. +// junk - an scp-style `host:path` git remote: a colon glued straight onto a +// path char (not `:line:col`, not a `(path): prose` colon). Consumed +// so neither the host nor the path half underlines. +// path - PATH_TOKEN_RE. +const PATH_SCAN_RE_G = new RegExp( + "(?[a-zA-Z][\\w+.-]{0,15}:\\/\\/\\S+)" + + "|(?[\\w.@-]{1,255}:(?=[A-Za-z_~./])[\\w.@:/-]*)" + + "|(?" + PATH_TOKEN_RE.source + ")", + "g", +); + +/** File-path tokens in `text`, skipping URLs and scp `host:path` compounds. + * Each result carries the token's start index so callers can hit-test a click + * or build a hover range; `raw` has trailing prose punctuation trimmed. */ +export function scanPathTokens(text: string): { raw: string; index: number }[] { + PATH_SCAN_RE_G.lastIndex = 0; + const out: { raw: string; index: number }[] = []; + let m: RegExpExecArray | null; + while ((m = PATH_SCAN_RE_G.exec(text))) { + if (m.groups?.path === undefined) continue; // url / junk: not ours + const raw = m[0].replace(TRAILING_PUNCT_RE, ""); + if (raw) out.push({ raw, index: m.index }); + } + return out; +} +const TRAILING_LINE_COL_RE = /:(\d+)(?::(\d+))?$/; const TRAILING_PUNCT_RE = /[.,;:!?)\]}>'"]+$/; +export type ClickTarget = + | { kind: "url"; uri: string } + | { kind: "path"; path: string; line?: number; col?: number }; + +/** Split "src/file.ts:123:5" into { path, line, col }. */ +export function parsePathToken(full: string): { path: string; line?: number; col?: number } { + const lc = TRAILING_LINE_COL_RE.exec(full); + if (!lc) return { path: full }; + return { + path: full.slice(0, lc.index), + line: parseInt(lc[1], 10), + col: lc[2] !== undefined ? parseInt(lc[2], 10) : undefined, + }; +} + /** OSC 8 hyperlink at a buffer cell, or null. Anchor-text links ("Learn * more") carry their URL only in the escape sequence, never in the visible * buffer text, so the regex scrape below can't see them. xterm's public API @@ -38,8 +100,14 @@ function osc8LinkAt(term: Terminal, absRow: number, col: number): string | null } } -/** URL in the terminal buffer at the mouse position, or null. */ -function linkAt(term: Terminal, host: HTMLElement, ev: MouseEvent): string | null { +interface ClickContext { absRow: number; col: number; text: string; clickIdx: number; } + +/** The clicked cell plus the LOGICAL line under it (soft-wrapped rows joined), + * with the clicked cell's index into that line. translateToString(false) + * keeps trailing spaces so column math stays aligned across rows. (Wide CJK + * glyphs before the token can shift the index; accepted.) Null if the click + * isn't over a cell. Shared by urlAt/pathAt so the join runs once. */ +function clickContext(term: Terminal, host: HTMLElement, ev: MouseEvent): ClickContext | null { const screen = host.querySelector(".xterm-screen") as HTMLElement | null; if (!screen) return null; const rect = screen.getBoundingClientRect(); @@ -48,16 +116,6 @@ function linkAt(term: Terminal, host: HTMLElement, ev: MouseEvent): string | nul const row = Math.floor((ev.clientY - rect.top) / (rect.height / term.rows)); if (col < 0 || col >= term.cols || row < 0 || row >= term.rows) return null; - // OSC 8 first: if the clicked cell carries an explicit hyperlink, that - // beats any text-scrape guess. - const osc8 = osc8LinkAt(term, term.buffer.active.viewportY + row, col); - if (osc8) return osc8; - - // Reconstruct the LOGICAL line under the click (soft-wrapped rows joined), - // tracking the clicked cell's character index. translateToString(false) - // keeps trailing spaces so column math stays aligned across rows. (Wide - // CJK glyphs before the URL can shift the index; accepted — URLs and the - // text around them are overwhelmingly single-width.) const buf = term.buffer.active; const clickedLine = buf.viewportY + row; let start = clickedLine; @@ -71,12 +129,30 @@ function linkAt(term: Terminal, host: HTMLElement, ev: MouseEvent): string | nul text += line.translateToString(false); } if (clickIdx < 0) return null; + return { absRow: clickedLine, col, text, clickIdx }; +} +/** URL at the click: an OSC 8 hyperlink on the cell (explicit, so it beats any + * text scrape), else a scraped http(s) URL. Null if neither. */ +function urlAt(term: Terminal, ctx: ClickContext): ClickTarget | null { + const osc8 = osc8LinkAt(term, ctx.absRow, ctx.col); + if (osc8) return { kind: "url", uri: osc8 }; URL_RE.lastIndex = 0; let m: RegExpExecArray | null; - while ((m = URL_RE.exec(text))) { - if (clickIdx >= m.index && clickIdx < m.index + m[0].length) { - return m[0].replace(TRAILING_PUNCT_RE, ""); + while ((m = URL_RE.exec(ctx.text))) { + if (ctx.clickIdx >= m.index && ctx.clickIdx < m.index + m[0].length) { + return { kind: "url", uri: m[0].replace(TRAILING_PUNCT_RE, "") }; + } + } + return null; +} + +/** File-path reference at the click, or null. */ +function pathAt(ctx: ClickContext): ClickTarget | null { + for (const { raw, index } of scanPathTokens(ctx.text)) { + if (ctx.clickIdx >= index && ctx.clickIdx < index + raw.length) { + const { path, line, col } = parsePathToken(raw); + return { kind: "path", path, line, col }; } } return null; @@ -86,33 +162,37 @@ function linkAt(term: Terminal, host: HTMLElement, ev: MouseEvent): string | nul export function attachCmdClickLinkOpener( term: Terminal, host: HTMLElement, - open: (uri: string) => void, + onActivate: (target: ClickTarget, clientX: number, clientY: number) => void, + opts?: { urlsOnly?: boolean }, ): () => void { // Armed between mousedown and the trailing click event so the whole // gesture is swallowed as one unit (mouse reporting never sees any of it, // and the WebLinksAddon can't double-open). - let armedUri: string | null = null; + let armed: { target: ClickTarget; x: number; y: number } | null = null; function onDown(ev: MouseEvent) { - armedUri = null; + armed = null; if (ev.button !== 0 || !(ev.metaKey || ev.ctrlKey)) return; - const uri = linkAt(term, host, ev); - if (!uri) return; - armedUri = uri; + const ctx = clickContext(term, host, ev); + if (!ctx) return; + // URLs win over paths; the scratch shell (urlsOnly) never resolves paths. + const target = urlAt(term, ctx) ?? (opts?.urlsOnly ? null : pathAt(ctx)); + if (!target) return; + armed = { target, x: ev.clientX, y: ev.clientY }; ev.preventDefault(); ev.stopPropagation(); } function onUp(ev: MouseEvent) { - if (!armedUri) return; + if (!armed) return; ev.preventDefault(); ev.stopPropagation(); - open(armedUri); + onActivate(armed.target, armed.x, armed.y); // Clear AFTER the browser dispatches the trailing click (same turn), // so onClick below still sees the armed state and swallows it. - setTimeout(() => { armedUri = null; }, 0); + setTimeout(() => { armed = null; }, 0); } function onClick(ev: MouseEvent) { - if (!armedUri) return; + if (!armed) return; ev.preventDefault(); ev.stopPropagation(); } @@ -126,3 +206,51 @@ export function attachCmdClickLinkOpener( host.removeEventListener("click", onClick, true); }; } + +/** Hover-underline for file-path references. Activation is a fallback; real + * clicks go through the capture-phase opener above. */ +export function registerPathLinkProvider( + term: Terminal, + onActivate: (path: string, line: number | undefined, col: number | undefined, event: MouseEvent) => void, +): IDisposable { + return term.registerLinkProvider({ + provideLinks(bufferLineNumber, callback) { + const buf = term.buffer.active; + const y0 = bufferLineNumber - 1; + let start = y0; + while (start > 0 && buf.getLine(start)?.isWrapped) start--; + // Reconstruct the wrapped logical line (as in clickContext), tracking + // each row's start offset. Single-width assumption: wide CJK can shift it. + let text = ""; + const rowStart: number[] = []; + for (let ln = start; ln - start < 100; ln++) { + const line = buf.getLine(ln); + if (!line || (ln !== start && !line.isWrapped)) break; + rowStart.push(text.length); + text += line.translateToString(false); + } + if (rowStart.length === 0) { callback(undefined); return; } + + const toPos = (offset: number) => { + let i = 0; + while (i + 1 < rowStart.length && rowStart[i + 1] <= offset) i++; + return { row: start + i, col: offset - rowStart[i] }; + }; + + const links: ILink[] = []; + for (const { raw, index } of scanPathTokens(text)) { + const s = toPos(index); + // End on the last char (+1 -> 1-based inclusive), not one-past: a token + // ending on a soft wrap would otherwise land at col 0 of the next row. + const e = toPos(index + raw.length - 1); + const { path, line, col } = parsePathToken(raw); + links.push({ + range: { start: { x: s.col + 1, y: s.row + 1 }, end: { x: e.col + 1, y: e.row + 1 } }, + text: raw, + activate: (event) => onActivate(path, line, col, event), + }); + } + callback(links.length ? links : undefined); + }, + }); +}