Open workspace files in desktop targets

This commit is contained in:
Mohamed Boudra
2026-06-03 18:34:24 +08:00
committed by GitHub
parent 45f412456c
commit 3681fd132b
30 changed files with 1830 additions and 822 deletions
@@ -1,118 +0,0 @@
import type { ChildProcess } from "node:child_process";
import { describe, expect, it, vi } from "vitest";
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
describe("editor-targets", () => {
it("lists available editors in deterministic order", async () => {
const available = new Set(["code", "cursor", "explorer", "webstorm"]);
const editors = await listAvailableEditorTargets({
platform: "win32",
findExecutable: (command) => (available.has(command) ? command : null),
});
expect(editors).toEqual([
{ id: "cursor", label: "Cursor" },
{ id: "vscode", label: "VS Code" },
{ id: "webstorm", label: "WebStorm" },
{ id: "explorer", label: "Explorer" },
]);
});
it("returns Finder on macOS", async () => {
const editors = await 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", async () => {
const editors = await 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 unknown as ChildProcess);
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,
env: expect.any(Object),
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");
});
it("rejects unknown editor ids", async () => {
await expect(
openInEditorTarget(
{
editorId: "unknown-editor",
path: "/tmp/repo",
},
{
existsSync: () => true,
findExecutable: () => null,
},
),
).rejects.toThrow("Unknown editor target: unknown-editor");
});
});
@@ -1,168 +0,0 @@
import type { ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { posix, win32 } from "node:path";
import type {
EditorTargetDescriptorPayload,
EditorTargetId,
KnownEditorTargetId,
} from "@getpaseo/protocol/messages";
import { createExternalProcessEnv } from "./paseo-env.js";
import { findExecutable } from "../utils/executable.js";
import { spawnProcess } from "../utils/spawn.js";
interface EditorTargetDefinition {
id: KnownEditorTargetId;
label: string;
command: string;
platforms?: readonly NodeJS.Platform[];
excludedPlatforms?: readonly NodeJS.Platform[];
}
interface ListAvailableEditorTargetsDependencies {
platform?: NodeJS.Platform;
findExecutable?: (command: string) => string | null | Promise<string | null>;
}
type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & {
existsSync?: typeof existsSync;
spawn?: typeof spawnProcess;
};
const EDITOR_TARGETS: readonly EditorTargetDefinition[] = [
{ id: "cursor", label: "Cursor", command: "cursor" },
{ id: "vscode", label: "VS Code", command: "code" },
{ id: "webstorm", label: "WebStorm", command: "webstorm" },
{ 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 async function listAvailableEditorTargets(
dependencies: ListAvailableEditorTargetsDependencies = {},
): Promise<EditorTargetDescriptorPayload[]> {
const platform = dependencies.platform ?? process.platform;
const findExecutableFn = dependencies.findExecutable ?? findExecutable;
const supportedTargets = EDITOR_TARGETS.filter((target) =>
isTargetSupportedOnPlatform(target, platform),
);
const executables = await Promise.all(
supportedTargets.map((target) => findExecutableFn(target.command)),
);
const results: EditorTargetDescriptorPayload[] = [];
for (let i = 0; i < supportedTargets.length; i += 1) {
if (!executables[i]) continue;
const target = supportedTargets[i];
results.push({
id: target.id,
label: target.label,
});
}
return results;
}
interface Launch {
command: string;
args: string[];
}
async function resolveEditorLaunch(input: {
editorId: EditorTargetId;
path: string;
platform: NodeJS.Platform;
findExecutableFn: (command: string) => string | null | Promise<string | null>;
}): Promise<Launch> {
const target = resolveEditorTargetDefinition(input.editorId);
if (!isTargetSupportedOnPlatform(target, input.platform)) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
const executable = await 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 ?? spawnProcess;
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 = await resolveEditorLaunch({
editorId: input.editorId,
path: pathToOpen,
platform,
findExecutableFn,
});
await new Promise<void>((resolve, reject) => {
let child: ChildProcess;
try {
child = spawnFn(launch.command, launch.args, {
detached: true,
env: createExternalProcessEnv(process.env),
shell: platform === "win32",
stdio: "ignore",
});
} catch (error) {
reject(error);
return;
}
child.once("error", reject);
child.once("spawn", () => {
child.unref();
resolve();
});
});
}
+21 -107
View File
@@ -1,7 +1,5 @@
import equal from "fast-deep-equal";
import { v4 as uuidv4 } from "uuid";
import { TTLCache } from "@isaacs/ttlcache";
import pMemoize from "p-memoize";
import { realpathSync } from "node:fs";
import type { FSWatcher } from "node:fs";
import { basename, resolve, sep } from "path";
@@ -10,7 +8,6 @@ import { z } from "zod";
import type { ToolSet } from "ai";
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
import {
isLegacyEditorTargetId,
serializeAgentStreamEvent,
type AgentSnapshotPayload,
type AgentAttachment,
@@ -26,8 +23,6 @@ import {
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
type DirectorySuggestionsRequest,
type EditorTargetDescriptorPayload,
type EditorTargetId,
type ProjectPlacementPayload,
type WorkspaceSetupSnapshot,
type WorkspaceDescriptorPayload,
@@ -47,7 +42,6 @@ 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 { getPidLockInfo } from "./pid-lock.js";
import { generateLocalPairingOffer } from "./pairing-offer.js";
import {
@@ -337,7 +331,6 @@ const LEGACY_MODE_ICONS = new Set<string>([
"ShieldQuestionMark",
]);
const MIN_VERSION_ALL_PROVIDERS = "0.1.45";
const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50";
function errorToFriendlyMessage(error: unknown): string {
if (error instanceof Error) return error.message;
@@ -418,10 +411,6 @@ function clientSupportsAllProviders(appVersion: string | null): boolean {
return isAppVersionAtLeast(appVersion, MIN_VERSION_ALL_PROVIDERS);
}
function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean {
return isAppVersionAtLeast(appVersion, MIN_VERSION_FLEXIBLE_EDITOR_IDS);
}
type DeleteFencedAgentStorage = AgentStorage & {
beginDelete(agentId: string): void;
};
@@ -529,9 +518,6 @@ const MIN_STREAMING_SEGMENT_BYTES = Math.round(
PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS,
);
const AgentIdSchema = z.string().uuid();
const AVAILABLE_EDITOR_TARGETS_CACHE_TTL_MS = 60_000;
const AVAILABLE_EDITOR_TARGETS_CACHE_KEY = "available";
interface VoiceModeBaseConfig {
systemPrompt?: string;
}
@@ -817,21 +803,6 @@ export class Session {
private readonly terminalController: TerminalSessionController;
private inflightRequests = 0;
private peakInflightRequests = 0;
private readonly availableEditorTargetsCache = new TTLCache<
string,
EditorTargetDescriptorPayload[]
>({
ttl: AVAILABLE_EDITOR_TARGETS_CACHE_TTL_MS,
max: 1,
checkAgeOnGet: true,
});
private readonly getMemoizedAvailableEditorTargets = pMemoize(
async () => this.resolveAvailableEditorTargets(),
{
cache: this.availableEditorTargetsCache,
cacheKey: () => AVAILABLE_EDITOR_TARGETS_CACHE_KEY,
},
);
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
private readonly workspaceSetupSnapshots: Map<string, WorkspaceSetupSnapshot>;
@@ -1446,15 +1417,6 @@ export class Session {
return LEGACY_PROVIDER_IDS.has(provider);
}
private filterEditorsForClient(
editors: EditorTargetDescriptorPayload[],
): EditorTargetDescriptorPayload[] {
if (clientSupportsFlexibleEditorIds(this.appVersion)) {
return editors;
}
return editors.filter((editor) => isLegacyEditorTargetId(editor.id));
}
private agentThinkingOptionMatchesFilter(
agent: AgentSnapshotPayload,
filter: AgentUpdatesFilter,
@@ -2128,10 +2090,11 @@ export class Session {
return this.handleCreatePaseoWorktreeRequest(msg);
case "workspace_setup_status_request":
return this.handleWorkspaceSetupStatusRequest(msg);
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs.
case "list_available_editors_request":
return this.handleListAvailableEditorsRequest(msg);
return this.handleLegacyListAvailableEditorsRequest(msg);
case "open_in_editor_request":
return this.handleOpenInEditorRequest(msg);
return this.handleLegacyOpenInEditorRequest(msg);
case "open_project_request":
return this.handleOpenProjectRequest(msg);
case "archive_workspace_request":
@@ -7166,18 +7129,6 @@ export class Session {
});
}
async resolveAvailableEditorTargets(): Promise<EditorTargetDescriptorPayload[]> {
return listAvailableEditorTargets();
}
async getAvailableEditorTargets() {
return this.filterEditorsForClient(await this.getMemoizedAvailableEditorTargets());
}
async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise<void> {
await openInEditorTarget(options);
}
private async handleStartWorkspaceScriptRequest(
request: StartWorkspaceScriptRequest,
): Promise<void> {
@@ -7244,67 +7195,30 @@ export class Session {
}
}
private async handleListAvailableEditorsRequest(
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs.
private async handleLegacyListAvailableEditorsRequest(
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,
},
});
}
this.emit({
type: "list_available_editors_response",
payload: {
requestId: request.requestId,
editors: [],
error: "Editor opening moved to the desktop app and is no longer supported by the daemon",
},
});
}
private async handleOpenInEditorRequest(
private async handleLegacyOpenInEditorRequest(
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,
},
});
}
this.emit({
type: "open_in_editor_response",
payload: {
requestId: request.requestId,
error: "Editor opening moved to the desktop app and is no longer supported by the daemon",
},
});
}
private async handleCreatePaseoWorktreeRequest(
@@ -5,11 +5,7 @@ import path from "node:path";
import { expect, test, vi } from "vitest";
import { z } from "zod";
import { Session } from "./session.js";
import type {
AgentSnapshotPayload,
EditorTargetDescriptorPayload,
SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage } from "./agent/agent-storage.js";
import type {
@@ -123,10 +119,6 @@ interface SessionTestAccess {
emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise<unknown>;
emit(message: unknown): void;
onMessage(message: unknown): void;
getAvailableEditorTargets(...args: unknown[]): Promise<unknown>;
filterEditorsForClient(...args: unknown[]): unknown;
openEditorTarget(input: { editorId: string; path: string }): Promise<unknown>;
resolveAvailableEditorTargets(...args: unknown[]): Promise<unknown>;
paseoHome: string;
terminalManager: {
killTerminal(id: string): unknown;
@@ -3492,137 +3484,16 @@ test.skip("open_project_request collapses a git subdirectory onto the repo root
expect(response?.payload.workspace?.id).toBe(repoRoot);
});
test("list_available_editors_request returns available targets", async () => {
test("legacy editor RPC requests return daemon unsupported errors", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({ appVersion: "0.1.50" });
session.emit = (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
};
session.getAvailableEditorTargets = async () =>
session.filterEditorsForClient([
{ id: "cursor", label: "Cursor" },
{ id: "webstorm", label: "WebStorm" },
{ id: "finder", label: "Finder" },
{ id: "unknown-editor", label: "Unknown Editor" },
]);
const session = createSessionForWorkspaceTests({
onMessage: (message) => emitted.push(message),
});
await session.handleMessage({
type: "list_available_editors_request",
requestId: "req-editors",
});
const response = emitted.find((message) => message.type === "list_available_editors_response") as
| { payload: Record<string, unknown> }
| undefined;
expect(response?.payload.error).toBeNull();
expect(response?.payload.editors).toEqual([
{ id: "cursor", label: "Cursor" },
{ id: "webstorm", label: "WebStorm" },
{ id: "finder", label: "Finder" },
{ id: "unknown-editor", label: "Unknown Editor" },
]);
});
test("list_available_editors_request coalesces concurrent discovery", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = asTestSession(
createSessionForWorkspaceTests({
appVersion: "0.1.50",
onMessage: (message) => emitted.push(message),
}),
);
let resolveDiscovery: (editors: EditorTargetDescriptorPayload[]) => void = () => {};
let discoveryCalls = 0;
vi.spyOn(session, "resolveAvailableEditorTargets").mockImplementation(async () => {
discoveryCalls += 1;
return await new Promise<EditorTargetDescriptorPayload[]>((resolve) => {
resolveDiscovery = resolve;
});
});
const first = session.handleMessage({
type: "list_available_editors_request",
requestId: "req-editors-1",
});
const second = session.handleMessage({
type: "list_available_editors_request",
requestId: "req-editors-2",
});
await Promise.resolve();
expect(discoveryCalls).toBe(1);
resolveDiscovery([{ id: "cursor", label: "Cursor" }]);
await Promise.all([first, second]);
const responses = emitted.filter(
(
message,
): message is Extract<SessionOutboundMessage, { type: "list_available_editors_response" }> =>
message.type === "list_available_editors_response",
);
expect(responses).toHaveLength(2);
expect(responses.map((response) => response.payload.requestId)).toEqual([
"req-editors-1",
"req-editors-2",
]);
expect(responses.map((response) => response.payload.editors)).toEqual([
[{ id: "cursor", label: "Cursor" }],
[{ id: "cursor", label: "Cursor" }],
]);
await session.handleMessage({
type: "list_available_editors_request",
requestId: "req-editors-3",
});
expect(discoveryCalls).toBe(1);
});
test("list_available_editors_request filters unsupported ids for legacy clients", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({ appVersion: "0.1.49" });
session.emit = (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
};
session.getAvailableEditorTargets = async () =>
session.filterEditorsForClient([
{ id: "cursor", label: "Cursor" },
{ id: "webstorm", label: "WebStorm" },
{ id: "unknown-editor", label: "Unknown Editor" },
{ id: "finder", label: "Finder" },
]);
await session.handleMessage({
type: "list_available_editors_request",
requestId: "req-editors-legacy",
});
const response = emitted.find((message) => message.type === "list_available_editors_response") as
| { payload: Record<string, unknown> }
| undefined;
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: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();
const calls: Array<{ editorId: string; path: string }> = [];
session.emit = (message) => {
if (isSessionOutboundMessage(message)) 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",
@@ -3630,11 +3501,15 @@ test("open_in_editor_request launches the selected target", async () => {
path: REPO_CWD,
});
expect(calls).toEqual([{ editorId: "vscode", path: REPO_CWD }]);
const response = emitted.find((message) => message.type === "open_in_editor_response") as
| { payload: Record<string, unknown> }
| undefined;
expect(response?.payload.error).toBeNull();
const listResponse = findByType(emitted, "list_available_editors_response");
const openResponse = findByType(emitted, "open_in_editor_response");
expect(listResponse?.payload.editors).toEqual([]);
expect(listResponse?.payload.error).toBe(
"Editor opening moved to the desktop app and is no longer supported by the daemon",
);
expect(openResponse?.payload.error).toBe(
"Editor opening moved to the desktop app and is no longer supported by the daemon",
);
});
test("archive_workspace_request hides non-destructive workspace records", async () => {