diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 26253e383761d..673afa2dc427a 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -897,6 +897,57 @@ it.effect( }, ); +it.effect.each(["Zed Nightly", "Zed Preview"])( + "launches stable Zed on Windows when %s comes first on PATH", + (channel) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-channels-" }); + // A user folder naming Zed and a channel does not mark either install. + const programs = path.join(root, "Users", "zedpreview", "Programs"); + const prereleaseBin = path.join(programs, channel, "bin"); + const stableBin = path.join(programs, "Zed", "bin"); + for (const dir of [prereleaseBin, stableBin]) { + yield* fs.makeDirectory(dir, { recursive: true }); + yield* fs.writeFileString(path.join(dir, "zed.EXE"), ""); + } + const env = { PATH: `${prereleaseBin};${stableBin}`, PATHEXT: ".COM;.EXE;.BAT;.CMD" }; + + const launchZed = Effect.gen(function* () { + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ editor: "zed", cwd: "C:\\workspace" }); + }).pipe( + Effect.provide( + layerTest({ + platform: "win32", + env, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + assert.ok(spawned); + return spawned; + }); + + const stable = yield* launchZed; + assert.equal(stable.command, path.join(stableBin, "zed.EXE")); + assert.deepEqual(stable.args, ["C:\\workspace"]); + + env.PATH = `${stableBin};${prereleaseBin}`; + assert.equal((yield* launchZed).command, "zed"); + + env.PATH = `${prereleaseBin};${stableBin}`; + // With only the prerelease install left, it still opens by name. + yield* fs.remove(path.join(stableBin, "zed.EXE")); + assert.equal((yield* launchZed).command, "zed"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + const launchWindowsShimEditor = (cwd: string) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/packages/shared/src/editor.ts b/packages/shared/src/editor.ts index fdde5f4ff7041..ba6ee17e3cd66 100644 --- a/packages/shared/src/editor.ts +++ b/packages/shared/src/editor.ts @@ -7,10 +7,20 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as HostProcess from "./HostProcess.ts"; -import { isCommandAvailable, resolveCommandPath } from "./shell.ts"; +import { isCommandAvailable, resolveCommandPath, resolveCommandPaths } from "./shell.ts"; type Editor = (typeof EDITORS)[number]; +// The nearest Zed folder identifies the channel, even below a user named zedpreview. +const isPrereleaseZedPath = (filePath: string) => { + const install = filePath + .split(/[\\/]/) + .slice(0, -1) + .findLast((folder) => folder.toLowerCase().includes("zed")) + ?.toLowerCase(); + return install !== undefined && (install.includes("nightly") || install.includes("preview")); +}; + const installNames: Partial>> = { vscode: ["Visual Studio Code"], "vscode-insiders": ["Visual Studio Code - Insiders"], @@ -59,7 +69,21 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun // Prefer Zed's macOS bundle because OpenZFS also installs a zed executable. const preferBundle = platform === "darwin" && editor.id === "zed"; const preferZeditor = platform === "linux" && editor.id === "zed"; - if (!preferBundle && !preferZeditor) { + if (platform === "win32" && editor.id === "zed") { + const isStable = (resolved: string) => !isPrereleaseZedPath(resolved); + let firstCommand: string | undefined; + for (const command of editor.commands) { + const matches = yield* resolveCommandPaths(command, { env, until: isStable }); + const stable = matches.find(isStable); + // Bypass PATH only when stable is behind a prerelease install. + if (stable !== undefined) { + const isFirst = firstCommand === undefined && stable === matches[0]; + return Option.some({ command: isFirst ? command : stable, baseArgs }); + } + if (firstCommand === undefined && matches.length > 0) firstCommand = command; + } + if (firstCommand !== undefined) return Option.some({ command: firstCommand, baseArgs }); + } else if (!preferBundle && !preferZeditor) { for (const command of editor.commands) { if (yield* isCommandAvailable(command, { env })) return Option.some({ command, baseArgs }); } diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 83561c8daced4..28aa3b9ce6155 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -32,6 +32,7 @@ import { readEnvironmentFromLoginShell, readEnvironmentFromWindowsShell, resolveCommandPath, + resolveCommandPaths, resolveSpawnCommand, resolveWindowsEnvironment, runsGitWithoutConsole, @@ -595,6 +596,34 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { Effect.provideService(CommandResolutionCache, new Map()), ), ); + + it.effect("stops listing PATH matches at the first one the caller accepts", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const first = yield* fs.makeTempDirectoryScoped(); + const second = yield* fs.makeTempDirectoryScoped(); + const third = yield* fs.makeTempDirectoryScoped(); + for (const dir of [first, second, third]) { + yield* fs.writeFileString(path.join(dir, "zed.EXE"), ""); + } + const probed: Array = []; + const matches = yield* resolveCommandPaths("zed", { + env: { PATH: `${first};${second};${third}`, PATHEXT: ".EXE" }, + until: (match) => path.dirname(match) === second, + }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + stat: (file) => { + probed.push(file); + return fs.stat(file); + }, + }), + ); + expect(matches).toEqual([path.join(first, "zed.EXE"), path.join(second, "zed.EXE")]); + expect(probed.filter((file) => file.startsWith(third))).toEqual([]); + }).pipe(Effect.provideService(HostProcess.Platform, "win32")), + ); }); describe("runsGitWithoutConsole", () => { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 2b4dd9b96493b..3d25bde8d7809 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -614,6 +614,47 @@ const isExecutableFile = Effect.fnUntraced(function* ( return canExecuteFile(filePath); }); +// Called within the traced command resolvers. +const findOnPath = Effect.fnUntraced(function* ( + pathValue: string, + commandCandidates: ReadonlyArray, + platform: NodeJS.Platform, + windowsPathExtensions: ReadonlyArray, + isLast: (match: string) => boolean, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const path = yield* Path.Path; + // Keep case variants: Windows can make PATH directories case-sensitive. + const pathEntries: string[] = []; + const seenPathEntries = new Set(); + for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { + const pathEntry = stripWrappingQuotes(entry.trim()); + if (pathEntry.length === 0 || seenPathEntries.has(pathEntry)) continue; + + seenPathEntries.add(pathEntry); + pathEntries.push(pathEntry); + } + + const listings = yield* PathDirectoryListings; + const matches: string[] = []; + for (const pathEntry of pathEntries) { + let listing = listings && (yield* Cache.get(listings, pathEntry)); + if (listings && listing?.names && listing.modified !== (yield* directoryMtime(pathEntry))) { + yield* Cache.invalidate(listings, pathEntry); + listing = yield* Cache.get(listings, pathEntry); + } + for (const candidate of commandCandidates) { + if (listing?.names && !listing.names.has(candidate.toLowerCase())) continue; + const candidatePath = path.join(pathEntry, candidate); + if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { + matches.push(candidatePath); + if (isLast(candidatePath)) return matches; + break; + } + } + } + return matches; +}); + const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")(function* ( command: string, options: CommandAvailabilityOptions & { readonly platform: NodeJS.Platform }, @@ -653,35 +694,15 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat return cached.resolvedPath; } - // Keep case variants: Windows can make PATH directories case-sensitive. - const pathEntries: string[] = []; - const seenPathEntries = new Set(); - for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { - const pathEntry = stripWrappingQuotes(entry.trim()); - if (pathEntry.length === 0 || seenPathEntries.has(pathEntry)) continue; - - seenPathEntries.add(pathEntry); - pathEntries.push(pathEntry); - } - - const listings = yield* PathDirectoryListings; - for (const pathEntry of pathEntries) { - let listing = listings && (yield* Cache.get(listings, pathEntry)); - if (listings && listing?.names && listing.modified !== (yield* directoryMtime(pathEntry))) { - yield* Cache.invalidate(listings, pathEntry); - listing = yield* Cache.get(listings, pathEntry); - } - for (const candidate of commandCandidates) { - if (listing?.names && !listing.names.has(candidate.toLowerCase())) continue; - const candidatePath = path.join(pathEntry, candidate); - if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { - cacheCommandResolution(cache, cacheKey, candidatePath, nowNanos); - return candidatePath; - } - } - } - cacheCommandResolution(cache, cacheKey, null, nowNanos); - return null; + const [resolvedPath = null] = yield* findOnPath( + pathValue, + commandCandidates, + platform, + windowsPathExtensions, + () => true, + ); + cacheCommandResolution(cache, cacheKey, resolvedPath, nowNanos); + return resolvedPath; }); export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function* ( @@ -694,6 +715,24 @@ export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function }); }); +// Matches for a bare command in PATH order, up to the first one `until` accepts; not cached. +export const resolveCommandPaths = Effect.fn("shell.resolveCommandPaths")(function* ( + command: string, + options: CommandAvailabilityOptions & { readonly until: (match: string) => boolean }, +) { + const path = yield* Path.Path; + const platform = yield* HostProcess.Platform; + const env = options.env ?? (yield* HostProcess.Environment); + const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; + return yield* findOnPath( + resolvePathEnvironmentVariable(env), + resolveCommandCandidates(command, platform, windowsPathExtensions, path.extname), + platform, + windowsPathExtensions, + options.until, + ); +}); + // Git 2.56 x64 uses ucrt64; ARM64 uses clangarm64; older x64/32-bit builds use mingw64/mingw32. const GIT_FOR_WINDOWS_BUILDS = ["ucrt64", "clangarm64", "mingw64", "mingw32"] as const;