Open files in more installed editors (#2119)
* feat(desktop): open files in more installed editors Detect bundled app commands as well as PATH launchers and preserve file positions through editor-specific launches. * fix(desktop): preserve editor target compatibility Keep existing file-manager preference ids, retain project context for file launches, and recognize Windows 64-bit IDE commands.
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { listAvailableEditorTargets, openEditorTarget } from "./registry.js";
|
||||
import type { EditorTargetIcon, EditorTargetRuntime } from "./target.js";
|
||||
import { cursorTarget } from "./targets/cursor.js";
|
||||
import { explorerTarget, fileManagerTarget, finderTarget } from "./targets/file-manager.js";
|
||||
import { intellijIdeaTarget } from "./targets/intellij-idea.js";
|
||||
import { pycharmTarget } from "./targets/pycharm.js";
|
||||
import { vscodeTarget } from "./targets/vscode.js";
|
||||
import { webstormTarget } from "./targets/webstorm.js";
|
||||
import { zedTarget } from "./targets/zed.js";
|
||||
|
||||
interface RecordedLaunch {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
class FakeEditorTargets implements EditorTargetRuntime {
|
||||
readonly launches: RecordedLaunch[] = [];
|
||||
readonly openedPaths: string[] = [];
|
||||
readonly revealedPaths: string[] = [];
|
||||
readonly openedMacApplications: Array<{
|
||||
applicationName: string;
|
||||
paths: string[];
|
||||
}> = [];
|
||||
|
||||
readonly env: NodeJS.ProcessEnv;
|
||||
readonly platform: NodeJS.Platform;
|
||||
private readonly paths = new Set<string>();
|
||||
private readonly commands = new Map<string, string>();
|
||||
private readonly macApplications = new Set<string>();
|
||||
|
||||
constructor(platform: NodeJS.Platform = "linux", env: NodeJS.ProcessEnv = {}) {
|
||||
this.platform = platform;
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
addPath(targetPath: string): void {
|
||||
this.paths.add(targetPath);
|
||||
}
|
||||
|
||||
installCommand(command: string, executable = `/bin/${command}`): void {
|
||||
this.commands.set(command, executable);
|
||||
}
|
||||
|
||||
installMacApplication(applicationName: string): void {
|
||||
this.macApplications.add(applicationName);
|
||||
}
|
||||
|
||||
pathExists(targetPath: string): boolean {
|
||||
return this.paths.has(targetPath);
|
||||
}
|
||||
|
||||
isAbsolutePath(targetPath: string): boolean {
|
||||
return targetPath.startsWith("/") || /^[A-Z]:\//u.test(targetPath);
|
||||
}
|
||||
|
||||
resolveCommand(commands: readonly string[]): string | null {
|
||||
for (const command of commands) {
|
||||
const executable = this.commands.get(command);
|
||||
if (executable) return executable;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async spawnDetached(input: { command: string; args: readonly string[] }): Promise<void> {
|
||||
this.launches.push({ command: input.command, args: [...input.args] });
|
||||
}
|
||||
|
||||
async openPath(targetPath: string): Promise<void> {
|
||||
this.openedPaths.push(targetPath);
|
||||
}
|
||||
|
||||
revealPath(targetPath: string): void {
|
||||
this.revealedPaths.push(targetPath);
|
||||
}
|
||||
|
||||
async loadIcon(fileName: string): Promise<EditorTargetIcon> {
|
||||
return { kind: "image", dataUrl: `data:image/png;base64,${fileName}` };
|
||||
}
|
||||
|
||||
hasMacApplication(applicationName: string): boolean {
|
||||
return this.macApplications.has(applicationName);
|
||||
}
|
||||
|
||||
async openMacApplication(input: {
|
||||
applicationName: string;
|
||||
paths: readonly string[];
|
||||
}): Promise<void> {
|
||||
this.openedMacApplications.push({
|
||||
applicationName: input.applicationName,
|
||||
paths: [...input.paths],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("editor target registry", () => {
|
||||
it("lists installed target implementations in registration order", async () => {
|
||||
const runtime = new FakeEditorTargets();
|
||||
runtime.installCommand("code");
|
||||
runtime.installCommand("webstorm");
|
||||
|
||||
const targets = await listAvailableEditorTargets(runtime, [
|
||||
cursorTarget,
|
||||
vscodeTarget,
|
||||
webstormTarget,
|
||||
fileManagerTarget,
|
||||
]);
|
||||
|
||||
expect(targets).toEqual([
|
||||
{
|
||||
id: "vscode",
|
||||
label: "VS Code",
|
||||
kind: "editor",
|
||||
icon: { kind: "image", dataUrl: "data:image/png;base64,vscode.png" },
|
||||
},
|
||||
{
|
||||
id: "webstorm",
|
||||
label: "WebStorm",
|
||||
kind: "editor",
|
||||
icon: { kind: "image", dataUrl: "data:image/png;base64,webstorm.png" },
|
||||
},
|
||||
{
|
||||
id: "file-manager",
|
||||
label: "Files",
|
||||
kind: "file-manager",
|
||||
icon: { kind: "symbol", name: "folder" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens a selected file at its position through the target implementation", async () => {
|
||||
const runtime = new FakeEditorTargets();
|
||||
runtime.installCommand("code");
|
||||
runtime.addPath("/repo");
|
||||
runtime.addPath("/repo/src/app.ts");
|
||||
|
||||
await openEditorTarget(
|
||||
{
|
||||
editorId: "vscode",
|
||||
workspacePath: "/repo",
|
||||
filePath: "/repo/src/app.ts",
|
||||
line: 12,
|
||||
column: 4,
|
||||
},
|
||||
runtime,
|
||||
[vscodeTarget],
|
||||
);
|
||||
|
||||
expect(runtime.launches).toEqual([
|
||||
{
|
||||
command: "/bin/code",
|
||||
args: ["/repo", "--goto", "/repo/src/app.ts:12:4"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets each target choose its own command and arguments", async () => {
|
||||
const runtime = new FakeEditorTargets();
|
||||
runtime.installCommand("zeditor");
|
||||
runtime.installCommand("webstorm");
|
||||
runtime.installCommand("idea");
|
||||
|
||||
await zedTarget.launch(
|
||||
{ workspacePath: "/repo", filePath: "/repo/src/app.ts", line: 7, column: 2 },
|
||||
runtime,
|
||||
);
|
||||
await webstormTarget.launch(
|
||||
{ workspacePath: "/repo", filePath: "/repo/src/app.ts", line: 7, column: 2 },
|
||||
runtime,
|
||||
);
|
||||
await intellijIdeaTarget.launch({ workspacePath: "/repo" }, runtime);
|
||||
|
||||
expect(runtime.launches).toEqual([
|
||||
{ command: "/bin/zeditor", args: ["/repo", "/repo/src/app.ts:7:2"] },
|
||||
{
|
||||
command: "/bin/webstorm",
|
||||
args: ["--line", "7", "--column", "2", "/repo", "/repo/src/app.ts"],
|
||||
},
|
||||
{ command: "/bin/idea", args: ["/repo"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognizes Windows 64-bit project IDE launchers", async () => {
|
||||
const runtime = new FakeEditorTargets("win32");
|
||||
runtime.installCommand("pycharm64", "C:/Tools/PyCharm/bin/pycharm64.exe");
|
||||
|
||||
expect(await pycharmTarget.isInstalled(runtime)).toBe(true);
|
||||
await pycharmTarget.launch(
|
||||
{ workspacePath: "C:/repo", filePath: "C:/repo/src/app.py", line: 6 },
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(runtime.launches).toEqual([
|
||||
{
|
||||
command: "C:/Tools/PyCharm/bin/pycharm64.exe",
|
||||
args: ["--line", "6", "C:/repo", "C:/repo/src/app.py"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects and launches the macOS application when the command is absent", async () => {
|
||||
const runtime = new FakeEditorTargets("darwin");
|
||||
runtime.installMacApplication("Cursor");
|
||||
|
||||
expect(await cursorTarget.isInstalled(runtime)).toBe(true);
|
||||
await cursorTarget.launch({ workspacePath: "/repo", filePath: "/repo/src/app.ts" }, runtime);
|
||||
|
||||
expect(runtime.openedMacApplications).toEqual([
|
||||
{
|
||||
applicationName: "Cursor",
|
||||
paths: ["/repo", "/repo/src/app.ts"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses Cursor's bundled macOS command so file positions survive application detection", async () => {
|
||||
const runtime = new FakeEditorTargets("darwin");
|
||||
const bundledCommand = "/Applications/Cursor.app/Contents/Resources/app/bin/cursor";
|
||||
runtime.installCommand(bundledCommand, bundledCommand);
|
||||
|
||||
expect(await cursorTarget.isInstalled(runtime)).toBe(true);
|
||||
await cursorTarget.launch(
|
||||
{ workspacePath: "/repo", filePath: "/repo/src/app.ts", line: 18, column: 3 },
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(runtime.launches).toEqual([
|
||||
{
|
||||
command: bundledCommand,
|
||||
args: ["/repo", "--goto", "/repo/src/app.ts:18:3"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects Cursor's installed Windows command when it is absent from PATH", async () => {
|
||||
const runtime = new FakeEditorTargets("win32", {
|
||||
LOCALAPPDATA: "C:/Users/me/AppData/Local",
|
||||
});
|
||||
const installedCommand =
|
||||
"C:/Users/me/AppData/Local/Programs/cursor/resources/app/bin/cursor.cmd";
|
||||
runtime.installCommand(installedCommand, installedCommand);
|
||||
|
||||
expect(await cursorTarget.isInstalled(runtime)).toBe(true);
|
||||
await cursorTarget.launch(
|
||||
{ workspacePath: "C:/repo", filePath: "C:/repo/src/app.ts", line: 9 },
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(runtime.launches).toEqual([
|
||||
{
|
||||
command: installedCommand,
|
||||
args: ["C:/repo", "--goto", "C:/repo/src/app.ts:9"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("delegates folder opening and file reveal to the system file manager", async () => {
|
||||
const runtime = new FakeEditorTargets("win32");
|
||||
|
||||
expect(await explorerTarget.describe(runtime)).toEqual({
|
||||
id: "explorer",
|
||||
label: "Explorer",
|
||||
kind: "file-manager",
|
||||
icon: { kind: "symbol", name: "folder" },
|
||||
});
|
||||
await explorerTarget.launch({ workspacePath: "C:/repo" }, runtime);
|
||||
await explorerTarget.launch(
|
||||
{ workspacePath: "C:/repo", filePath: "C:/repo/src/app.ts" },
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(runtime.openedPaths).toEqual(["C:/repo"]);
|
||||
expect(runtime.revealedPaths).toEqual(["C:/repo/src/app.ts"]);
|
||||
});
|
||||
|
||||
it("keeps the platform file-manager ids used by stored preferences", async () => {
|
||||
const macTargets = await listAvailableEditorTargets(new FakeEditorTargets("darwin"), [
|
||||
finderTarget,
|
||||
explorerTarget,
|
||||
fileManagerTarget,
|
||||
]);
|
||||
const windowsTargets = await listAvailableEditorTargets(new FakeEditorTargets("win32"), [
|
||||
finderTarget,
|
||||
explorerTarget,
|
||||
fileManagerTarget,
|
||||
]);
|
||||
|
||||
expect(macTargets.map((target) => target.id)).toEqual(["finder"]);
|
||||
expect(windowsTargets.map((target) => target.id)).toEqual(["explorer"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user