authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 18:39:58-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 18:53:21-08:00
log302323a64b0e4342af013784f4422020b7c1fa53
treef216b3be4980cc74c6e512cce9c25293f82d9a55
parentb981fad920f43fe64dac4b547aba6ca4fb2f4715

Fix Windows command detection and branch checkout error flow

- tighten win32 command availability checks for PATHEXT and PATH delimiter handling - add server tests for Windows executable resolution edge cases - stop branch/create flows after checkout/create failures to avoid stale optimistic UI - remove OpenInPicker side effect that persisted last editor on render

4 files changed, 76 insertions(+), 17 deletions(-)

apps/server/src/open.test.ts+35
......@@ -167,6 +167,41 @@ describe("isCommandAvailable", () => {
167167 } satisfies NodeJS.ProcessEnv;
168168 assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false);
169169 });
170
171 it("does not treat bare files without executable extension as available on win32", () => {
172 withTempDir((dir) => {
173 fs.writeFileSync(path.join(dir, "npm"), "echo nope\r\n", "utf8");
174 const env = {
175 PATH: dir,
176 PATHEXT: ".COM;.EXE;.BAT;.CMD",
177 } satisfies NodeJS.ProcessEnv;
178 assert.equal(isCommandAvailable("npm", { platform: "win32", env }), false);
179 });
180 });
181
182 it("appends PATHEXT for commands with non-executable extensions on win32", () => {
183 withTempDir((dir) => {
184 fs.writeFileSync(path.join(dir, "my.tool.CMD"), "@echo off\r\n", "utf8");
185 const env = {
186 PATH: dir,
187 PATHEXT: ".COM;.EXE;.BAT;.CMD",
188 } satisfies NodeJS.ProcessEnv;
189 assert.equal(isCommandAvailable("my.tool", { platform: "win32", env }), true);
190 });
191 });
192
193 it("uses platform-specific PATH delimiter for platform overrides", () => {
194 withTempDir((firstDir) => {
195 withTempDir((secondDir) => {
196 fs.writeFileSync(path.join(secondDir, "code.CMD"), "@echo off\r\n", "utf8");
197 const env = {
198 PATH: `${firstDir};${secondDir}`,
199 PATHEXT: ".COM;.EXE;.BAT;.CMD",
200 } satisfies NodeJS.ProcessEnv;
201 assert.equal(isCommandAvailable("code", { platform: "win32", env }), true);
202 });
203 });
204 });
170205});
171206
172207describe("resolveAvailableEditors", () => {
apps/server/src/open.ts+38-11
......@@ -8,7 +8,7 @@
88 */
99import { spawn } from "node:child_process";
1010import { accessSync, constants, statSync } from "node:fs";
11import { delimiter, extname, join } from "node:path";
11import { extname, join } from "node:path";
1212
1313import { EDITORS, type EditorId } from "@t3tools/contracts";
1414import { ServiceMap, Schema, Effect, Layer } from "effect";
......@@ -78,24 +78,44 @@ function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<str
7878function resolveCommandCandidates(
7979 command: string,
8080 platform: NodeJS.Platform,
81 env: NodeJS.ProcessEnv,
81 windowsPathExtensions: ReadonlyArray<string>,
8282): ReadonlyArray<string> {
8383 if (platform !== "win32") return [command];
84 if (extname(command).length > 0) return [command];
84 const extension = extname(command);
85 const normalizedExtension = extension.toUpperCase();
86
87 if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) {
88 const commandWithoutExtension = command.slice(0, -extension.length);
89 return Array.from(
90 new Set([
91 command,
92 `${commandWithoutExtension}${normalizedExtension}`,
93 `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`,
94 ]),
95 );
96 }
8597
86 const candidates = [command];
87 for (const extension of resolveWindowsPathExtensions(env)) {
98 const candidates: string[] = [];
99 for (const extension of windowsPathExtensions) {
88100 candidates.push(`${command}${extension}`);
89101 candidates.push(`${command}${extension.toLowerCase()}`);
90102 }
91103 return Array.from(new Set(candidates));
92104}
93105
94function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean {
106function isExecutableFile(
107 filePath: string,
108 platform: NodeJS.Platform,
109 windowsPathExtensions: ReadonlyArray<string>,
110): boolean {
95111 try {
96112 const stat = statSync(filePath);
97113 if (!stat.isFile()) return false;
98 if (platform === "win32") return true;
114 if (platform === "win32") {
115 const extension = extname(filePath);
116 if (extension.length === 0) return false;
117 return windowsPathExtensions.includes(extension.toUpperCase());
118 }
99119 accessSync(filePath, constants.X_OK);
100120 return true;
101121 } catch {
......@@ -103,28 +123,35 @@ function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean
103123 }
104124}
105125
126function resolvePathDelimiter(platform: NodeJS.Platform): string {
127 return platform === "win32" ? ";" : ":";
128}
129
106130export function isCommandAvailable(
107131 command: string,
108132 options: CommandAvailabilityOptions = {},
109133): boolean {
110134 const platform = options.platform ?? process.platform;
111135 const env = options.env ?? process.env;
112 const commandCandidates = resolveCommandCandidates(command, platform, env);
136 const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : [];
137 const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions);
113138
114139 if (command.includes("/") || command.includes("\\")) {
115 return commandCandidates.some((candidate) => isExecutableFile(candidate, platform));
140 return commandCandidates.some((candidate) =>
141 isExecutableFile(candidate, platform, windowsPathExtensions),
142 );
116143 }
117144
118145 const pathValue = resolvePathEnvironmentVariable(env);
119146 if (pathValue.length === 0) return false;
120147 const pathEntries = pathValue
121 .split(delimiter)
148 .split(resolvePathDelimiter(platform))
122149 .map((entry) => stripWrappingQuotes(entry.trim()))
123150 .filter((entry) => entry.length > 0);
124151
125152 for (const pathEntry of pathEntries) {
126153 for (const candidate of commandCandidates) {
127 if (isExecutableFile(join(pathEntry, candidate), platform)) {
154 if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) {
128155 return true;
129156 }
130157 }
apps/web/src/components/BranchToolbar.tsx+3
......@@ -183,6 +183,7 @@ export default function BranchToolbar({
183183 title: "Failed to checkout branch.",
184184 description: error instanceof Error ? error.message : "An error occurred.",
185185 });
186 return;
186187 }
187188
188189 let nextBranchName = selectedBranchName;
......@@ -219,6 +220,7 @@ export default function BranchToolbar({
219220 title: "Failed to checkout branch.",
220221 description: error instanceof Error ? error.message : "An error occurred.",
221222 });
223 return;
222224 }
223225 } catch (error) {
224226 toastManager.add({
......@@ -226,6 +228,7 @@ export default function BranchToolbar({
226228 title: "Failed to create branch.",
227229 description: error instanceof Error ? error.message : "An error occurred.",
228230 });
231 return;
229232 }
230233
231234 setOptimisticBranch(name);
apps/web/src/components/ChatView.tsx-6
......@@ -3720,12 +3720,6 @@ const OpenInPicker = memo(function OpenInPicker({
37203720 : (options[0]?.value ?? null);
37213721 const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null;
37223722
3723 useEffect(() => {
3724 if (!effectiveEditor || effectiveEditor === lastEditor) return;
3725 localStorage.setItem(LAST_EDITOR_KEY, effectiveEditor);
3726 setLastEditor(effectiveEditor);
3727 }, [effectiveEditor, lastEditor]);
3728
37293723 const activeThread = useStore((store) =>
37303724 activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined,
37313725 );