diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index c3d521eb90a6ee5c970fd4cf2826ec2afce61942..617ed553599e69a13003a6ca1e1e0144e50197dc 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -1,45 +1,73 @@ -import { describe, expect, it } from "vitest"; +import { assert, describe, it } from "@effect/vitest"; import { launchDetached, resolveEditorLaunch } from "./open"; +import { Effect } from "effect"; +import { assertSuccess } from "@effect/vitest/utils"; describe("resolveEditorLaunch", () => { - it("returns cursor command for cursor editor", () => { - expect(resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "cursor" }, "darwin")).toEqual({ - command: "cursor", - args: ["/tmp/workspace"], - }); - }); + it.effect("returns cursor command for cursor editor", () => + Effect.gen(function* () { + const launch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "cursor" }, + "darwin", + ); + assert.deepEqual(launch, { + command: "cursor", + args: ["/tmp/workspace"], + }); + }), + ); - it("maps file-manager editor to OS open commands", () => { - expect( - resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "file-manager" }, "darwin"), - ).toEqual({ - command: "open", - args: ["/tmp/workspace"], - }); - expect(resolveEditorLaunch({ cwd: "C:\\workspace", editor: "file-manager" }, "win32")).toEqual({ - command: "explorer", - args: ["C:\\workspace"], - }); - expect(resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "file-manager" }, "linux")).toEqual( - { + it.effect("maps file-manager editor to OS open commands", () => + Effect.gen(function* () { + const launch1 = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "file-manager" }, + "darwin", + ); + assert.deepEqual(launch1, { + command: "open", + args: ["/tmp/workspace"], + }); + + const launch2 = yield* resolveEditorLaunch( + { cwd: "C:\\workspace", editor: "file-manager" }, + "win32", + ); + assert.deepEqual(launch2, { + command: "explorer", + args: ["C:\\workspace"], + }); + + const launch3 = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "file-manager" }, + "linux", + ); + assert.deepEqual(launch3, { command: "xdg-open", args: ["/tmp/workspace"], - }, - ); - }); + }); + }), + ); }); describe("launchDetached", () => { - it("resolves when command can be spawned", async () => { - await expect(launchDetached(process.execPath, ["-e", "process.exit(0)"])).resolves.toBe( - undefined, - ); - }); + it.effect("resolves when command can be spawned", () => + Effect.gen(function* () { + const result = yield* launchDetached({ + command: process.execPath, + args: ["-e", "process.exit(0)"], + }).pipe(Effect.result); + assertSuccess(result, undefined); + }), + ); - it("rejects when command does not exist", async () => { - await expect(launchDetached(`t3code-no-such-command-${Date.now()}`, [])).rejects.toBeInstanceOf( - Error, - ); - }); + it.effect("rejects when command does not exist", () => + Effect.gen(function* () { + const result = yield* launchDetached({ + command: `t3code-no-such-command-${Date.now()}`, + args: [], + }).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }), + ); }); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index 1d8c875af0aa15f88497e9e4bff67bebeabfbba0..d88f519f8fcf6f289b30ec921506a5ae7dc81576 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -3,6 +3,10 @@ import { spawn } from "node:child_process"; import { EDITORS, type EditorId } from "@t3tools/contracts"; import { ServiceMap, Schema, Effect, Layer } from "effect"; +// ============================== +// Definitions +// ============================== + export class OpenError extends Schema.TaggedErrorClass()("OpenError", { message: Schema.String, cause: Schema.optional(Schema.Defect), @@ -25,13 +29,17 @@ export interface OpenShape { export class Open extends ServiceMap.Service()("server/Open") {} -export function resolveEditorLaunch( +// ============================== +// Implementations +// ============================== + +export const resolveEditorLaunch = Effect.fnUntraced(function* ( input: OpenInEditorInput, platform: NodeJS.Platform = process.platform, -): EditorLaunch { +): Effect.fn.Return { const editorDef = EDITORS.find((editor) => editor.id === input.editor); if (!editorDef) { - throw new OpenError({ message: `Unknown editor: ${input.editor}` }); + return yield* new OpenError({ message: `Unknown editor: ${input.editor}` }); } if (editorDef.command) { @@ -39,7 +47,7 @@ export function resolveEditorLaunch( } if (editorDef.id !== "file-manager") { - throw new OpenError({ message: `Unsupported editor: ${input.editor}` }); + return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` }); } switch (platform) { @@ -50,24 +58,25 @@ export function resolveEditorLaunch( default: return { command: "xdg-open", args: [input.cwd] }; } -} +}); -export function launchDetached(command: string, args: ReadonlyArray): Promise { - return new Promise((resolve, reject) => { - const child = spawn(command, [...args], { +export const launchDetached = (launch: EditorLaunch) => + Effect.callback((resume) => { + const child = spawn(launch.command, [...launch.args], { detached: true, stdio: "ignore", }); const handleSpawn = () => { child.unref(); - resolve(); + resume(Effect.void); }; child.once("spawn", handleSpawn); - child.once("error", (error) => reject(error)); + child.once("error", (cause) => + resume(Effect.fail(new OpenError({ message: "failed to spawn detached process", cause }))), + ); }); -} const make = Effect.gen(function* () { const open = yield* Effect.promise(() => import("open")); @@ -78,17 +87,7 @@ const make = Effect.gen(function* () { try: () => open.default(target), catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }), }), - openInEditor: (input) => - Effect.tryPromise({ - try: () => { - const launch = resolveEditorLaunch(input); - return launchDetached(launch.command, launch.args); - }, - catch: (cause) => - cause instanceof OpenError - ? cause - : new OpenError({ message: "Failed to open editor", cause }), - }), + openInEditor: (input) => Effect.flatMap(resolveEditorLaunch(input), launchDetached), } satisfies OpenShape; });