diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 59b0239c96..d978cf89f0 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -5,10 +5,12 @@ import { FileSystem, Path, Effect } from "effect"; import { isCommandAvailable, + isAppInstalled, launchDetached, resolveAvailableEditors, resolveEditorLaunch, } from "./open"; +import {EDITORS} from "@t3tools/contracts"; it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { it.effect("returns commands for command-based editors", () => @@ -82,7 +84,8 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const ideaLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "idea" }, - "darwin", + "linux", + { PATH: "" }, ); assert.deepEqual(ideaLaunch, { command: "idea", @@ -172,7 +175,8 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const ideaLineOnly = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/AGENTS.md:48", editor: "idea" }, - "darwin", + "linux", + { PATH: "" }, ); assert.deepEqual(ideaLineOnly, { command: "idea", @@ -181,7 +185,8 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const ideaLineAndColumn = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "idea" }, - "darwin", + "linux", + { PATH: "" }, ); assert.deepEqual(ideaLineAndColumn, { command: "idea", @@ -221,6 +226,25 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { }), ); + it.effect("falls back to open -a on macOS when CLI is missing but .app is installed", () => + Effect.gen(function* () { + const idea = EDITORS.find((e) => e.id === "idea"); + assert.isDefined(idea); + + if (!isAppInstalled(idea, "darwin")) return; + + const launch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "idea" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(launch, { + command: "open", + args: ["-a", "IntelliJ IDEA", "--args", "/tmp/workspace"], + }); + }), + ); + it.effect("maps file-manager editor to OS open commands", () => Effect.gen(function* () { const launch1 = yield* resolveEditorLaunch( @@ -383,6 +407,16 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { }), ); + it("includes editors detected via macOS .app bundle", () => { + const idea = EDITORS.find((e) => e.id === "idea"); + assert.isDefined(idea); + + if (!isAppInstalled(idea, "darwin")) return; + + const editors = resolveAvailableEditors("darwin", { PATH: "" }); + assert.isTrue(editors.includes("idea")); + }); + it("omits file-manager when the platform opener is unavailable", () => { const editors = resolveAvailableEditors("linux", { PATH: "", diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index ef50d3a5b8..eacc276ee3 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -10,7 +10,7 @@ import { spawn } from "node:child_process"; import { accessSync, constants, statSync } from "node:fs"; import { extname, join } from "node:path"; -import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts"; +import { EDITORS, OpenError, type EditorId, EditorDefinition } from "@t3tools/contracts"; import { ServiceMap, Effect, Layer } from "effect"; // ============================== @@ -53,10 +53,7 @@ function parseTargetPathAndPosition(target: string): { }; } -function resolveCommandEditorArgs( - editor: (typeof EDITORS)[number], - target: string, -): ReadonlyArray { +function resolveCommandEditorArgs(editor: EditorDefinition, target: string): ReadonlyArray { const parsedTarget = parseTargetPathAndPosition(target); switch (editor.launchStyle) { @@ -203,6 +200,31 @@ export function isCommandAvailable( return false; } +function resolveAppPaths(appName: string, platform: NodeJS.Platform): ReadonlyArray { + switch (platform) { + case "darwin": + return [`/Applications/${appName}.app`]; + default: + return []; + } +} + +export function isAppInstalled( + editor: EditorDefinition, + platform: NodeJS.Platform, +): editor is EditorDefinition & { appName: string } { + if (!("appName" in editor)) return false; + for (const appPath of resolveAppPaths(editor.appName, platform)) { + try { + statSync(appPath); + return true; + } catch { + // not found at this path + } + } + return false; +} + export function resolveAvailableEditors( platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, @@ -221,6 +243,8 @@ export function resolveAvailableEditors( const command = resolveAvailableCommand(editor.commands, { platform, env }); if (command !== null) { available.push(editor.id); + } else if (isAppInstalled(editor, platform)) { + available.push(editor.id); } } @@ -269,12 +293,23 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( } if (editorDef.commands) { - const command = - resolveAvailableCommand(editorDef.commands, { platform, env }) ?? editorDef.commands[0]; - return { - command, - args: resolveCommandEditorArgs(editorDef, input.cwd), - }; + const command = resolveAvailableCommand(editorDef.commands, { platform, env }); + const args = resolveCommandEditorArgs(editorDef, input.cwd); + if (command) { + return { command, args }; + } + + if (isAppInstalled(editorDef, platform)) { + switch (platform) { + case "darwin": + return { + command: "open", + args: ["-a", editorDef.appName, "--args", ...args], + }; + } + } + + return { command: editorDef.commands[0], args }; } if (editorDef.id !== "file-manager") { diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 2ffdf4c6db..4ba2cb4bc3 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -4,11 +4,12 @@ import { TrimmedNonEmptyString } from "./baseSchemas"; export const EditorLaunchStyle = Schema.Literals(["direct-path", "goto", "line-column"]); export type EditorLaunchStyle = typeof EditorLaunchStyle.Type; -type EditorDefinition = { +export type EditorDefinition = { readonly id: string; readonly label: string; readonly commands: readonly [string, ...string[]] | null; readonly launchStyle: EditorLaunchStyle; + readonly appName?: string; }; export const EDITORS = [ @@ -24,7 +25,13 @@ export const EDITORS = [ { id: "vscodium", label: "VSCodium", commands: ["codium"], launchStyle: "goto" }, { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" }, { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" }, - { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" }, + { + id: "idea", + label: "IntelliJ IDEA", + commands: ["idea"], + launchStyle: "line-column", + appName: "IntelliJ IDEA", + }, { id: "file-manager", label: "File Manager", commands: null, launchStyle: "direct-path" }, ] as const satisfies ReadonlyArray;