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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 19 additions & 2 deletions packages/app/src/components/icons/editor-app-icons.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { SquareTerminal } from "lucide-react-native";
import { Image, type ImageSourcePropType } from "react-native";
import type { EditorTargetId } from "@server/shared/messages";
import {
isKnownEditorTargetId,
type EditorTargetId,
type KnownEditorTargetId,
} from "@server/shared/messages";

interface EditorAppIconProps {
editorId: EditorTargetId;
Expand All @@ -8,20 +13,32 @@ interface EditorAppIconProps {
}

/* eslint-disable @typescript-eslint/no-require-imports */
const EDITOR_APP_IMAGES: Record<EditorTargetId, ImageSourcePropType> = {
const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
cursor: require("../../../assets/images/editor-apps/cursor.png"),
vscode: require("../../../assets/images/editor-apps/vscode.png"),
webstorm: require("../../../assets/images/editor-apps/webstorm.png"),
zed: require("../../../assets/images/editor-apps/zed.png"),
finder: require("../../../assets/images/editor-apps/finder.png"),
explorer: require("../../../assets/images/editor-apps/file-explorer.png"),
"file-manager": require("../../../assets/images/editor-apps/file-explorer.png"),
};
/* eslint-enable @typescript-eslint/no-require-imports */

export function hasBundledEditorAppIcon(
editorId: EditorTargetId,
): editorId is KnownEditorTargetId {
return isKnownEditorTargetId(editorId);
}

export function EditorAppIcon({
editorId,
size = 16,
color,
}: EditorAppIconProps) {
if (!hasBundledEditorAppIcon(editorId)) {
return <SquareTerminal size={size} color={color} />;
}

return (
<Image
source={EDITOR_APP_IMAGES[editorId]}
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/hooks/use-preferred-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ describe("resolvePreferredEditorId", () => {
expect(resolvePreferredEditorId(["explorer", "vscode"], "finder")).toBe("explorer");
});

it("keeps unknown editor ids when they are still available", () => {
expect(resolvePreferredEditorId(["unknown-editor", "cursor"], "unknown-editor")).toBe(
"unknown-editor",
);
});

it("returns null when no editors are available", () => {
expect(resolvePreferredEditorId([], "cursor")).toBeNull();
});
Expand Down
18 changes: 17 additions & 1 deletion packages/server/src/server/editor-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets

describe("editor-targets", () => {
it("lists available editors in deterministic order", () => {
const available = new Set(["code", "cursor", "explorer"]);
const available = new Set(["code", "cursor", "explorer", "webstorm"]);

const editors = listAvailableEditorTargets({
platform: "win32",
Expand All @@ -13,6 +13,7 @@ describe("editor-targets", () => {
expect(editors).toEqual([
{ id: "cursor", label: "Cursor" },
{ id: "vscode", label: "VS Code" },
{ id: "webstorm", label: "WebStorm" },
{ id: "explorer", label: "Explorer" },
]);
});
Expand Down Expand Up @@ -97,4 +98,19 @@ describe("editor-targets", () => {
),
).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");
});
});
9 changes: 7 additions & 2 deletions packages/server/src/server/editor-targets.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
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 type {
EditorTargetDescriptorPayload,
EditorTargetId,
KnownEditorTargetId,
} from "../shared/messages.js";
import {
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "../utils/executable.js";

type EditorTargetDefinition = {
id: EditorTargetId;
id: KnownEditorTargetId;
label: string;
command: string;
platforms?: readonly NodeJS.Platform[];
Expand All @@ -29,6 +33,7 @@ type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & {
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"] },
Expand Down
26 changes: 23 additions & 3 deletions packages/server/src/server/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { homedir } from "node:os";
import { z } from "zod";
import type { ToolSet } from "ai";
import {
isLegacyEditorTargetId,
serializeAgentStreamEvent,
type AgentSnapshotPayload,
type SessionInboundMessage,
Expand All @@ -28,6 +29,7 @@ import {
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
type DirectorySuggestionsRequest,
type EditorTargetDescriptorPayload,
type EditorTargetId,
type ProjectPlacementPayload,
type WorkspaceDescriptorPayload,
Expand Down Expand Up @@ -202,13 +204,14 @@ const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
// the entire session message if they encounter an unknown provider.
const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]);
const MIN_VERSION_ALL_PROVIDERS = "0.1.45";
const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50";

function clientSupportsAllProviders(appVersion: string | null): boolean {
function isAppVersionAtLeast(appVersion: string | null, minVersion: string): boolean {
if (!appVersion) return false;
// Strip RC/prerelease suffix: "0.1.45-rc.4" → "0.1.45"
const base = appVersion.replace(/-.*$/, "");
const parts = base.split(".").map(Number);
const minParts = MIN_VERSION_ALL_PROVIDERS.split(".").map(Number);
const minParts = minVersion.split(".").map(Number);
for (let i = 0; i < minParts.length; i++) {
const a = parts[i] ?? 0;
const b = minParts[i] ?? 0;
Expand All @@ -218,6 +221,14 @@ function clientSupportsAllProviders(appVersion: string | null): boolean {
return true;
}

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);
}

const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024;
Expand Down Expand Up @@ -1215,6 +1226,15 @@ 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 matchesAgentFilter(options: {
agent: AgentSnapshotPayload;
project: ProjectPlacementPayload;
Expand Down Expand Up @@ -6095,7 +6115,7 @@ export class Session {
}

async getAvailableEditorTargets() {
return listAvailableEditorTargets();
return this.filterEditorsForClient(listAvailableEditorTargets());
}

async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise<void> {
Expand Down
44 changes: 39 additions & 5 deletions packages/server/src/server/session.workspaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ function makeAgent(input: {
};
}

function createSessionForWorkspaceTests(): Session {
function createSessionForWorkspaceTests(options: { appVersion?: string | null } = {}): Session {
const logger = {
child: () => logger,
trace: vi.fn(),
Expand All @@ -73,6 +73,7 @@ function createSessionForWorkspaceTests(): Session {

const session = new Session({
clientId: "test-client",
appVersion: options.appVersion ?? null,
onMessage: vi.fn(),
logger: logger as any,
downloadTokenStore: {} as any,
Expand Down Expand Up @@ -1246,17 +1247,50 @@ describe("workspace aggregation", () => {

test("list_available_editors_request returns available targets", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests() as any;
const session = createSessionForWorkspaceTests({ appVersion: "0.1.50" }) as any;

session.emit = (message: any) => emitted.push(message);
session.getAvailableEditorTargets = async () => [
session.getAvailableEditorTargets = async () =>
session.filterEditorsForClient([
{ id: "cursor", label: "Cursor" },
{ id: "webstorm", label: "WebStorm" },
{ id: "finder", label: "Finder" },
{ id: "unknown-editor", label: "Unknown Editor" },
]);

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: "webstorm", label: "WebStorm" },
{ id: "finder", label: "Finder" },
];
{ id: "unknown-editor", label: "Unknown Editor" },
]);
});

test("list_available_editors_request filters unsupported ids for legacy clients", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests({ appVersion: "0.1.49" }) as any;

session.emit = (message: any) => 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",
requestId: "req-editors-legacy",
});

const response = emitted.find(
Expand Down
3 changes: 3 additions & 0 deletions packages/server/src/shared/literal-union.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Adapted from type-fest's LiteralUnion:
// https://github.com/sindresorhus/type-fest/blob/main/source/literal-union.d.ts
export type LiteralUnion<T extends U, U extends string> = T | (U & Record<never, never>);
27 changes: 24 additions & 3 deletions packages/server/src/shared/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
LoopLogsResponseSchema,
LoopStopResponseSchema,
} from "../server/loop/rpc-schemas.js";
import type { LiteralUnion } from "./literal-union.js";
import type {
AgentCapabilityFlags,
AgentModelDefinition,
Expand Down Expand Up @@ -1096,14 +1097,32 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
requestId: z.string(),
});

export const EditorTargetIdSchema = z.enum([
// TODO: Remove once most clients are on >=0.1.50 and support arbitrary editor ids.
export const LEGACY_EDITOR_TARGET_IDS = [
"cursor",
"vscode",
"zed",
"finder",
"explorer",
"file-manager",
]);
] as const;

export const KNOWN_EDITOR_TARGET_IDS = [...LEGACY_EDITOR_TARGET_IDS, "webstorm"] as const;

export const KnownEditorTargetIdSchema = z.enum(KNOWN_EDITOR_TARGET_IDS);
export const LegacyEditorTargetIdSchema = z.enum(LEGACY_EDITOR_TARGET_IDS);
export const EditorTargetIdSchema = z.string().trim().min(1);

const KNOWN_EDITOR_TARGET_ID_SET = new Set<string>(KNOWN_EDITOR_TARGET_IDS);
const LEGACY_EDITOR_TARGET_ID_SET = new Set<string>(LEGACY_EDITOR_TARGET_IDS);

export function isKnownEditorTargetId(value: string): value is KnownEditorTargetId {
return KNOWN_EDITOR_TARGET_ID_SET.has(value);
}

export function isLegacyEditorTargetId(value: string): value is LegacyEditorTargetId {
return LEGACY_EDITOR_TARGET_ID_SET.has(value);
}

export const EditorTargetDescriptorPayloadSchema = z.object({
id: EditorTargetIdSchema,
Expand Down Expand Up @@ -2626,7 +2645,9 @@ 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 KnownEditorTargetId = z.infer<typeof KnownEditorTargetIdSchema>;
export type LegacyEditorTargetId = z.infer<typeof LegacyEditorTargetIdSchema>;
export type EditorTargetId = LiteralUnion<KnownEditorTargetId, string>;
export type EditorTargetDescriptorPayload = z.infer<typeof EditorTargetDescriptorPayloadSchema>;
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
Expand Down
45 changes: 45 additions & 0 deletions packages/server/src/shared/messages.workspaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,24 @@ describe("workspace message schemas", () => {
expect(parsed.type).toBe("list_available_editors_request");
});

test("parses open_in_editor_request with flexible editor ids", () => {
const knownEditor = SessionInboundMessageSchema.parse({
type: "open_in_editor_request",
requestId: "req-open-webstorm",
editorId: "webstorm",
path: "/tmp/repo",
});
const unknownEditor = SessionInboundMessageSchema.parse({
type: "open_in_editor_request",
requestId: "req-open-custom",
editorId: "unknown-editor",
path: "/tmp/repo",
});

expect(knownEditor.type).toBe("open_in_editor_request");
expect(unknownEditor.type).toBe("open_in_editor_request");
});

test("parses open_in_editor_response", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "open_in_editor_response",
Expand All @@ -50,6 +68,33 @@ describe("workspace message schemas", () => {
expect(parsed.type).toBe("open_in_editor_response");
});

test("parses list_available_editors_response with unknown editor ids", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "list_available_editors_response",
payload: {
requestId: "req-editors",
editors: [
{ id: "cursor", label: "Cursor" },
{ id: "unknown-editor", label: "Unknown Editor" },
],
error: null,
},
});

expect(parsed.type).toBe("list_available_editors_response");
});

test("rejects empty editor ids", () => {
const result = SessionInboundMessageSchema.safeParse({
type: "open_in_editor_request",
requestId: "req-open-empty",
editorId: "",
path: "/tmp/repo",
});

expect(result.success).toBe(false);
});

test("rejects invalid workspace update payload", () => {
const result = SessionOutboundMessageSchema.safeParse({
type: "workspace_update",
Expand Down