From 659d97ea84fe6027432b9d8f632879d3f6a3bfb0 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 12:03:41 -0500 Subject: [PATCH 1/4] fix(server): open stable Zed when a prerelease Zed is first on PATH On Windows, Zed's stable, Preview and Nightly channels each install a `zed` CLI in their own folder, so choosing "Zed" opened whichever came first on PATH. Generic Zed on Windows now prefers the first PATH install whose own folder does not name a prerelease channel, and launches it by absolute path when it is not the first match. The PATH walk is shared with resolveCommandPath. Selection rule from #9501 by EzraBuild. Fixes #1978. Co-Authored-By: Claude Opus 5.5 --- .../src/process/externalLauncher.test.ts | 46 +++++++++ packages/shared/src/editor.ts | 30 +++++- packages/shared/src/shell.ts | 99 +++++++++++++------ 3 files changed, 144 insertions(+), 31 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 73ff1b067e875..f8d24f39d5723 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -695,6 +695,52 @@ it.effect("keeps scanning after the caller is interrupted and shares that scan", ); }); +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-" }); + const prereleaseBin = path.join(root, "Programs", channel, "bin"); + // "preview" outside the install's own folder name does not mark a channel. + const stableBin = path.join(root, "Users", "preview", "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"]); + + // 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 4a263d0b10e89..32e90a5aa37c4 100644 --- a/packages/shared/src/editor.ts +++ b/packages/shared/src/editor.ts @@ -5,10 +5,21 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as HostProcess from "./HostProcess.ts"; -import { isCommandAvailable } from "./shell.ts"; +import { isCommandAvailable, resolveCommandPaths } from "./shell.ts"; type Editor = (typeof EDITORS)[number]; +// On Windows, Zed's stable, Preview and Nightly channels each install a `zed` +// CLI in their own folder, so PATH order alone can open a prerelease build +// when the user picked "Zed". A prerelease install is one path component +// naming both Zed and the channel (`Zed Nightly`, `Zed Preview`); a folder +// called "preview" elsewhere in the path does not count. +const isPrereleaseZedPath = (filePath: string) => + filePath.split(/[\\/]/).some((component) => { + const name = component.toLowerCase(); + return name.includes("zed") && (name.includes("nightly") || name.includes("preview")); + }); + const installNames: Partial>> = { vscode: ["Visual Studio Code"], "vscode-insiders": ["Visual Studio Code - Insiders"], @@ -37,7 +48,22 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun // OpenZFS also installs a `zed` executable. Prefer the macOS editor bundle // so a PATH entry for the ZFS Event Daemon cannot take over editor opens. const preferBundle = platform === "darwin" && editor.id === "zed"; - if (!preferBundle) { + if (platform === "win32" && editor.id === "zed") { + const installs: Array<{ readonly command: string; readonly resolved: string }> = []; + for (const command of editor.commands) { + for (const resolved of yield* resolveCommandPaths(command, { env })) { + installs.push({ command, resolved }); + } + } + const [first] = installs; + const preferred = installs.find((install) => !isPrereleaseZedPath(install.resolved)) ?? first; + // The first match still launches by name; a later stable install launches + // by absolute path. + if (preferred) { + const command = preferred === first ? preferred.command : preferred.resolved; + return Option.some({ command, baseArgs }); + } + } else if (!preferBundle) { for (const command of editor.commands) { if (yield* isCommandAvailable(command, { env })) return Option.some({ command, baseArgs }); } diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index df3d32747c2f2..634c3462911f5 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -601,6 +601,46 @@ const isExecutableFile = Effect.fnUntraced(function* ( return canExecuteFile(filePath); }); +const findOnPath = Effect.fnUntraced(function* ( + pathValue: string, + commandCandidates: ReadonlyArray, + platform: NodeJS.Platform, + windowsPathExtensions: ReadonlyArray, + limit: number, +): 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 (matches.length === limit) return matches; + break; + } + } + } + return matches; +}); + const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")(function* ( command: string, options: CommandAvailabilityOptions & { readonly platform: NodeJS.Platform }, @@ -640,35 +680,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, + 1, + ); + cacheCommandResolution(cache, cacheKey, resolvedPath, nowNanos); + return resolvedPath; }); export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function* ( @@ -681,6 +701,27 @@ export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function }); }); +/** + * Every PATH match for a bare command, in PATH order, for callers choosing + * between same-named installs. Not cached, unlike `resolveCommandPath`. + */ +export const resolveCommandPaths = Effect.fn("shell.resolveCommandPaths")(function* ( + command: string, + options: CommandAvailabilityOptions = {}, +) { + 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, + Infinity, + ); +}); + // Git for Windows 2.56 moved x64 builds from mingw64 to ucrt64; ARM64 builds // live in clangarm64 and 32-bit ones in mingw32. const GIT_FOR_WINDOWS_BUILDS = ["ucrt64", "clangarm64", "mingw64", "mingw32"] as const; From 076fc4f3f7e8bd1b5f0361875ce269f4f7fafd8f Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 12:31:58 -0500 Subject: [PATCH 2/4] fix(shared): only the Zed install folder decides its Windows channel A user or parent folder such as `zedpreview` matched the prerelease check, so stable Zed under it counted as prerelease and PATH order won again. The channel now comes from the nearest folder naming Zed. Co-Authored-By: Claude Opus 5.5 --- .../src/process/externalLauncher.test.ts | 7 ++++--- packages/shared/src/editor.ts | 19 +++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index f8d24f39d5723..8c7ee47d56f03 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -702,9 +702,10 @@ it.effect.each(["Zed Nightly", "Zed Preview"])( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-channels-" }); - const prereleaseBin = path.join(root, "Programs", channel, "bin"); - // "preview" outside the install's own folder name does not mark a channel. - const stableBin = path.join(root, "Users", "preview", "Programs", "Zed", "bin"); + // 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"), ""); diff --git a/packages/shared/src/editor.ts b/packages/shared/src/editor.ts index 32e90a5aa37c4..f564e54d3a8cf 100644 --- a/packages/shared/src/editor.ts +++ b/packages/shared/src/editor.ts @@ -11,14 +11,17 @@ type Editor = (typeof EDITORS)[number]; // On Windows, Zed's stable, Preview and Nightly channels each install a `zed` // CLI in their own folder, so PATH order alone can open a prerelease build -// when the user picked "Zed". A prerelease install is one path component -// naming both Zed and the channel (`Zed Nightly`, `Zed Preview`); a folder -// called "preview" elsewhere in the path does not count. -const isPrereleaseZedPath = (filePath: string) => - filePath.split(/[\\/]/).some((component) => { - const name = component.toLowerCase(); - return name.includes("zed") && (name.includes("nightly") || name.includes("preview")); - }); +// when the user picked "Zed". The nearest folder naming Zed is the install +// (`Zed`, `Zed Nightly`, `Zed Preview`) and decides the channel, so a user or +// parent folder further up, such as `zedpreview`, cannot mark stable Zed. +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"], From 03a458db1ca74ca93fa91a0830d94b847fe01058 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 10:40:38 +0000 Subject: [PATCH 3/4] test(server): cover stable-first Windows Zed resolution --- apps/server/src/process/externalLauncher.test.ts | 4 ++++ packages/shared/src/editor.ts | 9 ++------- packages/shared/src/shell.ts | 6 ++---- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 045230632eef1..db3f4b322c284 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -637,6 +637,10 @@ it.effect.each(["Zed Nightly", "Zed Preview"])( 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"); diff --git a/packages/shared/src/editor.ts b/packages/shared/src/editor.ts index 79b18edc0685a..28c81785e1fd4 100644 --- a/packages/shared/src/editor.ts +++ b/packages/shared/src/editor.ts @@ -9,11 +9,7 @@ import { isCommandAvailable, resolveCommandPaths } from "./shell.ts"; type Editor = (typeof EDITORS)[number]; -// On Windows, Zed's stable, Preview and Nightly channels each install a `zed` -// CLI in their own folder, so PATH order alone can open a prerelease build -// when the user picked "Zed". The nearest folder naming Zed is the install -// (`Zed`, `Zed Nightly`, `Zed Preview`) and decides the channel, so a user or -// parent folder further up, such as `zedpreview`, cannot mark stable Zed. +// The nearest Zed folder identifies the channel, even below a user named zedpreview. const isPrereleaseZedPath = (filePath: string) => { const install = filePath .split(/[\\/]/) @@ -57,8 +53,7 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun } const [first] = installs; const preferred = installs.find((install) => !isPrereleaseZedPath(install.resolved)) ?? first; - // The first match still launches by name; a later stable install launches - // by absolute path. + // Bypass PATH only when stable is behind a prerelease install. if (preferred) { const command = preferred === first ? preferred.command : preferred.resolved; return Option.some({ command, baseArgs }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 1e6614775c54d..665648bb06722 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -593,6 +593,7 @@ const isExecutableFile = Effect.fnUntraced(function* ( return canExecuteFile(filePath); }); +// Called within the traced command resolvers. const findOnPath = Effect.fnUntraced(function* ( pathValue: string, commandCandidates: ReadonlyArray, @@ -693,10 +694,7 @@ export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function }); }); -/** - * Every PATH match for a bare command, in PATH order, for callers choosing - * between same-named installs. Not cached, unlike `resolveCommandPath`. - */ +// All matches for a bare command, in PATH order, without the single-match cache. export const resolveCommandPaths = Effect.fn("shell.resolveCommandPaths")(function* ( command: string, options: CommandAvailabilityOptions = {}, From 9b0be020e86ade6a03995791c608ad9822ed7b77 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Wed, 11 Nov 2026 17:31:09 +0000 Subject: [PATCH 4/4] fix(shared): stop the Windows Zed PATH scan at the first stable install Resolving generic Zed on Windows listed every PATH match for both command names before picking one, so each launch checked every later PATH entry. The scan now ends at the first install outside a preview or nightly folder. The selection is unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/editor.ts | 20 ++++++++++---------- packages/shared/src/shell.test.ts | 29 +++++++++++++++++++++++++++++ packages/shared/src/shell.ts | 12 ++++++------ 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/packages/shared/src/editor.ts b/packages/shared/src/editor.ts index 69fb797cfcaa1..ba6ee17e3cd66 100644 --- a/packages/shared/src/editor.ts +++ b/packages/shared/src/editor.ts @@ -70,19 +70,19 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun const preferBundle = platform === "darwin" && editor.id === "zed"; const preferZeditor = platform === "linux" && editor.id === "zed"; if (platform === "win32" && editor.id === "zed") { - const installs: Array<{ readonly command: string; readonly resolved: string }> = []; + const isStable = (resolved: string) => !isPrereleaseZedPath(resolved); + let firstCommand: string | undefined; for (const command of editor.commands) { - for (const resolved of yield* resolveCommandPaths(command, { env })) { - installs.push({ command, resolved }); + 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; } - const [first] = installs; - const preferred = installs.find((install) => !isPrereleaseZedPath(install.resolved)) ?? first; - // Bypass PATH only when stable is behind a prerelease install. - if (preferred) { - const command = preferred === first ? preferred.command : preferred.resolved; - return Option.some({ command, baseArgs }); - } + 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 c619dd608b58d..3d25bde8d7809 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -620,7 +620,7 @@ const findOnPath = Effect.fnUntraced(function* ( commandCandidates: ReadonlyArray, platform: NodeJS.Platform, windowsPathExtensions: ReadonlyArray, - limit: number, + 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. @@ -647,7 +647,7 @@ const findOnPath = Effect.fnUntraced(function* ( const candidatePath = path.join(pathEntry, candidate); if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { matches.push(candidatePath); - if (matches.length === limit) return matches; + if (isLast(candidatePath)) return matches; break; } } @@ -699,7 +699,7 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat commandCandidates, platform, windowsPathExtensions, - 1, + () => true, ); cacheCommandResolution(cache, cacheKey, resolvedPath, nowNanos); return resolvedPath; @@ -715,10 +715,10 @@ export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function }); }); -// All matches for a bare command, in PATH order, without the single-match cache. +// 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 = {}, + options: CommandAvailabilityOptions & { readonly until: (match: string) => boolean }, ) { const path = yield* Path.Path; const platform = yield* HostProcess.Platform; @@ -729,7 +729,7 @@ export const resolveCommandPaths = Effect.fn("shell.resolveCommandPaths")(functi resolveCommandCandidates(command, platform, windowsPathExtensions, path.extname), platform, windowsPathExtensions, - Infinity, + options.until, ); });