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