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", () => {...@@ -167,6 +167,41 @@ describe("isCommandAvailable", () => {
167 } satisfies NodeJS.ProcessEnv;167 } satisfies NodeJS.ProcessEnv;
168 assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false);168 assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false);
169 });169 });
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 });
170});205});
171206
172describe("resolveAvailableEditors", () => {207describe("resolveAvailableEditors", () => {
apps/server/src/open.ts+38-11
...@@ -8,7 +8,7 @@...@@ -8,7 +8,7 @@
8 */8 */
9import { spawn } from "node:child_process";9import { spawn } from "node:child_process";
10import { accessSync, constants, statSync } from "node:fs";10import { accessSync, constants, statSync } from "node:fs";
11import { delimiter, extname, join } from "node:path";11import { extname, join } from "node:path";
1212
13import { EDITORS, type EditorId } from "@t3tools/contracts";13import { EDITORS, type EditorId } from "@t3tools/contracts";
14import { ServiceMap, Schema, Effect, Layer } from "effect";14import { ServiceMap, Schema, Effect, Layer } from "effect";
...@@ -78,24 +78,44 @@ function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<str...@@ -78,24 +78,44 @@ function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<str
78function resolveCommandCandidates(78function resolveCommandCandidates(
79 command: string,79 command: string,
80 platform: NodeJS.Platform,80 platform: NodeJS.Platform,
81 env: NodeJS.ProcessEnv,81 windowsPathExtensions: ReadonlyArray<string>,
82): ReadonlyArray<string> {82): ReadonlyArray<string> {
83 if (platform !== "win32") return [command];83 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];98 const candidates: string[] = [];
87 for (const extension of resolveWindowsPathExtensions(env)) {99 for (const extension of windowsPathExtensions) {
88 candidates.push(`${command}${extension}`);100 candidates.push(`${command}${extension}`);
89 candidates.push(`${command}${extension.toLowerCase()}`);101 candidates.push(`${command}${extension.toLowerCase()}`);
90 }102 }
91 return Array.from(new Set(candidates));103 return Array.from(new Set(candidates));
92}104}
93105
94function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean {106function isExecutableFile(
107 filePath: string,
108 platform: NodeJS.Platform,
109 windowsPathExtensions: ReadonlyArray<string>,
110): boolean {
95 try {111 try {
96 const stat = statSync(filePath);112 const stat = statSync(filePath);
97 if (!stat.isFile()) return false;113 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 }
99 accessSync(filePath, constants.X_OK);119 accessSync(filePath, constants.X_OK);
100 return true;120 return true;
101 } catch {121 } catch {
...@@ -103,28 +123,35 @@ function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean...@@ -103,28 +123,35 @@ function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean
103 }123 }
104}124}
105125
126function resolvePathDelimiter(platform: NodeJS.Platform): string {
127 return platform === "win32" ? ";" : ":";
128}
129
106export function isCommandAvailable(130export function isCommandAvailable(
107 command: string,131 command: string,
108 options: CommandAvailabilityOptions = {},132 options: CommandAvailabilityOptions = {},
109): boolean {133): boolean {
110 const platform = options.platform ?? process.platform;134 const platform = options.platform ?? process.platform;
111 const env = options.env ?? process.env;135 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
114 if (command.includes("/") || command.includes("\\")) {139 if (command.includes("/") || command.includes("\\")) {
115 return commandCandidates.some((candidate) => isExecutableFile(candidate, platform));140 return commandCandidates.some((candidate) =>
141 isExecutableFile(candidate, platform, windowsPathExtensions),
142 );
116 }143 }
117144
118 const pathValue = resolvePathEnvironmentVariable(env);145 const pathValue = resolvePathEnvironmentVariable(env);
119 if (pathValue.length === 0) return false;146 if (pathValue.length === 0) return false;
120 const pathEntries = pathValue147 const pathEntries = pathValue
121 .split(delimiter)148 .split(resolvePathDelimiter(platform))
122 .map((entry) => stripWrappingQuotes(entry.trim()))149 .map((entry) => stripWrappingQuotes(entry.trim()))
123 .filter((entry) => entry.length > 0);150 .filter((entry) => entry.length > 0);
124151
125 for (const pathEntry of pathEntries) {152 for (const pathEntry of pathEntries) {
126 for (const candidate of commandCandidates) {153 for (const candidate of commandCandidates) {
127 if (isExecutableFile(join(pathEntry, candidate), platform)) {154 if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) {
128 return true;155 return true;
129 }156 }
130 }157 }
apps/web/src/components/BranchToolbar.tsx+3
...@@ -183,6 +183,7 @@ export default function BranchToolbar({...@@ -183,6 +183,7 @@ export default function BranchToolbar({
183 title: "Failed to checkout branch.",183 title: "Failed to checkout branch.",
184 description: error instanceof Error ? error.message : "An error occurred.",184 description: error instanceof Error ? error.message : "An error occurred.",
185 });185 });
186 return;
186 }187 }
187188
188 let nextBranchName = selectedBranchName;189 let nextBranchName = selectedBranchName;
...@@ -219,6 +220,7 @@ export default function BranchToolbar({...@@ -219,6 +220,7 @@ export default function BranchToolbar({
219 title: "Failed to checkout branch.",220 title: "Failed to checkout branch.",
220 description: error instanceof Error ? error.message : "An error occurred.",221 description: error instanceof Error ? error.message : "An error occurred.",
221 });222 });
223 return;
222 }224 }
223 } catch (error) {225 } catch (error) {
224 toastManager.add({226 toastManager.add({
...@@ -226,6 +228,7 @@ export default function BranchToolbar({...@@ -226,6 +228,7 @@ export default function BranchToolbar({
226 title: "Failed to create branch.",228 title: "Failed to create branch.",
227 description: error instanceof Error ? error.message : "An error occurred.",229 description: error instanceof Error ? error.message : "An error occurred.",
228 });230 });
231 return;
229 }232 }
230233
231 setOptimisticBranch(name);234 setOptimisticBranch(name);
apps/web/src/components/ChatView.tsx-6
...@@ -3720,12 +3720,6 @@ const OpenInPicker = memo(function OpenInPicker({...@@ -3720,12 +3720,6 @@ const OpenInPicker = memo(function OpenInPicker({
3720 : (options[0]?.value ?? null);3720 : (options[0]?.value ?? null);
3721 const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null;3721 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
3729 const activeThread = useStore((store) =>3723 const activeThread = useStore((store) =>
3730 activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined,3724 activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined,
3731 );3725 );