Unified · Split
| @@ -1,45 +1,73 @@ | ||
| 1 | −import { describe, expect, it } from "vitest"; | |
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | 2 | |
| 3 | 3 | import { launchDetached, resolveEditorLaunch } from "./open"; |
| 4 | +import { Effect } from "effect"; | |
| 5 | +import { assertSuccess } from "@effect/vitest/utils"; | |
| 4 | 6 | |
| 5 | 7 | describe("resolveEditorLaunch", () => { |
| 6 | − it("returns cursor command for cursor editor", () => { | |
| 7 | − expect(resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "cursor" }, "darwin")).toEqual({ | |
| 8 | − command: "cursor", | |
| 9 | − args: ["/tmp/workspace"], | |
| 10 | − }); | |
| 11 | − }); | |
| 8 | + it.effect("returns cursor command for cursor editor", () => | |
| 9 | + Effect.gen(function* () { | |
| 10 | + const launch = yield* resolveEditorLaunch( | |
| 11 | + { cwd: "/tmp/workspace", editor: "cursor" }, | |
| 12 | + "darwin", | |
| 13 | + ); | |
| 14 | + assert.deepEqual(launch, { | |
| 15 | + command: "cursor", | |
| 16 | + args: ["/tmp/workspace"], | |
| 17 | + }); | |
| 18 | + }), | |
| 19 | + ); | |
| 20 | + | |
| 21 | + it.effect("maps file-manager editor to OS open commands", () => | |
| 22 | + Effect.gen(function* () { | |
| 23 | + const launch1 = yield* resolveEditorLaunch( | |
| 24 | + { cwd: "/tmp/workspace", editor: "file-manager" }, | |
| 25 | + "darwin", | |
| 26 | + ); | |
| 27 | + assert.deepEqual(launch1, { | |
| 28 | + command: "open", | |
| 29 | + args: ["/tmp/workspace"], | |
| 30 | + }); | |
| 31 | + | |
| 32 | + const launch2 = yield* resolveEditorLaunch( | |
| 33 | + { cwd: "C:\\workspace", editor: "file-manager" }, | |
| 34 | + "win32", | |
| 35 | + ); | |
| 36 | + assert.deepEqual(launch2, { | |
| 37 | + command: "explorer", | |
| 38 | + args: ["C:\\workspace"], | |
| 39 | + }); | |
| 12 | 40 | |
| 13 | − it("maps file-manager editor to OS open commands", () => { | |
| 14 | − expect( | |
| 15 | − resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "file-manager" }, "darwin"), | |
| 16 | − ).toEqual({ | |
| 17 | − command: "open", | |
| 18 | − args: ["/tmp/workspace"], | |
| 19 | − }); | |
| 20 | − expect(resolveEditorLaunch({ cwd: "C:\\workspace", editor: "file-manager" }, "win32")).toEqual({ | |
| 21 | − command: "explorer", | |
| 22 | − args: ["C:\\workspace"], | |
| 23 | − }); | |
| 24 | − expect(resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "file-manager" }, "linux")).toEqual( | |
| 25 | − { | |
| 41 | + const launch3 = yield* resolveEditorLaunch( | |
| 42 | + { cwd: "/tmp/workspace", editor: "file-manager" }, | |
| 43 | + "linux", | |
| 44 | + ); | |
| 45 | + assert.deepEqual(launch3, { | |
| 26 | 46 | command: "xdg-open", |
| 27 | 47 | args: ["/tmp/workspace"], |
| 28 | − }, | |
| 29 | − ); | |
| 30 | − }); | |
| 48 | + }); | |
| 49 | + }), | |
| 50 | + ); | |
| 31 | 51 | }); |
| 32 | 52 | |
| 33 | 53 | describe("launchDetached", () => { |
| 34 | − it("resolves when command can be spawned", async () => { | |
| 35 | − await expect(launchDetached(process.execPath, ["-e", "process.exit(0)"])).resolves.toBe( | |
| 36 | − undefined, | |
| 37 | − ); | |
| 38 | − }); | |
| 54 | + it.effect("resolves when command can be spawned", () => | |
| 55 | + Effect.gen(function* () { | |
| 56 | + const result = yield* launchDetached({ | |
| 57 | + command: process.execPath, | |
| 58 | + args: ["-e", "process.exit(0)"], | |
| 59 | + }).pipe(Effect.result); | |
| 60 | + assertSuccess(result, undefined); | |
| 61 | + }), | |
| 62 | + ); | |
| 39 | 63 | |
| 40 | − it("rejects when command does not exist", async () => { | |
| 41 | − await expect(launchDetached(`t3code-no-such-command-${Date.now()}`, [])).rejects.toBeInstanceOf( | |
| 42 | − Error, | |
| 43 | − ); | |
| 44 | − }); | |
| 64 | + it.effect("rejects when command does not exist", () => | |
| 65 | + Effect.gen(function* () { | |
| 66 | + const result = yield* launchDetached({ | |
| 67 | + command: `t3code-no-such-command-${Date.now()}`, | |
| 68 | + args: [], | |
| 69 | + }).pipe(Effect.result); | |
| 70 | + assert.equal(result._tag, "Failure"); | |
| 71 | + }), | |
| 72 | + ); | |
| 45 | 73 | }); |
| @@ -3,6 +3,10 @@ import { spawn } from "node:child_process"; · 2 unmodified lines | ||
| 3 | 3 | import { EDITORS, type EditorId } from "@t3tools/contracts"; |
| 4 | 4 | import { ServiceMap, Schema, Effect, Layer } from "effect"; |
| 5 | 5 | |
| 6 | +// ============================== | |
| 7 | +// Definitions | |
| 8 | +// ============================== | |
| 9 | + | |
| 6 | 10 | export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", { |
| 7 | 11 | message: Schema.String, |
| 8 | 12 | cause: Schema.optional(Schema.Defect), |
| @@ -25,13 +29,17 @@ export interface OpenShape { · 16 unmodified lines | ||
| 25 | 29 | |
| 26 | 30 | export class Open extends ServiceMap.Service<Open, OpenShape>()("server/Open") {} |
| 27 | 31 | |
| 28 | −export function resolveEditorLaunch( | |
| 32 | +// ============================== | |
| 33 | +// Implementations | |
| 34 | +// ============================== | |
| 35 | + | |
| 36 | +export const resolveEditorLaunch = Effect.fnUntraced(function* ( | |
| 29 | 37 | input: OpenInEditorInput, |
| 30 | 38 | platform: NodeJS.Platform = process.platform, |
| 31 | −): EditorLaunch { | |
| 39 | +): Effect.fn.Return<EditorLaunch, OpenError> { | |
| 32 | 40 | const editorDef = EDITORS.find((editor) => editor.id === input.editor); |
| 33 | 41 | if (!editorDef) { |
| 34 | − throw new OpenError({ message: `Unknown editor: ${input.editor}` }); | |
| 42 | + return yield* new OpenError({ message: `Unknown editor: ${input.editor}` }); | |
| 35 | 43 | } |
| 36 | 44 | |
| 37 | 45 | if (editorDef.command) { |
| @@ -39,7 +47,7 @@ export function resolveEditorLaunch( · 1 unmodified line | ||
| 39 | 47 | } |
| 40 | 48 | |
| 41 | 49 | if (editorDef.id !== "file-manager") { |
| 42 | − throw new OpenError({ message: `Unsupported editor: ${input.editor}` }); | |
| 50 | + return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` }); | |
| 43 | 51 | } |
| 44 | 52 | |
| 45 | 53 | switch (platform) { |
| @@ -50,24 +58,25 @@ export function resolveEditorLaunch( · 4 unmodified lines | ||
| 50 | 58 | default: |
| 51 | 59 | return { command: "xdg-open", args: [input.cwd] }; |
| 52 | 60 | } |
| 53 | −} | |
| 61 | +}); | |
| 54 | 62 | |
| 55 | −export function launchDetached(command: string, args: ReadonlyArray<string>): Promise<void> { | |
| 56 | − return new Promise((resolve, reject) => { | |
| 57 | − const child = spawn(command, [...args], { | |
| 63 | +export const launchDetached = (launch: EditorLaunch) => | |
| 64 | + Effect.callback<void, OpenError>((resume) => { | |
| 65 | + const child = spawn(launch.command, [...launch.args], { | |
| 58 | 66 | detached: true, |
| 59 | 67 | stdio: "ignore", |
| 60 | 68 | }); |
| 61 | 69 | |
| 62 | 70 | const handleSpawn = () => { |
| 63 | 71 | child.unref(); |
| 64 | − resolve(); | |
| 72 | + resume(Effect.void); | |
| 65 | 73 | }; |
| 66 | 74 | |
| 67 | 75 | child.once("spawn", handleSpawn); |
| 68 | − child.once("error", (error) => reject(error)); | |
| 76 | + child.once("error", (cause) => | |
| 77 | + resume(Effect.fail(new OpenError({ message: "failed to spawn detached process", cause }))), | |
| 78 | + ); | |
| 69 | 79 | }); |
| 70 | −} | |
| 71 | 80 | |
| 72 | 81 | const make = Effect.gen(function* () { |
| 73 | 82 | const open = yield* Effect.promise(() => import("open")); |
| @@ -78,17 +87,7 @@ const make = Effect.gen(function* () { · 4 unmodified lines | ||
| 78 | 87 | try: () => open.default(target), |
| 79 | 88 | catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }), |
| 80 | 89 | }), |
| 81 | − openInEditor: (input) => | |
| 82 | − Effect.tryPromise({ | |
| 83 | − try: () => { | |
| 84 | − const launch = resolveEditorLaunch(input); | |
| 85 | − return launchDetached(launch.command, launch.args); | |
| 86 | − }, | |
| 87 | − catch: (cause) => | |
| 88 | − cause instanceof OpenError | |
| 89 | − ? cause | |
| 90 | − : new OpenError({ message: "Failed to open editor", cause }), | |
| 91 | − }), | |
| 90 | + openInEditor: (input) => Effect.flatMap(resolveEditorLaunch(input), launchDetached), | |
| 92 | 91 | } satisfies OpenShape; |
| 93 | 92 | }); |
| 94 | 93 | |