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
126 changes: 126 additions & 0 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,132 @@ it.effect("discovers editors through the service API", () =>
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("opens a terminal at the directory holding the launch target", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-terminals-" });
yield* fileSystem.writeFileString(path.join(binDir, "ghostty"), "#!/bin/sh\n");
yield* fileSystem.chmod(path.join(binDir, "ghostty"), 0o755);
const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-workspace-" });
const file = path.join(workspace, "index.ts");
yield* fileSystem.writeFileString(file, "");

let spawned: ChildProcess.StandardCommand | undefined;
yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
// A file with a position, as the "open this config file" flows send.
yield* launcher.launchEditor({ editor: "ghostty", cwd: `${file}:12:4` });
}).pipe(
Effect.provide(
testLayer({
platform: "linux",
env: { PATH: binDir },
onSpawn: (command) => {
spawned = command;
},
}),
),
);

assert.ok(spawned);
assert.equal(spawned.command, "ghostty");
assert.deepEqual(spawned.args, [`--working-directory=${workspace}`]);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

// A colon is legal in a POSIX directory name, so the position suffix must not
// be stripped off a path that is already a directory.
it.effect("keeps a directory whose name ends in a colon-number intact", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-terminals-" });
yield* fileSystem.writeFileString(path.join(binDir, "ghostty"), "#!/bin/sh\n");
yield* fileSystem.chmod(path.join(binDir, "ghostty"), 0o755);
const parent = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-workspace-" });
const workspace = path.join(parent, "project:12");
yield* fileSystem.makeDirectory(workspace);

let spawned: ChildProcess.StandardCommand | undefined;
yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
yield* launcher.launchEditor({ editor: "ghostty", cwd: workspace });
}).pipe(
Effect.provide(
testLayer({
platform: "linux",
env: { PATH: binDir },
onSpawn: (command) => {
spawned = command;
},
}),
),
);

assert.ok(spawned);
assert.deepEqual(spawned.args, [`--working-directory=${workspace}`]);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

// `open` is macOS-only, so a stray .app path on another platform must not
// hijack the launch away from a plain command-not-found.
it.effect("does not fall back to a macOS app bundle off darwin", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-home-" });
yield* fileSystem.makeDirectory(path.join(home, "Applications", "Ghostty.app"), {
recursive: true,
});

const error = yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
return yield* launcher.launchEditor({ editor: "ghostty", cwd: "/tmp" }).pipe(Effect.flip);
}).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "", HOME: home } })));

assert.instanceOf(error, ExternalLauncher.ExternalLauncherCommandNotFoundError);
assert.equal(error.command, "ghostty");
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("opens a CLI-less macOS terminal through its app bundle", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-terminals-" });
yield* fileSystem.writeFileString(path.join(binDir, "open"), "#!/bin/sh\n");
yield* fileSystem.chmod(path.join(binDir, "open"), 0o755);
const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-workspace-" });
// The bundle lives under a temp HOME so the test does not depend on the
// machine it runs on having Terminal.app.
const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-home-" });
yield* fileSystem.makeDirectory(path.join(home, "Applications", "Terminal.app"), {
recursive: true,
});

let spawned: ChildProcess.StandardCommand | undefined;
yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
yield* launcher.launchEditor({ editor: "apple-terminal", cwd: workspace });
}).pipe(
Effect.provide(
testLayer({
platform: "darwin",
env: { PATH: binDir, HOME: home },
onSpawn: (command) => {
spawned = command;
},
}),
),
);

assert.ok(spawned);
assert.equal(spawned.command, "open");
assert.deepEqual(spawned.args, ["-a", "Terminal", workspace]);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("rejects unknown editors through the service API", () =>
Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
Expand Down
131 changes: 117 additions & 14 deletions apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* @module ExternalLauncher
*/
import {
EDITOR_CWD_PLACEHOLDER,
EDITORS,
ExternalLauncherError,
ExternalLauncherBrowserSpawnError,
Expand Down Expand Up @@ -104,6 +105,7 @@ const CommandLookupEnvConfig = Config.all({
Path: Config.string("Path").pipe(Config.option),
path: Config.string("path").pipe(Config.option),
PATHEXT: Config.string("PATHEXT").pipe(Config.option),
HOME: Config.string("HOME").pipe(Config.option),
}).pipe(Config.map(compactEnv));

const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({})));
Expand Down Expand Up @@ -146,6 +148,11 @@ function resolveCommandEditorArgs(
path,
],
});
// `target` is already the resolved directory here — see resolveWorkingDirectory.
case "working-directory":
return "cwdArgs" in editor && editor.cwdArgs
? editor.cwdArgs.map((arg) => arg.replaceAll(EDITOR_CWD_PLACEHOLDER, target))
: [target];
}
}

Expand All @@ -169,6 +176,59 @@ const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableComm
return Option.none();
});

const isMacAppAvailable = Effect.fn("externalLauncher.isMacAppAvailable")(function* (
appName: string,
env: NodeJS.ProcessEnv,
): Effect.fn.Return<boolean, never, FileSystem.FileSystem> {
const fileSystem = yield* FileSystem.FileSystem;
const candidates = [
`/Applications/${appName}.app`,
// Apple's own bundles (Terminal) live under /System, not /Applications.
`/System/Applications/${appName}.app`,
`/System/Applications/Utilities/${appName}.app`,
...(env.HOME ? [`${env.HOME}/Applications/${appName}.app`] : []),
];
for (const candidate of candidates) {
if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
return true;
}
}
return false;
});

/**
* Directory a `working-directory` launch should start in.
*
* The launch target is whatever the caller wanted opened, which may carry a
* `:line:column` suffix and may be a file rather than a directory — a terminal
* takes neither, so strip the position and step up to the containing folder.
*
* The target is tested as-is first: a colon is legal in a POSIX directory
* name, so stripping a position before looking would send a real directory
* named `project:12` to its parent instead.
*/
const resolveWorkingDirectory = Effect.fn("externalLauncher.resolveWorkingDirectory")(function* (
target: string,
): Effect.fn.Return<string, never, FileSystem.FileSystem | Path.Path> {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const isDirectory = (candidate: string) =>
fileSystem.stat(candidate).pipe(
Effect.map((info) => info.type === "Directory"),
Effect.orElseSucceed(() => false),
);

if (yield* isDirectory(target)) {
return target;
}

const withoutPosition = Option.match(parseTargetPathAndPosition(target), {
onNone: () => target,
onSome: (parsed) => parsed.path,
});
return (yield* isDirectory(withoutPosition)) ? withoutPosition : path.dirname(withoutPosition);
});

function encodeUtf16LeBase64(input: string): string {
const bytes = new Uint8Array(input.length * 2);
for (let index = 0; index < input.length; index += 1) {
Expand Down Expand Up @@ -267,17 +327,26 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors"
const available: EditorId[] = [];

for (const editor of EDITORS) {
if (editor.commands === null) {
if ("kind" in editor && editor.kind === "file-manager") {
const command = fileManagerCommandForPlatform(platform);
if (yield* isCommandAvailable(command, { env })) {
available.push(editor.id);
}
continue;
}

const command = yield* resolveAvailableCommand(editor.commands, env);
if (Option.isSome(command)) {
available.push(editor.id);
if (editor.commands !== null) {
const command = yield* resolveAvailableCommand(editor.commands, env);
if (Option.isSome(command)) {
available.push(editor.id);
continue;
}
}

if ("macAppName" in editor && editor.macAppName && platform === "darwin") {
if (yield* isMacAppAvailable(editor.macAppName, env)) {
available.push(editor.id);
}
}
}

Expand Down Expand Up @@ -335,28 +404,62 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
return yield* new ExternalLauncherUnknownEditorError({ editor: input.editor });
}

const target =
editorDef.launchStyle === "working-directory"
? yield* resolveWorkingDirectory(input.cwd)
: input.cwd;
const macAppName = "macAppName" in editorDef ? editorDef.macAppName : undefined;

if (editorDef.commands) {
const command = Option.getOrElse(
yield* resolveAvailableCommand(editorDef.commands, env),
() => editorDef.commands[0],
);
const resolved = yield* resolveAvailableCommand(editorDef.commands, env);
if (
Option.isNone(resolved) &&
macAppName &&
platform === "darwin" &&
(yield* isMacAppAvailable(macAppName, env))
) {
return {
editor: editorDef.id,
target,
command: "open",
args: ["-a", macAppName, target],
};
}

return {
editor: editorDef.id,
target,
command: Option.getOrElse(resolved, () => editorDef.commands[0]),
args: resolveEditorArgs(editorDef, target),
};
}

// No CLI at all: a GUI-only bundle handed the path to open, which is how the
// macOS terminals (Terminal, iTerm, Warp) take a working directory. The
// bundle is checked first because `open` detaches with stdio ignored, so a
// missing app would otherwise look like success and simply do nothing.
if (macAppName && platform === "darwin") {
if (!(yield* isMacAppAvailable(macAppName, env))) {
return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor });
}

return {
editor: editorDef.id,
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
target: input.cwd,
command,
args: resolveEditorArgs(editorDef, input.cwd),
target,
command: "open",
args: ["-a", macAppName, target],
};
}

if (editorDef.id !== "file-manager") {
if (!("kind" in editorDef) || editorDef.kind !== "file-manager") {
return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor });
}

return {
editor: editorDef.id,
target: input.cwd,
target,
command: fileManagerCommandForPlatform(platform),
args: [input.cwd],
args: [target],
};
});

Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import ProjectScriptsControl, {
type ProjectScriptActionResult,
} from "../ProjectScriptsControl";
import { OpenInPicker } from "./OpenInPicker";
import { OpenTerminalPicker } from "./OpenTerminalPicker";
import { usePrimaryEnvironmentId } from "../../state/environments";
import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts";
import { ProjectFavicon } from "../ProjectFavicon";
Expand Down Expand Up @@ -152,6 +153,13 @@ export const ChatHeader = memo(function ChatHeader({
onDeleteScript={onDeleteProjectScript}
/>
)}
{showOpenInPicker && (
<OpenTerminalPicker
environmentId={activeThreadEnvironmentId}
availableEditors={availableEditors}
openInCwd={openInCwd}
/>
)}
{showOpenInPicker && (
<OpenInPicker
environmentId={activeThreadEnvironmentId}
Expand Down
Loading
Loading