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
65 changes: 65 additions & 0 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,71 @@ it.effect("launches an installed editor with platform-safe arguments", () =>
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("launches stable Zed when a nightly install comes first on PATH", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-zed-" });
const nightlyDir = path.join(root, "Zed Nightly", "bin");
const stableDir = path.join(root, "Zed", "bin");
yield* fileSystem.makeDirectory(nightlyDir, { recursive: true });
yield* fileSystem.makeDirectory(stableDir, { recursive: true });
yield* fileSystem.writeFileString(path.join(nightlyDir, "zed.EXE"), "");
yield* fileSystem.writeFileString(path.join(stableDir, "zed.EXE"), "");
const env = { PATH: `${nightlyDir};${stableDir}`, 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(
testLayer({
platform: "win32",
env,
onSpawn: (command) => {
spawned = command;
},
}),
),
);
assert.ok(spawned);
return spawned;
});

const preferred = yield* launchZed;
assert.equal(preferred.command, path.join(stableDir, "zed.EXE"));
assert.deepEqual(preferred.args, ["C:\\workspace"]);
assert.equal(preferred.options.shell, false);

// With only a nightly install left, PATH order is already right and the
// bare command is kept.
yield* fileSystem.remove(path.join(stableDir, "zed.EXE"));
const fallback = yield* launchZed;
assert.equal(fallback.command, "zed");
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it("recognizes prerelease Zed installs by their own folder name only", () => {
for (const prerelease of [
"C:\\Users\\me\\AppData\\Local\\Programs\\Zed Preview\\bin\\zed.exe",
"C:\\Users\\me\\AppData\\Local\\Programs\\Zed Nightly\\bin\\zed.exe",
"/Applications/Zed Nightly.app/Contents/MacOS/cli",
"/home/me/.local/zed-preview.app/bin/zed",
]) {
assert.equal(ExternalLauncher.isPrereleaseZedPath(prerelease), true, prerelease);
}
for (const stable of [
"C:\\Users\\preview-user\\AppData\\Local\\Programs\\Zed\\bin\\zed.exe",
"C:\\nightly-builds\\Zed\\bin\\zed.exe",
"/Applications/Zed.app/Contents/MacOS/cli",
"/home/me/.local/zed.app/bin/zed",
]) {
assert.equal(ExternalLauncher.isPrereleaseZedPath(stable), false, stable);
}
});

it.effect("reveals a file in Finder with open -R on macOS", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
44 changes: 42 additions & 2 deletions apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ import {
type LaunchEditorInput,
} from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
import {
isCommandAvailable,
resolveCommandPaths,
resolveSpawnCommand,
} from "@t3tools/shared/shell";
import * as Clock from "effect/Clock";
import * as Config from "effect/Config";
import * as Context from "effect/Context";
Expand Down Expand Up @@ -175,6 +179,40 @@ const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableComm
return Option.none();
});

// Zed's stable, preview, and nightly channels each install a `zed` CLI, so
// PATH order alone can open a prerelease build when the user picked "Zed".
// Prefer the first install whose location does not name a prerelease channel
// and, when that is not the first on PATH, launch it by absolute path.
// A prerelease install is recognized by a single path component that names
// both Zed and the channel (`Zed Preview`, `Zed Nightly.app`,
// `zed-nightly.app`), so a user or folder called "preview" elsewhere in the
// path does not count.
export function isPrereleaseZedPath(filePath: string): boolean {
return filePath.split(/[\\/]/).some((component) => {
const normalized = component.toLowerCase();
return (
normalized.includes("zed") &&
(normalized.includes("nightly") || normalized.includes("preview"))
);
});
}

const resolveZedCommand = Effect.fn("externalLauncher.resolveZedCommand")(function* (
commands: ReadonlyArray<string>,
env: NodeJS.ProcessEnv,
): Effect.fn.Return<Option.Option<string>, never, FileSystem.FileSystem | Path.Path> {
const installs: Array<{ readonly command: string; readonly path: string }> = [];
for (const command of commands) {
for (const resolvedPath of yield* resolveCommandPaths(command, { env })) {
installs.push({ command, path: resolvedPath });
}
}
const first = installs[0];
if (!first) return Option.none();
const preferred = installs.find((install) => !isPrereleaseZedPath(install.path)) ?? first;
return Option.some(preferred === first ? first.command : preferred.path);
});

function encodeUtf16LeBase64(input: string): string {
const bytes = new Uint8Array(input.length * 2);
for (let index = 0; index < input.length; index += 1) {
Expand Down Expand Up @@ -540,7 +578,9 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (

if (editorDef.commands) {
const command = Option.getOrElse(
yield* resolveAvailableCommand(editorDef.commands, env),
editorDef.id === "zed"
? yield* resolveZedCommand(editorDef.commands, env)
: yield* resolveAvailableCommand(editorDef.commands, env),
() => editorDef.commands[0],
);
return {
Expand Down
40 changes: 40 additions & 0 deletions packages/shared/src/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import { it as effectIt } from "@effect/vitest";
import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import { describe, expect, it, vi } from "vite-plus/test";

import {
Expand All @@ -17,6 +19,7 @@ import {
readPathFromLaunchctl,
readPathFromLoginShell,
resolveCommandPath,
resolveCommandPaths,
resolveKnownWindowsCliDirs,
resolveSpawnCommand,
resolveWindowsEnvironment,
Expand Down Expand Up @@ -365,6 +368,43 @@ effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => {
);
});

effectIt.layer(NodeServices.layer)("resolveCommandPaths", (it) => {
it.effect("lists every PATH match in order and yields nothing for an empty PATH", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-command-paths-" });
const firstDir = path.join(root, "first");
const secondDir = path.join(root, "second");
const emptyDir = path.join(root, "empty");
yield* fileSystem.makeDirectory(firstDir, { recursive: true });
yield* fileSystem.makeDirectory(secondDir, { recursive: true });
yield* fileSystem.makeDirectory(emptyDir, { recursive: true });
yield* fileSystem.writeFileString(path.join(firstDir, "zed.CMD"), "@echo off\r\n");
yield* fileSystem.writeFileString(path.join(secondDir, "zed.EXE"), "");
const env = {
PATH: [firstDir, emptyDir, secondDir].join(";"),
PATHEXT: ".COM;.EXE;.BAT;.CMD",
};

const resolved = yield* resolveCommandPaths("zed", { env }).pipe(
Effect.provideService(HostProcessPlatform, "win32"),
);
expect(resolved).toEqual([path.join(firstDir, "zed.CMD"), path.join(secondDir, "zed.EXE")]);

const explicit = yield* resolveCommandPaths(path.join(secondDir, "zed.EXE"), { env }).pipe(
Effect.provideService(HostProcessPlatform, "win32"),
);
expect(explicit).toEqual([path.join(secondDir, "zed.EXE")]);

const none = yield* resolveCommandPaths("zed", { env: { PATH: "" } }).pipe(
Effect.provideService(HostProcessPlatform, "win32"),
);
expect(none).toEqual([]);
}).pipe(Effect.scoped),
);
});

effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => {
it.effect("fails when PATH is empty", () =>
Effect.gen(function* () {
Expand Down
48 changes: 48 additions & 0 deletions packages/shared/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,54 @@ export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function
});
});

/**
* Every executable on PATH that `command` could resolve to, in PATH order.
* Callers that must choose between same-named installs (for example stable
* and nightly Zed both shipping a `zed` CLI) use this instead of the
* first-match `resolveCommandPath`. Explicit paths yield at most one entry.
* Never cached and never fails: an empty PATH yields an empty list.
*/
export const resolveCommandPaths = Effect.fn("shell.resolveCommandPaths")(function* (
command: string,
options: CommandAvailabilityOptions = {},
): Effect.fn.Return<ReadonlyArray<string>, never, FileSystem.FileSystem | Path.Path> {
const path = yield* Path.Path;
const platform = yield* HostProcessPlatform;
const env = options.env ?? (yield* HostProcessEnvironment);
const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : [];
const commandCandidates = resolveCommandCandidates(
command,
platform,
windowsPathExtensions,
path.extname,
);

if (command.includes("/") || command.includes("\\")) {
for (const candidate of commandCandidates) {
if (yield* isExecutableFile(candidate, platform, windowsPathExtensions)) {
return [candidate];
}
}
return [];
}

const resolvedPaths: string[] = [];
for (const entry of resolvePathEnvironmentVariable(env).split(
pathDelimiterForPlatform(platform),
)) {
const pathEntry = stripWrappingQuotes(entry.trim());
if (pathEntry.length === 0) continue;
for (const candidate of commandCandidates) {
const candidatePath = path.join(pathEntry, candidate);
if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) {
resolvedPaths.push(candidatePath);
break;
}
}
}
return resolvedPaths;
});

export const resolveSpawnCommand = Effect.fn("shell.resolveSpawnCommand")(function* (
command: string,
args: ReadonlyArray<string>,
Expand Down
Loading