Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const testLayer = Layer.mergeAll(
Layer.succeed(Open, {
openBrowser: (_target: string) => Effect.void,
openInEditor: () => Effect.void,
openWorkspace: () => Effect.void,
} satisfies OpenShape),
AnalyticsService.layerTest,
FetchHttpClient.layer,
Expand Down
151 changes: 151 additions & 0 deletions apps/server/src/open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
isCommandAvailable,
launchDetached,
resolveAvailableEditors,
resolveAvailableOpenTargets,
resolveEditorLaunch,
resolveWorkspaceLaunch,
} from "./open";
import { Effect } from "effect";
import { assertSuccess } from "@effect/vitest/utils";
Expand Down Expand Up @@ -117,6 +119,78 @@ describe("resolveEditorLaunch", () => {
);
});

describe("resolveWorkspaceLaunch", () => {
it.effect("returns editor-backed workspace launches for existing workspace targets", () =>
Effect.gen(function* () {
const cursorLaunch = yield* resolveWorkspaceLaunch(
{ cwd: "/tmp/workspace", target: "cursor" },
"darwin",
);
assert.deepEqual(cursorLaunch, {
command: "cursor",
args: ["/tmp/workspace"],
});

const fileManagerLaunch = yield* resolveWorkspaceLaunch(
{ cwd: "/tmp/workspace", target: "file-manager" },
"linux",
);
assert.deepEqual(fileManagerLaunch, {
command: "xdg-open",
args: ["/tmp/workspace"],
});
}),
);

it.effect("uses AppleScript for Ghostty on macOS", () =>
Effect.gen(function* () {
const launch = yield* resolveWorkspaceLaunch(
{ cwd: "/tmp/workspace", target: "ghostty" },
"darwin",
);

assert.deepEqual(launch, {
command: "osascript",
args: [
"-e",
[
'tell application "Ghostty"',
" activate",
" set cfg to new surface configuration",
' set initial working directory of cfg to "/tmp/workspace"',
" new window with configuration cfg",
"end tell",
].join("\n"),
],
});
}),
);

it.effect("uses Ghostty CLI for Linux", () =>
Effect.gen(function* () {
const launch = yield* resolveWorkspaceLaunch(
{ cwd: "/tmp/workspace", target: "ghostty" },
"linux",
);

assert.deepEqual(launch, {
command: "ghostty",
args: ["+new-window", "--working-directory", "/tmp/workspace"],
});
}),
);

it.effect("rejects Ghostty on unsupported platforms", () =>
Effect.gen(function* () {
const result = yield* resolveWorkspaceLaunch(
{ cwd: "C:\\workspace", target: "ghostty" },
"win32",
).pipe(Effect.result);
assert.equal(result._tag, "Failure");
}),
);
});

describe("launchDetached", () => {
it.effect("resolves when command can be spawned", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -220,3 +294,80 @@ describe("resolveAvailableEditors", () => {
}
});
});

describe("resolveAvailableOpenTargets", () => {
it("returns Ghostty on macOS when the app is installed", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-open-targets-darwin-"));
try {
fs.writeFileSync(path.join(dir, "cursor"), "#!/bin/sh\n", { mode: 0o755 });
fs.writeFileSync(path.join(dir, "open"), "#!/bin/sh\n", { mode: 0o755 });

const targets = resolveAvailableOpenTargets(
"darwin",
{
PATH: dir,
},
{
isMacApplicationAvailable: (appName) => appName === "Ghostty",
},
);
assert.deepEqual(targets, ["cursor", "ghostty", "file-manager"]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it("omits Ghostty on macOS when the app is not installed", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-open-targets-darwin-missing-"));
try {
fs.writeFileSync(path.join(dir, "cursor"), "#!/bin/sh\n", { mode: 0o755 });
fs.writeFileSync(path.join(dir, "open"), "#!/bin/sh\n", { mode: 0o755 });
fs.writeFileSync(path.join(dir, "ghostty"), "#!/bin/sh\n", { mode: 0o755 });

const targets = resolveAvailableOpenTargets(
"darwin",
{
PATH: dir,
},
{
isMacApplicationAvailable: () => false,
},
);
assert.deepEqual(targets, ["cursor", "file-manager"]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it("returns Ghostty on Linux only when the CLI is available", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-open-targets-"));
try {
fs.writeFileSync(path.join(dir, "cursor"), "#!/bin/sh\n", { mode: 0o755 });
fs.writeFileSync(path.join(dir, "ghostty"), "#!/bin/sh\n", { mode: 0o755 });
fs.writeFileSync(path.join(dir, "xdg-open"), "#!/bin/sh\n", { mode: 0o755 });

const targets = resolveAvailableOpenTargets("linux", {
PATH: dir,
});
assert.deepEqual(targets, ["cursor", "ghostty", "file-manager"]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it("omits Ghostty on unsupported platforms", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-open-targets-win-"));
try {
fs.writeFileSync(path.join(dir, "cursor.CMD"), "@echo off\r\n", "utf8");
fs.writeFileSync(path.join(dir, "explorer.EXE"), "MZ", "utf8");

const targets = resolveAvailableOpenTargets("win32", {
PATH: dir,
PATHEXT: ".COM;.EXE;.BAT;.CMD",
});
assert.deepEqual(targets, ["cursor", "file-manager"]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
144 changes: 142 additions & 2 deletions apps/server/src/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@
*
* @module Open
*/
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import { accessSync, constants, statSync } from "node:fs";
import { extname, join } from "node:path";

import { EDITORS, type EditorId } from "@t3tools/contracts";
import {
EDITORS,
type EditorId,
WORKSPACE_OPEN_TARGETS,
type WorkspaceOpenTargetId,
} from "@t3tools/contracts";
import { ServiceMap, Schema, Effect, Layer } from "effect";

// ==============================
Expand All @@ -27,6 +32,11 @@ export interface OpenInEditorInput {
readonly editor: EditorId;
}

export interface OpenWorkspaceInput {
readonly cwd: string;
readonly target: WorkspaceOpenTargetId;
}

interface EditorLaunch {
readonly command: string;
readonly args: ReadonlyArray<string>;
Expand All @@ -37,6 +47,10 @@ interface CommandAvailabilityOptions {
readonly env?: NodeJS.ProcessEnv;
}

interface OpenTargetAvailabilityOptions extends CommandAvailabilityOptions {
readonly isMacApplicationAvailable?: (appName: string) => boolean;
}

const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;

function shouldUseGotoFlag(editorId: EditorId, target: string): boolean {
Expand All @@ -56,6 +70,16 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {
}
}

function ghosttyCommandForPlatform(platform: NodeJS.Platform): string | null {
switch (platform) {
case "darwin":
case "linux":
return "ghostty";
default:
return null;
}
}

function stripWrappingQuotes(value: string): string {
return value.replace(/^"+|"+$/g, "");
}
Expand Down Expand Up @@ -177,6 +201,90 @@ export function resolveAvailableEditors(
return available;
}

function isWorkspaceEditorTarget(target: WorkspaceOpenTargetId): target is EditorId {
return target !== "ghostty";
}

function resolveWorkspaceTargetCommand(
target: WorkspaceOpenTargetId,
platform: NodeJS.Platform,
): string | null {
if (target === "ghostty") {
return ghosttyCommandForPlatform(platform);
}

const editorDef = EDITORS.find((editor) => editor.id === target);
if (!editorDef) return null;
return editorDef.command ?? fileManagerCommandForPlatform(platform);
}

function isMacApplicationAvailable(appName: string): boolean {
const result = spawnSync("open", ["-Ra", appName], {
stdio: "ignore",
});
return result.status === 0;
}

function isWorkspaceTargetAvailable(
target: WorkspaceOpenTargetId,
options: OpenTargetAvailabilityOptions,
): boolean {
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;

if (target === "ghostty") {
switch (platform) {
case "darwin":
return (options.isMacApplicationAvailable ?? isMacApplicationAvailable)("Ghostty");
case "linux":
return isCommandAvailable("ghostty", { platform, env });
default:
return false;
}
}

const command = resolveWorkspaceTargetCommand(target, platform);
if (!command) return false;
return isCommandAvailable(command, { platform, env });
}

export function resolveAvailableOpenTargets(
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
options: Omit<OpenTargetAvailabilityOptions, "platform" | "env"> = {},
): ReadonlyArray<WorkspaceOpenTargetId> {
const available: WorkspaceOpenTargetId[] = [];

for (const target of WORKSPACE_OPEN_TARGETS) {
if (
isWorkspaceTargetAvailable(target.id, {
platform,
env,
...options,
})
) {
available.push(target.id);
}
}

return available;
}

function escapeAppleScriptString(value: string): string {
return JSON.stringify(value);
}

function ghosttyAppleScript(cwd: string): string {
return [
'tell application "Ghostty"',
" activate",
" set cfg to new surface configuration",
` set initial working directory of cfg to ${escapeAppleScriptString(cwd)}`,
" new window with configuration cfg",
"end tell",
].join("\n");
}

/**
* OpenShape - Service API for browser and editor launch actions.
*/
Expand All @@ -192,6 +300,11 @@ export interface OpenShape {
* Launches the editor as a detached process so server startup is not blocked.
*/
readonly openInEditor: (input: OpenInEditorInput) => Effect.Effect<void, OpenError>;

/**
* Open a workspace in a selected workspace launcher target.
*/
readonly openWorkspace: (input: OpenWorkspaceInput) => Effect.Effect<void, OpenError>;
}

/**
Expand Down Expand Up @@ -225,6 +338,32 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* (
return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] };
});

export const resolveWorkspaceLaunch = Effect.fnUntraced(function* (
input: OpenWorkspaceInput,
platform: NodeJS.Platform = process.platform,
): Effect.fn.Return<EditorLaunch, OpenError> {
if (isWorkspaceEditorTarget(input.target)) {
return yield* resolveEditorLaunch({ cwd: input.cwd, editor: input.target }, platform);
}

switch (platform) {
case "darwin":
return {
command: "osascript",
args: ["-e", ghosttyAppleScript(input.cwd)],
};
case "linux":
return {
command: "ghostty",
args: ["+new-window", "--working-directory", input.cwd],
};
default:
return yield* new OpenError({
message: `Unsupported workspace target ${input.target} on platform ${platform}`,
});
}
});

export const launchDetached = (launch: EditorLaunch) =>
Effect.gen(function* () {
if (!isCommandAvailable(launch.command)) {
Expand Down Expand Up @@ -270,6 +409,7 @@ const make = Effect.gen(function* () {
catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }),
}),
openInEditor: (input) => Effect.flatMap(resolveEditorLaunch(input), launchDetached),
openWorkspace: (input) => Effect.flatMap(resolveWorkspaceLaunch(input), launchDetached),
} satisfies OpenShape;
});

Expand Down
Loading
Loading