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
82 changes: 81 additions & 1 deletion apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
Expand Down Expand Up @@ -38,6 +41,11 @@ const testLayer = (input: {
readonly resolveExecutable?: (command: string) => string | undefined;
readonly onSpawn?: (command: ChildProcess.StandardCommand) => void;
readonly onUnref?: () => void;
readonly editorCommandAvailability?: (
command: string,
env: NodeJS.ProcessEnv,
) => Effect.Effect<boolean>;
readonly editorDiscoveryTimeout?: Duration.Input;
}) => {
const spawnerLayer = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
Expand All @@ -53,8 +61,20 @@ const testLayer = (input: {
),
);

const externalLauncherLayer = Layer.effect(
ExternalLauncher.ExternalLauncher,
ExternalLauncher.makeWithOptions({
...(input.editorCommandAvailability === undefined
? {}
: { editorCommandAvailability: input.editorCommandAvailability }),
...(input.editorDiscoveryTimeout === undefined
? {}
: { editorDiscoveryTimeout: input.editorDiscoveryTimeout }),
}),
);

return Layer.mergeAll(
ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))),
externalLauncherLayer.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))),
Layer.succeed(HostProcessPlatform, input.platform),
Layer.succeed(
SpawnExecutableResolution,
Expand Down Expand Up @@ -130,6 +150,31 @@ it.effect("launches an installed editor with platform-safe arguments", () =>
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("launches an editor through a responsive fallback alias", () => {
let spawned: ChildProcess.StandardCommand | undefined;

return Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
yield* launcher.launchEditor({ editor: "zed", cwd: "/workspace" });

assert.ok(spawned);
assert.equal(spawned.command, "zeditor");
assert.deepEqual(spawned.args, ["/workspace"]);
}).pipe(
Effect.provide(
testLayer({
platform: "linux",
env: { PATH: "/bin" },
editorCommandAvailability: (command) =>
command === "zeditor" ? Effect.succeed(true) : Effect.never,
onSpawn: (command) => {
spawned = command;
},
}),
),
);
});

it.effect("discovers editors through the service API", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand All @@ -155,6 +200,41 @@ it.effect("discovers editors through the service API", () =>
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("bounds slow editor probes concurrently and keeps partial results", () => {
const discoveryTimeout = Duration.seconds(3);

return Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
const discoveryFiber = yield* launcher.resolveAvailableEditors().pipe(Effect.forkScoped);

yield* Effect.yieldNow;
yield* TestClock.adjust(discoveryTimeout);

const editors = yield* Fiber.join(discoveryFiber);
assert.deepEqual(editors, ["cursor", "vscode", "zed"]);
}).pipe(
Effect.scoped,
Effect.provide(
Layer.merge(
TestClock.layer(),
testLayer({
platform: "win32",
env: { PATH: "C:\\bin", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
editorDiscoveryTimeout: discoveryTimeout,
editorCommandAvailability: (command) => {
if (command === "cursor") {
return Effect.sleep(Duration.seconds(1)).pipe(Effect.as(true));
}
return command === "code" || command === "zeditor"
? Effect.succeed(true)
: Effect.never;
},
}),
),
),
);
});

it.effect("rejects unknown editors through the service API", () =>
Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
Expand Down
108 changes: 76 additions & 32 deletions apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
import * as Config from "effect/Config";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Encoding from "effect/Encoding";
import * as FileSystem from "effect/FileSystem";
Expand Down Expand Up @@ -79,6 +80,18 @@ const DETACHED_IGNORE_STDIO_OPTIONS = {
stderr: "ignore",
} as const satisfies ChildProcess.CommandOptions;

const EDITOR_DISCOVERY_TIMEOUT = Duration.seconds(3);

type EditorCommandAvailability = (
command: string,
env: NodeJS.ProcessEnv,
) => Effect.Effect<boolean>;

interface ExternalLauncherOptions {
readonly editorCommandAvailability?: EditorCommandAvailability;
readonly editorDiscoveryTimeout?: Duration.Input;
}

const compactEnv = (input: Record<string, Option.Option<string>>): NodeJS.ProcessEnv =>
Object.fromEntries(
Object.entries(input).flatMap(([key, value]) =>
Expand Down Expand Up @@ -160,13 +173,20 @@ function resolveEditorArgs(
const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableCommand")(function* (
commands: ReadonlyArray<string>,
env: NodeJS.ProcessEnv,
): Effect.fn.Return<Option.Option<string>, never, FileSystem.FileSystem | Path.Path> {
for (const command of commands) {
if (yield* isCommandAvailable(command, { env })) {
return Option.some(command);
}
commandAvailability: EditorCommandAvailability,
): Effect.fn.Return<Option.Option<string>> {
if (commands.length === 0) {
return Option.none();
}
return Option.none();

return yield* Effect.raceAll(
commands.map((command) =>
commandAvailability(command, env).pipe(
Effect.filterOrFail((available) => available),
Effect.as(command),
),
),
).pipe(Effect.option);
});

function encodeUtf16LeBase64(input: string): string {
Expand Down Expand Up @@ -263,25 +283,33 @@ function buildBrowserLaunch(
const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* (
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv,
): Effect.fn.Return<ReadonlyArray<EditorId>, never, FileSystem.FileSystem | Path.Path> {
const available: EditorId[] = [];

for (const editor of EDITORS) {
if (editor.commands === null) {
const command = fileManagerCommandForPlatform(platform);
if (yield* isCommandAvailable(command, { env })) {
available.push(editor.id);
}
continue;
}

const command = yield* resolveAvailableCommand(editor.commands, env);
if (Option.isSome(command)) {
available.push(editor.id);
}
}
commandAvailability: EditorCommandAvailability,
timeout: Duration.Input,
): Effect.fn.Return<ReadonlyArray<EditorId>> {
const results = yield* Effect.forEach(
EDITORS,
(editor) => {
const probe =
editor.commands === null
? commandAvailability(fileManagerCommandForPlatform(platform), env)
: Effect.raceAll(
editor.commands.map((command) =>
commandAvailability(command, env).pipe(
Effect.filterOrFail((available) => available),
),
),
).pipe(Effect.orElseSucceed(() => false));
Comment thread
cursor[bot] marked this conversation as resolved.

return probe.pipe(
Effect.timeoutOption(timeout),
Effect.map(Option.getOrElse(() => false)),
Comment thread
cursor[bot] marked this conversation as resolved.
Effect.map((available) => (available ? Option.some(editor.id) : Option.none<EditorId>())),
);
},
{ concurrency: "unbounded" },
);

return available;
return results.flatMap(Option.toArray);
});

const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* (
Expand All @@ -292,10 +320,13 @@ const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(
return buildBrowserLaunch(target, platform, env);
});

const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () {
const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* (
commandAvailability: EditorCommandAvailability,
timeout: Duration.Input,
) {
const platform = yield* HostProcessPlatform;
const env = yield* readCommandLookupEnv;
return yield* buildAvailableEditors(platform, env);
return yield* buildAvailableEditors(platform, env, commandAvailability, timeout);
});

/**
Expand All @@ -322,6 +353,7 @@ export class ExternalLauncher extends Context.Service<

const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
input: LaunchEditorInput,
commandAvailability: EditorCommandAvailability,
): Effect.fn.Return<EditorLaunch, ExternalLauncherError, FileSystem.FileSystem | Path.Path> {
const platform = yield* HostProcessPlatform;
const env = yield* readCommandLookupEnv;
Expand All @@ -337,7 +369,7 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (

if (editorDef.commands) {
const command = Option.getOrElse(
yield* resolveAvailableCommand(editorDef.commands, env),
yield* resolveAvailableCommand(editorDef.commands, env, commandAvailability),
() => editorDef.commands[0],
);
return {
Expand Down Expand Up @@ -393,13 +425,14 @@ const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* (

const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* (
launch: EditorLaunch,
commandAvailability: EditorCommandAvailability,
): Effect.fn.Return<
void,
ExternalLauncherError,
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
> {
const env = yield* readCommandLookupEnv;
if (!(yield* isCommandAvailable(launch.command, { env }))) {
if (!(yield* commandAvailability(launch.command, env))) {
return yield* new ExternalLauncherCommandNotFoundError({
editor: launch.editor,
command: launch.command,
Expand Down Expand Up @@ -430,7 +463,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu
);
});

export const make = Effect.gen(function* () {
export const makeWithOptions = Effect.fn("ExternalLauncher.makeWithOptions")(function* (
options: ExternalLauncherOptions = {},
) {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand All @@ -443,21 +478,30 @@ export const make = Effect.gen(function* () {
Effect.provideService(Path.Path, path),
);

const editorCommandAvailability =
options.editorCommandAvailability ??
((command: string, env: NodeJS.ProcessEnv) =>
provideCommandResolutionServices(isCommandAvailable(command, { env })));
const editorDiscoveryTimeout = options.editorDiscoveryTimeout ?? EDITOR_DISCOVERY_TIMEOUT;

return ExternalLauncher.of({
resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()),
resolveAvailableEditors: () =>
resolveAvailableEditors(editorCommandAvailability, editorDiscoveryTimeout),
launchBrowser: (target) =>
launchBrowser(target).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
),
launchEditor: (input) =>
provideCommandResolutionServices(
Effect.flatMap(resolveEditorLaunch(input), (launch) =>
launchEditorProcess(launch).pipe(
Effect.flatMap(resolveEditorLaunch(input, editorCommandAvailability), (launch) =>
launchEditorProcess(launch, editorCommandAvailability).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
),
),
),
});
});

export const make = makeWithOptions();

export const layer = Layer.effect(ExternalLauncher, make);
Loading