diff --git a/electron/ssh.js b/electron/ssh.js index 2131636fe..c6568e168 100644 --- a/electron/ssh.js +++ b/electron/ssh.js @@ -85,6 +85,9 @@ function envPairs(env) { */ function boundaryArgv(bin, argv, env) { const rest = Array.isArray(argv) ? argv : []; + // Local process.env does not cross this boundary. Keep Git diagnostics + // stable on the host/distro too, without changing hooks' other locales. + if (/^git(?:\.exe)?$/i.test(basenameBin(bin))) env = { LANGUAGE: "C", ...env }; // Local settings files cannot cross SSH/WSL. Snapshot the opt-out for this run, // including providers that still need their overlay for MCP/session isolation. if (!require("./guardrails.js").guardrailsEnabled()) { diff --git a/electron/test/issues.test.js b/electron/test/issues.test.js index 28e58547e..dee31e63e 100644 --- a/electron/test/issues.test.js +++ b/electron/test/issues.test.js @@ -497,7 +497,7 @@ ${body} ssh.setExecFileSync((bin, args, opts) => { if (bin !== "ssh") return real(bin, args, opts); sshCalls += 1; - assert.match(args.at(-1), /cd '\/srv\/app' && 'git' 'remote' 'get-url' 'origin'/); + assert.match(args.at(-1), /cd '\/srv\/app' && 'env' 'LANGUAGE=C' 'git' 'remote' 'get-url' 'origin'/); return "git@github.com:acme/remote-app.git\n"; }); try { diff --git a/electron/test/services.test.js b/electron/test/services.test.js index 3c616fcb8..7e4d424eb 100644 --- a/electron/test/services.test.js +++ b/electron/test/services.test.js @@ -1384,7 +1384,7 @@ describe("services", () => { assert.ok(calls[0].args.includes("dev@box")); assert.ok(calls[0].args.includes("BatchMode=yes")); assert.ok(calls[0].args.includes("ConnectTimeout=10")); - const prefix = `cd '/srv/it'\\''s app' && ${enabled ? "" : "'env' 'CODER_GUARDRAILS=off' "}'git'`; + const prefix = `cd '/srv/it'\\''s app' && 'env' 'LANGUAGE=C' ${enabled ? "" : "'CODER_GUARDRAILS=off' "}'git'`; assert.deepEqual( calls.map((c) => c.args.at(-1)), [ diff --git a/electron/test/ssh-remote.test.js b/electron/test/ssh-remote.test.js index 187bfb351..bd1d56773 100644 --- a/electron/test/ssh-remote.test.js +++ b/electron/test/ssh-remote.test.js @@ -78,7 +78,7 @@ describe("worktrees.diff remoteHost", () => { assert.ok(calls[0].args.includes("BatchMode=yes")); assert.ok( calls.some((c) => - /cd '\/srv\/app' && 'git' 'status'/.test(c.args[c.args.length - 1]), + /cd '\/srv\/app' && 'env' 'LANGUAGE=C' 'git' 'status'/.test(c.args[c.args.length - 1]), ), ); assert.equal(result.files.length, 1); diff --git a/electron/test/ssh.test.js b/electron/test/ssh.test.js index 3fb18eb0c..90893ecca 100644 --- a/electron/test/ssh.test.js +++ b/electron/test/ssh.test.js @@ -105,7 +105,7 @@ describe("wrapCommand", () => { assert.equal(cmd.bin, "ssh"); assert.ok(cmd.args.includes("dev@box")); assert.ok(cmd.args.includes("BatchMode=yes")); - assert.equal(cmd.args[cmd.args.length - 1], "cd '/srv/app' && 'git' 'diff' 'HEAD'"); + assert.equal(cmd.args[cmd.args.length - 1], "cd '/srv/app' && 'env' 'LANGUAGE=C' 'git' 'diff' 'HEAD'"); }); it("uses basename of an absolute local binary on the remote", () => { diff --git a/electron/test/worktree-wsl.test.js b/electron/test/worktree-wsl.test.js index ccc61087a..a9a4835e6 100644 --- a/electron/test/worktree-wsl.test.js +++ b/electron/test/worktree-wsl.test.js @@ -100,6 +100,8 @@ describe("resolveGitCommand", () => { "--cd", "/home/me/repo", "--", + "env", + "LANGUAGE=C", "git", "status", ]); @@ -117,6 +119,8 @@ describe("resolveGitCommand", () => { "--cd", "/home/me/repo", "--", + "env", + "LANGUAGE=C", "git", "worktree", "remove", diff --git a/electron/test/worktrees-git.test.js b/electron/test/worktrees-git.test.js index 16c2ed336..e5cb993ac 100644 --- a/electron/test/worktrees-git.test.js +++ b/electron/test/worktrees-git.test.js @@ -2,11 +2,40 @@ const { describe, it, afterEach } = require("node:test"); const assert = require("node:assert/strict"); -const { gitEnv, gitOutAsync, gitTryAsync, setExecFile } = require("../worktrees-git.js"); +const { gitEnv, gitOutAsync, gitOutForDiffAsync, gitTryAsync, setExecFile, resolveGitCommand } = require("../worktrees-git.js"); describe("gitEnv (#1520 optional locks)", () => { afterEach(() => setExecFile(null)); + it("reads Git diagnostics in English while preserving hooks' locale and explicit overrides", () => { + const env = gitEnv(["status"], { LANG: "de_DE.UTF-8", LC_CTYPE: "ja_JP.UTF-8" }); + assert.equal(env.LANGUAGE, "C"); + assert.equal(env.LANG, "de_DE.UTF-8"); + assert.equal(env.LC_CTYPE, "ja_JP.UTF-8"); + assert.equal(gitEnv(["status"], { LANGUAGE: "fr" }).LANGUAGE, "fr"); + }); + + it("applies the diagnostic locale to actual local and SSH spawns", async () => { + const seen = []; + setExecFile((bin, args, opts, cb) => { + seen.push({ bin, args, env: opts.env }); + cb(null, "", ""); + }); + await gitOutAsync("/tmp", ["status"]); + await gitTryAsync("/tmp", ["commit", "-m", "x"], { env: { LANGUAGE: "fr" } }); + await gitOutForDiffAsync({ remoteHost: "dev@box", remotePath: "/srv/app" }, "/srv/app", ["status"]); + assert.equal(seen[0].env.LANGUAGE, "C"); + assert.equal(seen[1].env.LANGUAGE, "fr"); + assert.equal(seen[2].bin, "ssh"); + assert.match(seen[2].args.at(-1), /'env' 'LANGUAGE=C' 'git' 'status'/); + }); + + it("sets the diagnostic locale inside WSL rather than only on wsl.exe", () => { + const cwd = "\\\\wsl$\\Ubuntu\\home\\me\\repo"; + assert.deepEqual(resolveGitCommand(cwd, ["status"], "win32").args.slice(-4), ["env", "LANGUAGE=C", "git", "status"]); + assert.deepEqual(resolveGitCommand(cwd, ["status"], "win32", { LANGUAGE: "fr" }).args.slice(-4), ["env", "LANGUAGE=fr", "git", "status"]); + }); + it("sets GIT_OPTIONAL_LOCKS=0 on read-only subcommands only", () => { for (const args of [["status", "--porcelain"], ["diff"], ["log"], ["rev-parse", "HEAD"], ["-c", "x=y", "status"]]) { assert.equal(gitEnv(args).GIT_OPTIONAL_LOCKS, "0", args.join(" ")); diff --git a/electron/test/wsl.test.js b/electron/test/wsl.test.js index 2e0264e7f..1cd8e8b44 100644 --- a/electron/test/wsl.test.js +++ b/electron/test/wsl.test.js @@ -105,7 +105,7 @@ describe("wrapCommand on this platform", () => { "status", ]); assert.equal(out.bin, "ssh"); - assert.equal(out.args[out.args.length - 1], "cd '/srv/app' && 'git' 'status'"); + assert.equal(out.args[out.args.length - 1], "cd '/srv/app' && 'env' 'LANGUAGE=C' 'git' 'status'"); }); it("wraps a WSL-side project when platform is injected", () => { diff --git a/electron/worktrees-git.js b/electron/worktrees-git.js index 516dfb836..098507905 100644 --- a/electron/worktrees-git.js +++ b/electron/worktrees-git.js @@ -38,9 +38,10 @@ const PATCH_TRUNCATE = 100_000; * @param {string} cwd * @param {string[]} args * @param {NodeJS.Platform} [platform] + * @param {NodeJS.ProcessEnv} [env] * @returns {{ bin: string, args: string[], cwd?: string }} */ -function resolveGitCommand(cwd, args, platform = process.platform) { +function resolveGitCommand(cwd, args, platform = process.platform, env) { const argv = Array.isArray(args) ? args : []; const side = pathSide(cwd, platform); if (side.side !== "wsl") { @@ -50,7 +51,7 @@ function resolveGitCommand(cwd, args, platform = process.platform) { const s = pathSide(typeof a === "string" ? a : "", platform); return s.side === "wsl" ? s.linuxPath : a; }); - const cmd = buildWslCommand(side.distro, side.linuxPath, ["git", ...linuxArgs]); + const cmd = buildWslCommand(side.distro, side.linuxPath, ["env", `LANGUAGE=${env?.LANGUAGE ?? "C"}`, "git", ...linuxArgs]); return { bin: cmd.bin, args: cmd.args }; } @@ -159,14 +160,15 @@ function gitSubcommand(args) { * The one env for every local git spawn. Read-only subcommands get * GIT_OPTIONAL_LOCKS=0 (#1520); win32 gets core.longpaths=true appended * after any GIT_CONFIG_COUNT entries already set (#1523). - * ponytail: not forwarded across ssh/WSL wraps; those gits run elsewhere. + * LANGUAGE=C keeps diagnostic classifiers independent of the desktop locale. + * Other locale settings still reach hooks. SSH/WSL set LANGUAGE on the far side. * @param {string[]} args * @param {NodeJS.ProcessEnv} [extra] * @param {NodeJS.Platform} [platform] * @returns {NodeJS.ProcessEnv} */ function gitEnv(args, extra, platform = process.platform) { - const env = { ...process.env, ...(extra || {}) }; + const env = { ...process.env, LANGUAGE: "C", ...(extra || {}) }; if (READ_ONLY_GIT.has(gitSubcommand(args))) env.GIT_OPTIONAL_LOCKS = "0"; if (platform === "win32") { const n = parseInt(String(env.GIT_CONFIG_COUNT || ""), 10); @@ -283,7 +285,7 @@ function gitTry(cwd, args, opts) { if (opts && opts.timeout != null) { execOpts.timeout = opts.timeout; } - const cmd = resolveGitCommand(cwd, args); + const cmd = resolveGitCommand(cwd, args, process.platform, opts && opts.env); if (cmd.cwd) execOpts.cwd = cmd.cwd; else delete execOpts.cwd; // execCommand defaults the timeout when the caller did not set one. @@ -436,7 +438,7 @@ function gitTryAsync(cwd, args, opts) { const timeout = opts && opts.timeout != null ? opts.timeout : CHECKPOINT_GIT_TIMEOUT_MS; const env = gitEnv(args, { ...(opts && opts.env), GIT_TERMINAL_PROMPT: "0" }); - const cmd = resolveGitCommand(cwd, args); + const cmd = resolveGitCommand(cwd, args, process.platform, opts && opts.env); /** @type {import("node:child_process").ExecFileOptionsWithStringEncoding} */ const execOpts = { encoding: "utf8", diff --git a/src/components/composer/useComposerSpeech.ts b/src/components/composer/useComposerSpeech.ts index 489fdca68..f89ff4300 100644 --- a/src/components/composer/useComposerSpeech.ts +++ b/src/components/composer/useComposerSpeech.ts @@ -30,6 +30,7 @@ export type SpeechSnapshot = { threadId: string; draft: string; caret: number; + selectionEnd: number; prefix: string; suffix: string; accumulated: string; @@ -91,7 +92,7 @@ export function useComposerSpeech({ const captureRef = useRef(null); const dictatingRef = useRef(false); const writeDraftFor = useCallback( - (tid: string, text: string, caret?: number) => { + (tid: string, text: string, caret?: number, selectionEnd = caret) => { draftsRef.current[tid] = text; scheduleDraftSave(); if (liveThreadIdRef.current !== tid) return; @@ -100,7 +101,7 @@ export function useComposerSpeech({ el.value = text; if (caret != null) { el.focus(); - el.setSelectionRange(caret, caret); + el.setSelectionRange(caret, selectionEnd ?? caret); } } syncHasPrompt(text); @@ -120,7 +121,7 @@ export function useComposerSpeech({ setDictating(false); setSpeechConfirm(false); capture?.close(); - if (snap) writeDraftFor(snap.threadId, snap.draft, snap.caret); + if (snap) writeDraftFor(snap.threadId, snap.draft, snap.caret, snap.selectionEnd); const api = coderSpeech(); if (snap?.sessionId && api) { try { @@ -151,6 +152,7 @@ export function useComposerSpeech({ suffix: snap.suffix, accumulated: snap.accumulated, delta: status.delta, + replaceSelection: snap.selectionEnd > snap.caret, }); snap.accumulated = next.accumulated; writeDraftFor(snap.threadId, next.text, next.caret); @@ -162,8 +164,9 @@ export function useComposerSpeech({ original: snap.draft, originalCaret: snap.caret, transcript: status.transcript, + replaceSelection: snap.selectionEnd > snap.caret, }); - writeDraftFor(snap.threadId, next.text, next.caret); + writeDraftFor(snap.threadId, next.text, next.caret, status.transcript ? next.caret : snap.selectionEnd); if (status.note) setLocalError(status.note); snapshotRef.current = null; dictatingRef.current = false; @@ -203,12 +206,14 @@ export function useComposerSpeech({ const el = textareaRef.current; const draft = readDraft(); const caret = el?.selectionStart ?? draft.length; + const selectionEnd = el?.selectionEnd ?? caret; snapshotRef.current = { threadId, draft, caret, + selectionEnd, prefix: draft.slice(0, caret), - suffix: draft.slice(caret), + suffix: draft.slice(selectionEnd), accumulated: "", sessionId: "", }; @@ -246,7 +251,7 @@ export function useComposerSpeech({ snapshotRef.current = null; dictatingRef.current = false; setDictating(false); - if (snap) writeDraftFor(snap.threadId, snap.draft, snap.caret); + if (snap) writeDraftFor(snap.threadId, snap.draft, snap.caret, snap.selectionEnd); setLocalError(speechCaptureError(err)); } }, [disabled, readDraft, threadId, writeDraftFor]); diff --git a/src/pathLinks.ts b/src/pathLinks.ts index 111c025a2..abfa10891 100644 --- a/src/pathLinks.ts +++ b/src/pathLinks.ts @@ -19,7 +19,7 @@ export interface PathRef { const URL_RE = /https?:\/\/\S+|file:\/\/\S+|mailto:\S+/gi; /** Token body: path characters, plus `:` so `C:\` and `:12` stay attached. */ -const PATH_CHAR = /[A-Za-z0-9_./\\@+~:-]/; +const PATH_CHAR = /[A-Za-z0-9_./\\@+~:$()[\]…-]/; /** Ranges covered by a URL; shared with githubRefs.ts so a ref inside a link is never double-matched. */ export function urlRanges(text: string): Array<{ start: number; end: number }> { @@ -45,9 +45,37 @@ function isBoundary(text: string, i: number): boolean { } function stripTrailingDots(s: string): string { + const last = s.split(/[/\\]/).at(-1); + if (last && /^\.+$/.test(last)) return s; return s.replace(/\.+$/, ""); } +function trimPathToken(raw: string): { token: string; offset: number } { + let token = stripTrailingDots(raw); + let offset = 0; + for (const [open, close] of [["(", ")"], ["[", "]"]]) { + if (token.startsWith(open)) { + let depth = 0; + let end = -1; + for (let i = 0; i < token.length; i++) { + if (token[i] === open) depth++; + if (token[i] === close && --depth === 0) { + end = i; + break; + } + } + if (end === token.length - 1) { + token = token.slice(1, -1); + offset++; + } + } + const count = (char: string) => token.split(char).length - 1; + let excess = count(close) - count(open); + while (excess-- > 0 && token.endsWith(close)) token = token.slice(0, -1); + } + return { token, offset }; +} + /** * Pull a `:line` / `:line:col` suffix off a token without eating a Windows * drive letter (`C:\foo`). @@ -57,7 +85,8 @@ export function splitLineSuffix(raw: string): { line?: number; col?: number; } { - const m = raw.match(/^(.*?):(\d+)(?::(\d+))?$/); + const m = raw.match(/^(.*?):(\d+)(?::(\d+))?$/) ?? + raw.match(/^(.*?)\((\d+)(?:,\s*(\d+))?\)$/); if (!m) return { path: raw }; // `C:` alone is a drive, not `file:12`. if (/^[A-Za-z]$/.test(m[1])) return { path: raw }; @@ -107,9 +136,23 @@ export function findPathRefs(text: string): PathRef[] { } let j = i + 1; while (j < text.length && PATH_CHAR.test(text[j]) && !inRange(blocked, j)) { + // Compiler positions are suffixes, unlike parentheses in route directories. + if (text[j] === "(" && fileExt(text.slice(i, j))) { + const position = text.slice(j).match(/^\(\d+(?:,\s*\d+)?\)/); + if (position) { + j += position[0].length; + break; + } + } j += 1; } - let token = stripTrailingDots(text.slice(i, j)); + const trimmed = trimPathToken(text.slice(i, j)); + const untrimmedPath = splitLineSuffix(trimmed.token).path; + if (untrimmedPath.split(/[/\\]/).some((part) => /^(?:\.{3,}|…+)$/.test(part))) { + i = j; + continue; + } + let token = stripTrailingDots(trimmed.token); // Trailing `:` that is not a line suffix (e.g. `path:` in JSON keys). if (token.endsWith(":") && !/:\d+$/.test(token)) { token = token.slice(0, -1); @@ -122,13 +165,13 @@ export function findPathRefs(text: string): PathRef[] { path, line, col, - start: i, - end: i + raw.length, + start: i + trimmed.offset, + end: i + trimmed.offset + raw.length, }); - i += raw.length; + i = j; continue; } - i += 1; + i = j; } return hits; } diff --git a/src/speechDraft.ts b/src/speechDraft.ts index e4131e27b..1bb425c6a 100644 --- a/src/speechDraft.ts +++ b/src/speechDraft.ts @@ -8,16 +8,35 @@ export function formatSpeechModelSize( return `~${mb} MB / ${bytes.toLocaleString("en-US")} bytes`; } +const WORD = /[\p{L}\p{M}\p{N}]/u; +const UNSPACED = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u; + +function speechBoundary(left: string, right: string): string { + const end = Array.from(left.slice(-2)).at(-1) ?? ""; + const start = Array.from(right.slice(0, 2))[0] ?? ""; + return WORD.test(end) && WORD.test(start) && + !UNSPACED.test(end) && !UNSPACED.test(start) ? " " : ""; +} + +function insertSpeech(prefix: string, transcript: string, suffix: string, replaceSelection = false) { + const before = replaceSelection ? "" : speechBoundary(prefix, transcript); + const after = replaceSelection ? "" : speechBoundary(transcript, suffix); + return { + text: prefix + before + transcript + after + suffix, + caret: prefix.length + before.length + transcript.length, + }; +} + /** Concatenate an incremental suffix onto the one provisional range. */ export function applySpeechDelta(input: { prefix: string; suffix: string; accumulated: string; delta: string; + replaceSelection?: boolean; }): { text: string; accumulated: string; caret: number } { const accumulated = input.accumulated + input.delta; - const text = input.prefix + accumulated + input.suffix; - return { text, accumulated, caret: input.prefix.length + accumulated.length }; + return { ...insertSpeech(input.prefix, accumulated, input.suffix, input.replaceSelection), accumulated }; } /** Replace the whole provisional range. Empty final restores the snapshot. */ @@ -27,10 +46,10 @@ export function applySpeechTranscript(input: { original: string; originalCaret: number; transcript: string; + replaceSelection?: boolean; }): { text: string; caret: number } { if (!input.transcript) { return { text: input.original, caret: input.originalCaret }; } - const text = input.prefix + input.transcript + input.suffix; - return { text, caret: input.prefix.length + input.transcript.length }; + return insertSpeech(input.prefix, input.transcript, input.suffix, input.replaceSelection); } diff --git a/test/composer.test.tsx b/test/composer.test.tsx index ea60647b4..d7d675088 100644 --- a/test/composer.test.tsx +++ b/test/composer.test.tsx @@ -3597,6 +3597,26 @@ describe("Composer live dictation (#845)", () => { m.unmount(); }); + it("dictation replaces the selected range and preserves it on an empty transcript", async () => { + const fake = createFakeCoder({ speechStatus: SPEECH_READY }); + installSpeechMedia(); + const { m } = await mountSpeechComposer(fake); + const ta = m.query("textarea") as HTMLTextAreaElement; + await m.type(ta, "someOLDthing"); + ta.setSelectionRange(4, 7); + await m.click(mic(m)); + await inAct(() => fake.emitSpeech({ ...SPEECH_READY, state: "recording", delta: "new" })); + assert.equal(ta.value, "somenewthing"); + await inAct(() => fake.emitSpeech({ ...SPEECH_READY, transcript: "" })); + assert.equal(ta.value, "someOLDthing"); + assert.equal(ta.selectionStart, 4); + assert.equal(ta.selectionEnd, 7); + await m.click(mic(m)); + await inAct(() => fake.emitSpeech({ ...SPEECH_READY, transcript: "new" })); + assert.equal(ta.value, "somenewthing"); + m.unmount(); + }); + it("empty final transcript restores the original draft", async () => { const fake = createFakeCoder({ speechStatus: SPEECH_READY }); installSpeechMedia(); diff --git a/test/markdown.test.tsx b/test/markdown.test.tsx index f24a4c4c4..592a8103a 100644 --- a/test/markdown.test.tsx +++ b/test/markdown.test.tsx @@ -185,6 +185,37 @@ function linked( } describe("Markdown path links", () => { + it("opens complete framework paths with positions and leaves elided paths plain", async () => { + const paths = [ + "src/app/[id]/page.tsx", + "src/routes/(app)/+page.svelte", + "app/routes/posts.$id.tsx", + "node_modules/.pnpm/react@18.3.1/node_modules/react/index.js", + ]; + const requested: string[] = []; + const opened: Array<{ abs: string; line?: number; col?: number }> = []; + const text = paths.map((path) => `\`${path}:12:5\``).join(" and ") + + " but `/tmp/.../report.md` and `src/…/file.ts` stay plain"; + const m = await mount( + { + requested.push(...candidates); + return Object.fromEntries(candidates.map((path) => [path, `/wt/${path}`])); + }} + openPath={(abs, opts) => opened.push({ abs, line: opts?.line, col: opts?.col })} + > + + , + ); + await m.flush(); + const links = [...m.container.querySelectorAll("[data-path-link]")]; + assert.deepEqual(links.map((link) => link.textContent), paths.map((path) => `${path}:12:5`)); + for (const link of links) await m.click(link); + assert.deepEqual(opened, paths.map((path) => ({ abs: `/wt/${path}`, line: 12, col: 5 }))); + assert.ok(!requested.some((path) => path.includes("...") || path.includes("…"))); + assert.match(m.text(), /\/tmp\/\.\.\.\/report\.md/); + }); + it("makes a relative file clickable and opens the worktree path", async () => { const opened: string[] = []; const m = await mount(linked("see `src/foo.ts` please", opened)); diff --git a/test/pathLinks.test.ts b/test/pathLinks.test.ts index 491196567..9fe7f68e8 100644 --- a/test/pathLinks.test.ts +++ b/test/pathLinks.test.ts @@ -34,6 +34,20 @@ describe("findPathRefs", () => { assert.equal(col[0].col, 4); }); + it("keeps compiler positions separate from file names", () => { + for (const path of ["src/foo.ts", "src/routes/(app)/page.tsx", "C:\\repo\\foo.ts"]) { + for (const suffix of ["(12,3)", "(12, 3)", "(12)"]) { + const text = `${path}${suffix}: error TS2322`; + const hits = findPathRefs(text); + assert.equal(hits.length, 1, text); + assert.equal(hits[0].path, path); + assert.equal(hits[0].line, 12); + assert.equal(hits[0].col, suffix.includes(",") ? 3 : undefined); + assert.equal(text.slice(hits[0].start, hits[0].end), `${path}${suffix}`); + } + } + }); + it("leaves http links alone", () => { assert.deepEqual(findPathRefs("see https://example.com/src/foo.ts"), []); assert.deepEqual(findPathRefs("docs at http://localhost:5173/src/foo.ts"), []); @@ -48,6 +62,35 @@ describe("findPathRefs", () => { assert.equal(hits.length, 1); assert.equal(hits[0].path, "src/components/Markdown.tsx"); }); + + it("leaves elided paths wholly unlinked", () => { + for (const path of ["/tmp/.../report.md", "/tmp/claude-1000/...", "src/…/file.ts:12:3", "C:\\repo\\...\\file.ts", "/tmp/……/file.ts", "../.../report.md"]) { + assert.deepEqual(findPathRefs(path), [], path); + } + const hits = findPathRefs("skip /tmp/.../bad.ts then src/good.ts:4"); + assert.deepEqual(hits.map((hit) => hit.raw), ["src/good.ts:4"]); + }); + + it("keeps framework and package-manager segments intact", () => { + for (const path of ["src/app/[id]/page.tsx", "src/app/[[...slug]]/page.tsx", "src/routes/(app)/+page.svelte", "app/routes/posts.$id.tsx", "node_modules/.pnpm/react@18.3.1/node_modules/react/index.js", "(app)/page.tsx"]) { + const text = `See ${path}:12:5 for details`; + const hits = findPathRefs(text); + assert.equal(hits.length, 1, path); + assert.equal(hits[0].path, path); + assert.equal(hits[0].line, 12); + assert.equal(hits[0].col, 5); + assert.equal(text.slice(hits[0].start, hits[0].end), `${path}:12:5`); + } + }); + + it("preserves parent paths and excludes surrounding prose punctuation", () => { + for (const [text, expected] of [["(src/foo.ts:12).", "src/foo.ts:12"], ["src/foo.ts).", "src/foo.ts"], ["(src/foo.ts:12)", "src/foo.ts:12"], ["[src/foo.ts]", "src/foo.ts"], ["src/routes/(app)/page.tsx.", "src/routes/(app)/page.tsx"], ["../src/foo.ts", "../src/foo.ts"], ["../..", "../.."], ["src/.hidden.ts", "src/.hidden.ts"]]) { + const hits = findPathRefs(text); + assert.equal(hits.length, 1, text); + assert.equal(hits[0].raw, expected); + assert.equal(text.slice(hits[0].start, hits[0].end), expected); + } + }); }); describe("resolveWorkspacePath", () => { diff --git a/test/speechDraft.test.ts b/test/speechDraft.test.ts index fbee96763..c30871a81 100644 --- a/test/speechDraft.test.ts +++ b/test/speechDraft.test.ts @@ -62,4 +62,44 @@ describe("speech draft range", () => { assert.equal(restored.text, "Hello "); assert.equal(restored.caret, 6); }); + + it("separates inserted words on both sides without changing punctuation or unspaced scripts", () => { + for (const [prefix, transcript, suffix, expected] of [ + ["Hello", "world", "next", "Hello world next"], + ["코드를", "검토", "해주세요", "코드를 검토 해주세요"], + ["नमस्ते", "दुनिया", "", "नमस्ते दुनिया"], + ["Cafe\u0301", "menu", "", "Cafe\u0301 menu"], + ["Hello ", "world", "!", "Hello world!"], + ["Hello\n", "world", "", "Hello\nworld"], + ["Hello", "world", "\nnext", "Hello world\nnext"], + ["𐐀", "word", "", "𐐀 word"], + ["Hello", " world ", "next", "Hello world next"], + ["(", "hello", ")", "(hello)"], + ["日本", "語", "です", "日本語です"], + ["Hello", "世界", "", "Hello世界"], + ["สวัสดี", "โลก", "", "สวัสดีโลก"], + ]) { + const result = applySpeechTranscript({ prefix, suffix, transcript, original: prefix + suffix, originalCaret: prefix.length }); + assert.equal(result.text, expected); + assert.equal(result.text.slice(0, result.caret).endsWith(transcript), true); + } + }); + + it("formats the provisional range without adding spaces between partial word deltas", () => { + const first = applySpeechDelta({ prefix: "Hello", suffix: "next", accumulated: "", delta: "wor" }); + assert.equal(first.text, "Hello wor next"); + const second = applySpeechDelta({ prefix: "Hello", suffix: "next", accumulated: first.accumulated, delta: "ld" }); + const final = applySpeechTranscript({ prefix: "Hello", suffix: "next", original: "Hellonext", originalCaret: 5, transcript: "world" }); + assert.equal(second.text, "Hello world next"); + assert.equal(second.text, final.text); + assert.equal(second.caret, final.caret); + assert.equal(second.accumulated, "world"); + }); + + it("replaces a selection verbatim and restores it when transcription is empty", () => { + const input = { prefix: "some", suffix: "thing", original: "someOLDthing", originalCaret: 4, replaceSelection: true }; + assert.equal(applySpeechDelta({ ...input, accumulated: "", delta: "new" }).text, "somenewthing"); + assert.equal(applySpeechTranscript({ ...input, transcript: "new" }).text, "somenewthing"); + assert.deepEqual(applySpeechTranscript({ ...input, transcript: "" }), { text: input.original, caret: 4 }); + }); });