| author | |
| committer | |
| log | 302323a64b0e4342af013784f4422020b7c1fa53 |
| tree | f216b3be4980cc74c6e512cce9c25293f82d9a55 |
| parent | b981fad920f43fe64dac4b547aba6ca4fb2f4715 |
- 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 render4 files changed, 76 insertions(+), 17 deletions(-)
apps/server/src/open.test.ts+35| ... | ... | @@ -167,6 +167,41 @@ describe("isCommandAvailable", () => { |
| 167 | 167 | } satisfies NodeJS.ProcessEnv; |
| 168 | 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 | }); |
| 171 | 206 | |
| 172 | 207 | describe("resolveAvailableEditors", () => { |
apps/server/src/open.ts+38-11| ... | ... | @@ -8,7 +8,7 @@ |
| 8 | 8 | */ |
| 9 | 9 | import { spawn } from "node:child_process"; |
| 10 | 10 | import { accessSync, constants, statSync } from "node:fs"; |
| 11 | import { delimiter, extname, join } from "node:path"; | |
| 11 | import { extname, join } from "node:path"; | |
| 12 | 12 | |
| 13 | 13 | import { EDITORS, type EditorId } from "@t3tools/contracts"; |
| 14 | 14 | import { ServiceMap, Schema, Effect, Layer } from "effect"; |
| ... | ... | @@ -78,24 +78,44 @@ function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<str |
| 78 | 78 | function resolveCommandCandidates( |
| 79 | 79 | command: string, |
| 80 | 80 | platform: NodeJS.Platform, |
| 81 | env: NodeJS.ProcessEnv, | |
| 81 | windowsPathExtensions: ReadonlyArray<string>, | |
| 82 | 82 | ): ReadonlyArray<string> { |
| 83 | 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 | } | |
| 85 | 97 | |
| 86 | const candidates = [command]; | |
| 87 | for (const extension of resolveWindowsPathExtensions(env)) { | |
| 98 | const candidates: string[] = []; | |
| 99 | for (const extension of windowsPathExtensions) { | |
| 88 | 100 | candidates.push(`${command}${extension}`); |
| 89 | 101 | candidates.push(`${command}${extension.toLowerCase()}`); |
| 90 | 102 | } |
| 91 | 103 | return Array.from(new Set(candidates)); |
| 92 | 104 | } |
| 93 | 105 | |
| 94 | function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean { | |
| 106 | function isExecutableFile( | |
| 107 | filePath: string, | |
| 108 | platform: NodeJS.Platform, | |
| 109 | windowsPathExtensions: ReadonlyArray<string>, | |
| 110 | ): boolean { | |
| 95 | 111 | try { |
| 96 | 112 | const stat = statSync(filePath); |
| 97 | 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 | 119 | accessSync(filePath, constants.X_OK); |
| 100 | 120 | return true; |
| 101 | 121 | } catch { |
| ... | ... | @@ -103,28 +123,35 @@ function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean |
| 103 | 123 | } |
| 104 | 124 | } |
| 105 | 125 | |
| 126 | function resolvePathDelimiter(platform: NodeJS.Platform): string { | |
| 127 | return platform === "win32" ? ";" : ":"; | |
| 128 | } | |
| 129 | ||
| 106 | 130 | export function isCommandAvailable( |
| 107 | 131 | command: string, |
| 108 | 132 | options: CommandAvailabilityOptions = {}, |
| 109 | 133 | ): boolean { |
| 110 | 134 | const platform = options.platform ?? process.platform; |
| 111 | 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); | |
| 113 | 138 | |
| 114 | 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 | } |
| 117 | 144 | |
| 118 | 145 | const pathValue = resolvePathEnvironmentVariable(env); |
| 119 | 146 | if (pathValue.length === 0) return false; |
| 120 | 147 | const pathEntries = pathValue |
| 121 | .split(delimiter) | |
| 148 | .split(resolvePathDelimiter(platform)) | |
| 122 | 149 | .map((entry) => stripWrappingQuotes(entry.trim())) |
| 123 | 150 | .filter((entry) => entry.length > 0); |
| 124 | 151 | |
| 125 | 152 | for (const pathEntry of pathEntries) { |
| 126 | 153 | for (const candidate of commandCandidates) { |
| 127 | if (isExecutableFile(join(pathEntry, candidate), platform)) { | |
| 154 | if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { | |
| 128 | 155 | return true; |
| 129 | 156 | } |
| 130 | 157 | } |
apps/web/src/components/BranchToolbar.tsx+3| ... | ... | @@ -183,6 +183,7 @@ export default function BranchToolbar({ |
| 183 | 183 | title: "Failed to checkout branch.", |
| 184 | 184 | description: error instanceof Error ? error.message : "An error occurred.", |
| 185 | 185 | }); |
| 186 | return; | |
| 186 | 187 | } |
| 187 | 188 | |
| 188 | 189 | let nextBranchName = selectedBranchName; |
| ... | ... | @@ -219,6 +220,7 @@ export default function BranchToolbar({ |
| 219 | 220 | title: "Failed to checkout branch.", |
| 220 | 221 | description: error instanceof Error ? error.message : "An error occurred.", |
| 221 | 222 | }); |
| 223 | return; | |
| 222 | 224 | } |
| 223 | 225 | } catch (error) { |
| 224 | 226 | toastManager.add({ |
| ... | ... | @@ -226,6 +228,7 @@ export default function BranchToolbar({ |
| 226 | 228 | title: "Failed to create branch.", |
| 227 | 229 | description: error instanceof Error ? error.message : "An error occurred.", |
| 228 | 230 | }); |
| 231 | return; | |
| 229 | 232 | } |
| 230 | 233 | |
| 231 | 234 | setOptimisticBranch(name); |
apps/web/src/components/ChatView.tsx-6| ... | ... | @@ -3720,12 +3720,6 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 3720 | 3720 | : (options[0]?.value ?? null); |
| 3721 | 3721 | const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; |
| 3722 | 3722 | |
| 3723 | useEffect(() => { | |
| 3724 | if (!effectiveEditor || effectiveEditor === lastEditor) return; | |
| 3725 | localStorage.setItem(LAST_EDITOR_KEY, effectiveEditor); | |
| 3726 | setLastEditor(effectiveEditor); | |
| 3727 | }, [effectiveEditor, lastEditor]); | |
| 3728 | ||
| 3729 | 3723 | const activeThread = useStore((store) => |
| 3730 | 3724 | activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, |
| 3731 | 3725 | ); |