feat(app): add open in editor toolbar action (#209)

* feat(app): add open in editor toolbar action

* fix(app): normalize editor icon asset permissions

* chore(website): drop unrelated generated route tree change
This commit is contained in:
Illia Panasenko
2026-04-07 10:25:25 +08:00
committed by GitHub
parent fd9dfb0cc8
commit 5cb424b2e6
17 changed files with 870 additions and 0 deletions
@@ -34,6 +34,8 @@ import type {
PaseoWorktreeListResponse,
PaseoWorktreeArchiveResponse,
ProjectIconResponse,
ListAvailableEditorsResponseMessage,
OpenInEditorResponseMessage,
OpenProjectResponseMessage,
ArchiveWorkspaceResponseMessage,
ListCommandsResponse,
@@ -54,6 +56,7 @@ import type {
TerminalInput,
SessionInboundMessage,
SessionOutboundMessage,
EditorTargetId,
} from "../shared/messages.js";
import type {
AgentPermissionRequest,
@@ -472,8 +475,11 @@ export type InspectScheduleOptions = {
id: string;
requestId?: string;
};
type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"];
type OpenInEditorPayload = OpenInEditorResponseMessage["payload"];
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
export type EditorTargetDescriptor = ListAvailableEditorsPayload["editors"][number];
export type FetchAgentResult = {
agent: AgentSnapshotPayload;
@@ -1318,6 +1324,34 @@ export class DaemonClient {
});
}
async listAvailableEditors(requestId?: string): Promise<ListAvailableEditorsPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "list_available_editors_request",
},
responseType: "list_available_editors_response",
timeout: 10000,
});
}
async openInEditor(
path: string,
editorId: EditorTargetId,
requestId?: string,
): Promise<OpenInEditorPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "open_in_editor_request",
path,
editorId,
},
responseType: "open_in_editor_response",
timeout: 10000,
});
}
async archiveWorkspace(
workspaceId: string,
requestId?: string,
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from "vitest";
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
describe("editor-targets", () => {
it("lists available editors in deterministic order", () => {
const available = new Set(["code", "cursor", "explorer"]);
const editors = listAvailableEditorTargets({
platform: "win32",
findExecutable: (command) => (available.has(command) ? command : null),
});
expect(editors).toEqual([
{ id: "cursor", label: "Cursor" },
{ id: "vscode", label: "VS Code" },
{ id: "explorer", label: "Explorer" },
]);
});
it("returns Finder on macOS", () => {
const editors = listAvailableEditorTargets({
platform: "darwin",
findExecutable: (command) => (command === "open" ? "/usr/bin/open" : null),
});
expect(editors).toEqual([{ id: "finder", label: "Finder" }]);
});
it("returns the generic file manager target on Linux", () => {
const editors = listAvailableEditorTargets({
platform: "linux",
findExecutable: (command) => (command === "xdg-open" ? "/usr/bin/xdg-open" : null),
});
expect(editors).toEqual([{ id: "file-manager", label: "File Manager" }]);
});
it("launches editors as detached processes", async () => {
const unref = vi.fn();
const once = vi.fn((event: string, handler: () => void) => {
if (event === "spawn") {
queueMicrotask(handler);
}
return child;
});
const child = { once, unref };
const spawn = vi.fn(() => child as any);
await openInEditorTarget(
{
editorId: "vscode",
path: "/tmp/repo",
},
{
platform: "darwin",
existsSync: () => true,
findExecutable: (command) => (command === "code" ? "/usr/local/bin/code" : null),
spawn,
},
);
expect(spawn).toHaveBeenCalledWith("/usr/local/bin/code", ["/tmp/repo"], {
detached: true,
shell: false,
stdio: "ignore",
});
expect(unref).toHaveBeenCalled();
});
it("rejects relative paths", async () => {
await expect(
openInEditorTarget(
{
editorId: "cursor",
path: "repo",
},
{
existsSync: () => true,
findExecutable: () => "/usr/local/bin/cursor",
},
),
).rejects.toThrow("Editor target path must be an absolute local path");
});
it("rejects platform-specific targets that are unavailable on this OS", async () => {
await expect(
openInEditorTarget(
{
editorId: "finder",
path: "/tmp/repo",
},
{
platform: "linux",
existsSync: () => true,
findExecutable: () => "/usr/bin/open",
},
),
).rejects.toThrow("Editor target unavailable: Finder");
});
});
@@ -0,0 +1,168 @@
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { posix, win32 } from "node:path";
import type { EditorTargetDescriptorPayload, EditorTargetId } from "../shared/messages.js";
import {
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "../utils/executable.js";
type EditorTargetDefinition = {
id: EditorTargetId;
label: string;
command: string;
platforms?: readonly NodeJS.Platform[];
excludedPlatforms?: readonly NodeJS.Platform[];
};
type ListAvailableEditorTargetsDependencies = {
platform?: NodeJS.Platform;
findExecutable?: (command: string) => string | null;
};
type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & {
existsSync?: typeof existsSync;
spawn?: typeof spawn;
};
const EDITOR_TARGETS: readonly EditorTargetDefinition[] = [
{ id: "cursor", label: "Cursor", command: "cursor" },
{ id: "vscode", label: "VS Code", command: "code" },
{ id: "zed", label: "Zed", command: "zed" },
{ id: "finder", label: "Finder", command: "open", platforms: ["darwin"] },
{ id: "explorer", label: "Explorer", command: "explorer", platforms: ["win32"] },
{
id: "file-manager",
label: "File Manager",
command: "xdg-open",
excludedPlatforms: ["darwin", "win32"],
},
];
function isAbsolutePath(value: string): boolean {
return posix.isAbsolute(value) || win32.isAbsolute(value);
}
function isTargetSupportedOnPlatform(
target: EditorTargetDefinition,
platform: NodeJS.Platform,
): boolean {
if (target.platforms && !target.platforms.includes(platform)) {
return false;
}
if (target.excludedPlatforms?.includes(platform)) {
return false;
}
return true;
}
function resolveEditorTargetDefinition(editorId: EditorTargetId): EditorTargetDefinition {
const target = EDITOR_TARGETS.find((entry) => entry.id === editorId);
if (!target) {
throw new Error(`Unknown editor target: ${editorId}`);
}
return target;
}
export function listAvailableEditorTargets(
dependencies: ListAvailableEditorTargetsDependencies = {},
): EditorTargetDescriptorPayload[] {
const platform = dependencies.platform ?? process.platform;
const findExecutableFn = dependencies.findExecutable ?? findExecutable;
return EDITOR_TARGETS.flatMap((target) => {
if (!isTargetSupportedOnPlatform(target, platform)) {
return [];
}
const executable = findExecutableFn(target.command);
if (!executable) {
return [];
}
return [
{
id: target.id,
label: target.label,
},
];
});
}
type Launch = {
command: string;
args: string[];
};
function resolveEditorLaunch(input: {
editorId: EditorTargetId;
path: string;
platform: NodeJS.Platform;
findExecutableFn: typeof findExecutable;
}): Launch {
const target = resolveEditorTargetDefinition(input.editorId);
if (!isTargetSupportedOnPlatform(target, input.platform)) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
const executable = input.findExecutableFn(target.command);
if (!executable) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
return {
command: executable,
args: [input.path],
};
}
export async function openInEditorTarget(
input: {
editorId: EditorTargetId;
path: string;
},
dependencies: OpenInEditorTargetDependencies = {},
): Promise<void> {
const platform = dependencies.platform ?? process.platform;
const pathToOpen = input.path.trim();
const existsSyncFn = dependencies.existsSync ?? existsSync;
const findExecutableFn = dependencies.findExecutable ?? findExecutable;
const spawnFn = dependencies.spawn ?? spawn;
if (!pathToOpen || !isAbsolutePath(pathToOpen)) {
throw new Error("Editor target path must be an absolute local path");
}
if (!existsSyncFn(pathToOpen)) {
throw new Error(`Path does not exist: ${pathToOpen}`);
}
const launch = resolveEditorLaunch({
editorId: input.editorId,
path: pathToOpen,
platform,
findExecutableFn,
});
const command = platform === "win32" ? quoteWindowsCommand(launch.command) : launch.command;
const args =
platform === "win32" ? launch.args.map((argument) => quoteWindowsArgument(argument)) : launch.args;
await new Promise<void>((resolve, reject) => {
let child: ChildProcess;
try {
child = spawnFn(command, args, {
detached: true,
shell: platform === "win32",
stdio: "ignore",
});
} catch (error) {
reject(error);
return;
}
child.once("error", reject);
child.once("spawn", () => {
child.unref();
resolve();
});
});
}
+81
View File
@@ -28,6 +28,7 @@ import {
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
type DirectorySuggestionsRequest,
type EditorTargetId,
type ProjectPlacementPayload,
type WorkspaceDescriptorPayload,
type WorkspaceStateBucket,
@@ -47,6 +48,7 @@ import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
import {
DictationStreamManager,
type DictationStreamOutboundMessage,
@@ -1742,6 +1744,14 @@ export class Session {
await this.handleCreatePaseoWorktreeRequest(msg);
break;
case "list_available_editors_request":
await this.handleListAvailableEditorsRequest(msg);
break;
case "open_in_editor_request":
await this.handleOpenInEditorRequest(msg);
break;
case "open_project_request":
await this.handleOpenProjectRequest(msg);
break;
@@ -5981,6 +5991,77 @@ export class Session {
}
}
async getAvailableEditorTargets() {
return listAvailableEditorTargets();
}
async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise<void> {
await openInEditorTarget(options);
}
private async handleListAvailableEditorsRequest(
request: Extract<SessionInboundMessage, { type: "list_available_editors_request" }>,
): Promise<void> {
try {
const editors = await this.getAvailableEditorTargets();
this.emit({
type: "list_available_editors_response",
payload: {
requestId: request.requestId,
editors,
error: null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to list available editors";
this.sessionLogger.error(
{ err: error, requestType: request.type },
"Failed to list available editors",
);
this.emit({
type: "list_available_editors_response",
payload: {
requestId: request.requestId,
editors: [],
error: message,
},
});
}
}
private async handleOpenInEditorRequest(
request: Extract<SessionInboundMessage, { type: "open_in_editor_request" }>,
): Promise<void> {
try {
await this.openEditorTarget({ editorId: request.editorId, path: request.path });
this.emit({
type: "open_in_editor_response",
payload: {
requestId: request.requestId,
error: null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to open in editor";
this.sessionLogger.error(
{
err: error,
editorId: request.editorId,
path: request.path,
requestType: request.type,
},
"Failed to open in editor",
);
this.emit({
type: "open_in_editor_response",
payload: {
requestId: request.requestId,
error: message,
},
});
}
}
private async handleCreatePaseoWorktreeRequest(
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> {
@@ -1223,6 +1223,55 @@ describe("workspace aggregation", () => {
expect(response?.payload.workspace?.id).toBe(repoRoot);
});
test("list_available_editors_request returns available targets", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests() as any;
session.emit = (message: any) => emitted.push(message);
session.getAvailableEditorTargets = async () => [
{ id: "cursor", label: "Cursor" },
{ id: "finder", label: "Finder" },
];
await session.handleMessage({
type: "list_available_editors_request",
requestId: "req-editors",
});
const response = emitted.find(
(message) => message.type === "list_available_editors_response",
) as any;
expect(response?.payload.error).toBeNull();
expect(response?.payload.editors).toEqual([
{ id: "cursor", label: "Cursor" },
{ id: "finder", label: "Finder" },
]);
});
test("open_in_editor_request launches the selected target", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests() as any;
const calls: Array<{ editorId: string; path: string }> = [];
session.emit = (message: any) => emitted.push(message);
session.openEditorTarget = async (input: { editorId: string; path: string }) => {
calls.push(input);
};
await session.handleMessage({
type: "open_in_editor_request",
requestId: "req-open-editor",
editorId: "vscode",
path: "/tmp/repo",
});
expect(calls).toEqual([{ editorId: "vscode", path: "/tmp/repo" }]);
const response = emitted.find(
(message) => message.type === "open_in_editor_response",
) as any;
expect(response?.payload.error).toBeNull();
});
test("archive_workspace_request hides non-destructive workspace records", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests() as any;
+55
View File
@@ -1084,6 +1084,32 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
requestId: z.string(),
});
export const EditorTargetIdSchema = z.enum([
"cursor",
"vscode",
"zed",
"finder",
"explorer",
"file-manager",
]);
export const EditorTargetDescriptorPayloadSchema = z.object({
id: EditorTargetIdSchema,
label: z.string(),
});
export const ListAvailableEditorsRequestSchema = z.object({
type: z.literal("list_available_editors_request"),
requestId: z.string(),
});
export const OpenInEditorRequestSchema = z.object({
type: z.literal("open_in_editor_request"),
path: z.string(),
editorId: EditorTargetIdSchema,
requestId: z.string(),
});
export const OpenProjectRequestSchema = z.object({
type: z.literal("open_project_request"),
cwd: z.string(),
@@ -1341,6 +1367,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
PaseoWorktreeListRequestSchema,
PaseoWorktreeArchiveRequestSchema,
CreatePaseoWorktreeRequestSchema,
ListAvailableEditorsRequestSchema,
OpenInEditorRequestSchema,
OpenProjectRequestSchema,
ArchiveWorkspaceRequestSchema,
FileExplorerRequestSchema,
@@ -1828,6 +1856,23 @@ export const OpenProjectResponseMessageSchema = z.object({
}),
});
export const ListAvailableEditorsResponseMessageSchema = z.object({
type: z.literal("list_available_editors_response"),
payload: z.object({
requestId: z.string(),
editors: z.array(EditorTargetDescriptorPayloadSchema),
error: z.string().nullable(),
}),
});
export const OpenInEditorResponseMessageSchema = z.object({
type: z.literal("open_in_editor_response"),
payload: z.object({
requestId: z.string(),
error: z.string().nullable(),
}),
});
export const ArchiveWorkspaceResponseMessageSchema = z.object({
type: z.literal("archive_workspace_response"),
payload: z.object({
@@ -2475,6 +2520,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FetchAgentsResponseMessageSchema,
FetchWorkspacesResponseMessageSchema,
OpenProjectResponseMessageSchema,
ListAvailableEditorsResponseMessageSchema,
OpenInEditorResponseMessageSchema,
ArchiveWorkspaceResponseMessageSchema,
FetchAgentResponseMessageSchema,
FetchAgentTimelineResponseMessageSchema,
@@ -2567,9 +2614,15 @@ export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePaylo
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>;
export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>;
export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema>;
export type EditorTargetId = z.infer<typeof EditorTargetIdSchema>;
export type EditorTargetDescriptorPayload = z.infer<typeof EditorTargetDescriptorPayloadSchema>;
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
export type ListAvailableEditorsResponseMessage = z.infer<
typeof ListAvailableEditorsResponseMessageSchema
>;
export type OpenInEditorResponseMessage = z.infer<typeof OpenInEditorResponseMessageSchema>;
export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>;
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>;
export type FetchAgentTimelineResponseMessage = z.infer<
@@ -2717,6 +2770,8 @@ export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSc
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
export type ListAvailableEditorsRequest = z.infer<typeof ListAvailableEditorsRequestSchema>;
export type OpenInEditorRequest = z.infer<typeof OpenInEditorRequestSchema>;
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
@@ -29,6 +29,27 @@ describe("workspace message schemas", () => {
expect(parsed.type).toBe("open_project_request");
});
test("parses list_available_editors_request", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "list_available_editors_request",
requestId: "req-editors",
});
expect(parsed.type).toBe("list_available_editors_request");
});
test("parses open_in_editor_response", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "open_in_editor_response",
payload: {
requestId: "req-open-editor",
error: null,
},
});
expect(parsed.type).toBe("open_in_editor_response");
});
test("rejects invalid workspace update payload", () => {
const result = SessionOutboundMessageSchema.safeParse({
type: "workspace_update",