| author | |
| committer | |
| log | 3ee2e1011dcff6dc46f5a1e0610d6265fb485702 |
| tree | dce7784650d0ef6705776dd889693ac852c9e0ee |
| parent | e5542b7a880aa40ab5a4b5ad8012822c11643471 |
2 files changed, 82 insertions(+), 55 deletions(-)
apps/server/src/open.test.ts+61-33| ... | ... | @@ -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 | }); |
apps/server/src/open.ts+21-22| ... | ... | @@ -3,6 +3,10 @@ import { spawn } from "node:child_process"; |
| 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 { |
| 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( |
| 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( |
| 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* () { |
| 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 |