Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions electron/ssh.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
2 changes: 1 addition & 1 deletion electron/test/issues.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion electron/test/services.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
[
Expand Down
2 changes: 1 addition & 1 deletion electron/test/ssh-remote.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion electron/test/ssh.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
4 changes: 4 additions & 0 deletions electron/test/worktree-wsl.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ describe("resolveGitCommand", () => {
"--cd",
"/home/me/repo",
"--",
"env",
"LANGUAGE=C",
"git",
"status",
]);
Expand All @@ -117,6 +119,8 @@ describe("resolveGitCommand", () => {
"--cd",
"/home/me/repo",
"--",
"env",
"LANGUAGE=C",
"git",
"worktree",
"remove",
Expand Down
31 changes: 30 additions & 1 deletion electron/test/worktrees-git.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(" "));
Expand Down
2 changes: 1 addition & 1 deletion electron/test/wsl.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
14 changes: 8 additions & 6 deletions electron/worktrees-git.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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 };
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 11 additions & 6 deletions src/components/composer/useComposerSpeech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type SpeechSnapshot = {
threadId: string;
draft: string;
caret: number;
selectionEnd: number;
prefix: string;
suffix: string;
accumulated: string;
Expand Down Expand Up @@ -91,7 +92,7 @@ export function useComposerSpeech({
const captureRef = useRef<SpeechCapture | null>(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;
Expand All @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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: "",
};
Expand Down Expand Up @@ -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]);
Expand Down
57 changes: 50 additions & 7 deletions src/pathLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_./\\@+~:$()[\]…-]/;
Comment thread
currentbits marked this conversation as resolved.
Resolved

/** 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 }> {
Expand All @@ -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`).
Expand All @@ -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 };
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down
Loading
Loading