Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 26 additions & 2 deletions packages/shared/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<Editor["id"], ReadonlyArray<string>>> = {
vscode: ["Visual Studio Code"],
"vscode-insiders": ["Visual Studio Code - Insiders"],
Expand Down Expand Up @@ -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 });
}
Expand Down
29 changes: 29 additions & 0 deletions packages/shared/src/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
readEnvironmentFromLoginShell,
readEnvironmentFromWindowsShell,
resolveCommandPath,
resolveCommandPaths,
resolveSpawnCommand,
resolveWindowsEnvironment,
runsGitWithoutConsole,
Expand Down Expand Up @@ -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<string> = [];
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", () => {
Expand Down
97 changes: 68 additions & 29 deletions packages/shared/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
platform: NodeJS.Platform,
windowsPathExtensions: ReadonlyArray<string>,
isLast: (match: string) => boolean,
): Effect.fn.Return<ReadonlyArray<string>, 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<string>();
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 },
Expand Down Expand Up @@ -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<string>();
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* (
Expand All @@ -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;

Expand Down
Loading