authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-25 22:50:25-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log3ee2e1011dcff6dc46f5a1e0610d6265fb485702
treedce7784650d0ef6705776dd889693ac852c9e0ee
parente5542b7a880aa40ab5a4b5ad8012822c11643471

effect native open service


2 files changed, 82 insertions(+), 55 deletions(-)

apps/server/src/open.test.ts+61-33
......@@ -1,45 +1,73 @@
1import { describe, expect, it } from "vitest";
1import { assert, describe, it } from "@effect/vitest";
22
33import { launchDetached, resolveEditorLaunch } from "./open";
4import { Effect } from "effect";
5import { assertSuccess } from "@effect/vitest/utils";
46
57describe("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 });
1240
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, {
2646 command: "xdg-open",
2747 args: ["/tmp/workspace"],
28 },
29 );
30 });
48 });
49 }),
50 );
3151});
3252
3353describe("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 );
3963
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 );
4573});
apps/server/src/open.ts+21-22
......@@ -3,6 +3,10 @@ import { spawn } from "node:child_process";
33import { EDITORS, type EditorId } from "@t3tools/contracts";
44import { ServiceMap, Schema, Effect, Layer } from "effect";
55
6// ==============================
7// Definitions
8// ==============================
9
610export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", {
711 message: Schema.String,
812 cause: Schema.optional(Schema.Defect),
......@@ -25,13 +29,17 @@ export interface OpenShape {
2529
2630export class Open extends ServiceMap.Service<Open, OpenShape>()("server/Open") {}
2731
28export function resolveEditorLaunch(
32// ==============================
33// Implementations
34// ==============================
35
36export const resolveEditorLaunch = Effect.fnUntraced(function* (
2937 input: OpenInEditorInput,
3038 platform: NodeJS.Platform = process.platform,
31): EditorLaunch {
39): Effect.fn.Return<EditorLaunch, OpenError> {
3240 const editorDef = EDITORS.find((editor) => editor.id === input.editor);
3341 if (!editorDef) {
34 throw new OpenError({ message: `Unknown editor: ${input.editor}` });
42 return yield* new OpenError({ message: `Unknown editor: ${input.editor}` });
3543 }
3644
3745 if (editorDef.command) {
......@@ -39,7 +47,7 @@ export function resolveEditorLaunch(
3947 }
4048
4149 if (editorDef.id !== "file-manager") {
42 throw new OpenError({ message: `Unsupported editor: ${input.editor}` });
50 return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` });
4351 }
4452
4553 switch (platform) {
......@@ -50,24 +58,25 @@ export function resolveEditorLaunch(
5058 default:
5159 return { command: "xdg-open", args: [input.cwd] };
5260 }
53}
61});
5462
55export function launchDetached(command: string, args: ReadonlyArray<string>): Promise<void> {
56 return new Promise((resolve, reject) => {
57 const child = spawn(command, [...args], {
63export const launchDetached = (launch: EditorLaunch) =>
64 Effect.callback<void, OpenError>((resume) => {
65 const child = spawn(launch.command, [...launch.args], {
5866 detached: true,
5967 stdio: "ignore",
6068 });
6169
6270 const handleSpawn = () => {
6371 child.unref();
64 resolve();
72 resume(Effect.void);
6573 };
6674
6775 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 );
6979 });
70}
7180
7281const make = Effect.gen(function* () {
7382 const open = yield* Effect.promise(() => import("open"));
......@@ -78,17 +87,7 @@ const make = Effect.gen(function* () {
7887 try: () => open.default(target),
7988 catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }),
8089 }),
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),
9291 } satisfies OpenShape;
9392});
9493