238 lines · 6.4 KB · TypeScript
RawDownload
/** * Open - Browser/editor launch service interface. * * Owns process launch helpers for opening URLs in a browser and workspace * paths in a configured editor. * * @module Open */import { spawn } from "node:child_process"; import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts";import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell";import { Context, Effect, Layer } from "effect"; // ==============================// Definitions// ============================== export { OpenError };export { isCommandAvailable } from "@t3tools/shared/shell"; export interface OpenInEditorInput {  readonly cwd: string;  readonly editor: EditorId;} interface EditorLaunch {  readonly command: string;  readonly args: ReadonlyArray<string>;} const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; function parseTargetPathAndPosition(target: string): {  path: string;  line: string | undefined;  column: string | undefined;} | null {  const match = TARGET_WITH_POSITION_PATTERN.exec(target);  if (!match?.[1] || !match[2]) {    return null;  }   return {    path: match[1],    line: match[2],    column: match[3],  };} function resolveCommandEditorArgs(  editor: (typeof EDITORS)[number],  target: string,): ReadonlyArray<string> {  const parsedTarget = parseTargetPathAndPosition(target);   switch (editor.launchStyle) {    case "direct-path":      return [target];    case "goto":      return parsedTarget ? ["--goto", target] : [target];    case "line-column": {      if (!parsedTarget) {        return [target];      }       const { path, line, column } = parsedTarget;      return [...(line ? ["--line", line] : []), ...(column ? ["--column", column] : []), path];    }  }} function resolveEditorArgs(  editor: (typeof EDITORS)[number],  target: string,): ReadonlyArray<string> {  const baseArgs = "baseArgs" in editor ? editor.baseArgs : [];  return [...baseArgs, ...resolveCommandEditorArgs(editor, target)];} function resolveAvailableCommand(  commands: ReadonlyArray<string>,  options: CommandAvailabilityOptions = {},): string | null {  for (const command of commands) {    if (isCommandAvailable(command, options)) {      return command;    }  }  return null;} function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {  switch (platform) {    case "darwin":      return "open";    case "win32":      return "explorer";    default:      return "xdg-open";  }} export function resolveAvailableEditors(  platform: NodeJS.Platform = process.platform,  env: NodeJS.ProcessEnv = process.env,): ReadonlyArray<EditorId> {  const available: EditorId[] = [];   for (const editor of EDITORS) {    if (editor.commands === null) {      const command = fileManagerCommandForPlatform(platform);      if (isCommandAvailable(command, { platform, env })) {        available.push(editor.id);      }      continue;    }     const command = resolveAvailableCommand(editor.commands, { platform, env });    if (command !== null) {      available.push(editor.id);    }  }   return available;} /** * OpenShape - Service API for browser and editor launch actions. */export interface OpenShape {  /**   * Open a URL target in the default browser.   */  readonly openBrowser: (target: string) => Effect.Effect<void, OpenError>;   /**   * Open a workspace path in a selected editor integration.   *   * Launches the editor as a detached process so server startup is not blocked.   */  readonly openInEditor: (input: OpenInEditorInput) => Effect.Effect<void, OpenError>;} /** * Open - Service tag for browser/editor launch operations. */export class Open extends Context.Service<Open, OpenShape>()("t3/open") {} // ==============================// Implementations// ============================== export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (  input: OpenInEditorInput,  platform: NodeJS.Platform = process.platform,  env: NodeJS.ProcessEnv = process.env,): Effect.fn.Return<EditorLaunch, OpenError> {  yield* Effect.annotateCurrentSpan({    "open.editor": input.editor,    "open.cwd": input.cwd,    "open.platform": platform,  });  const editorDef = EDITORS.find((editor) => editor.id === input.editor);  if (!editorDef) {    return yield* new OpenError({ message: `Unknown editor: ${input.editor}` });  }   if (editorDef.commands) {    const command =      resolveAvailableCommand(editorDef.commands, { platform, env }) ?? editorDef.commands[0];    return {      command,      args: resolveEditorArgs(editorDef, input.cwd),    };  }   if (editorDef.id !== "file-manager") {    return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` });  }   return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] };}); export const launchDetached = (launch: EditorLaunch) =>  Effect.gen(function* () {    if (!isCommandAvailable(launch.command)) {      return yield* new OpenError({ message: `Editor command not found: ${launch.command}` });    }     yield* Effect.callback<void, OpenError>((resume) => {      let child;      try {        const isWin32 = process.platform === "win32";        child = spawn(          launch.command,          isWin32 ? launch.args.map((a) => `"${a}"`) : [...launch.args],          {            detached: true,            stdio: "ignore",            shell: isWin32,          },        );      } catch (error) {        return resume(          Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })),        );      }       const handleSpawn = () => {        child.unref();        resume(Effect.void);      };       child.once("spawn", handleSpawn);      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.tryPromise({    try: () => import("open"),    catch: (cause) => new OpenError({ message: "failed to load browser opener", cause }),  });   return {    openBrowser: (target) =>      Effect.tryPromise({        try: () => open.default(target),        catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }),      }),    openInEditor: (input) => Effect.flatMap(resolveEditorLaunch(input), launchDetached),  } satisfies OpenShape;}); export const OpenLive = Layer.effect(Open, make);