Open workspace files in desktop targets
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user