diff --git a/packages/app/assets/images/editor-apps/webstorm.png b/packages/app/assets/images/editor-apps/webstorm.png new file mode 100644 index 00000000..7837d566 Binary files /dev/null and b/packages/app/assets/images/editor-apps/webstorm.png differ diff --git a/packages/app/src/components/icons/editor-app-icons.tsx b/packages/app/src/components/icons/editor-app-icons.tsx index a2934167..a0aef6c0 100644 --- a/packages/app/src/components/icons/editor-app-icons.tsx +++ b/packages/app/src/components/icons/editor-app-icons.tsx @@ -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; @@ -8,9 +13,10 @@ interface EditorAppIconProps { } /* eslint-disable @typescript-eslint/no-require-imports */ -const EDITOR_APP_IMAGES: Record = { +const EDITOR_APP_IMAGES: Record = { 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"), @@ -18,10 +24,21 @@ const EDITOR_APP_IMAGES: Record = { }; /* 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 ; + } + return ( { 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(); }); diff --git a/packages/server/src/server/editor-targets.test.ts b/packages/server/src/server/editor-targets.test.ts index 315ecab6..7f070422 100644 --- a/packages/server/src/server/editor-targets.test.ts +++ b/packages/server/src/server/editor-targets.test.ts @@ -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", @@ -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" }, ]); }); @@ -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"); + }); }); diff --git a/packages/server/src/server/editor-targets.ts b/packages/server/src/server/editor-targets.ts index 4bcadacf..dd37f6aa 100644 --- a/packages/server/src/server/editor-targets.ts +++ b/packages/server/src/server/editor-targets.ts @@ -1,7 +1,11 @@ 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, @@ -9,7 +13,7 @@ import { } from "../utils/executable.js"; type EditorTargetDefinition = { - id: EditorTargetId; + id: KnownEditorTargetId; label: string; command: string; platforms?: readonly NodeJS.Platform[]; @@ -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"] }, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index b6015127..a9cc0b28 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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, @@ -28,6 +29,7 @@ import { type SubscribeCheckoutDiffRequest, type UnsubscribeCheckoutDiffRequest, type DirectorySuggestionsRequest, + type EditorTargetDescriptorPayload, type EditorTargetId, type ProjectPlacementPayload, type WorkspaceDescriptorPayload, @@ -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; @@ -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; @@ -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; @@ -6095,7 +6115,7 @@ export class Session { } async getAvailableEditorTargets() { - return listAvailableEditorTargets(); + return this.filterEditorsForClient(listAvailableEditorTargets()); } async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index c054ca00..eb6d331f 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -61,7 +61,7 @@ function makeAgent(input: { }; } -function createSessionForWorkspaceTests(): Session { +function createSessionForWorkspaceTests(options: { appVersion?: string | null } = {}): Session { const logger = { child: () => logger, trace: vi.fn(), @@ -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, @@ -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( diff --git a/packages/server/src/shared/literal-union.ts b/packages/server/src/shared/literal-union.ts new file mode 100644 index 00000000..ad05ccd9 --- /dev/null +++ b/packages/server/src/shared/literal-union.ts @@ -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 | (U & Record); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 4805343b..90183017 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -47,6 +47,7 @@ import { LoopLogsResponseSchema, LoopStopResponseSchema, } from "../server/loop/rpc-schemas.js"; +import type { LiteralUnion } from "./literal-union.js"; import type { AgentCapabilityFlags, AgentModelDefinition, @@ -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(KNOWN_EDITOR_TARGET_IDS); +const LEGACY_EDITOR_TARGET_ID_SET = new Set(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, @@ -2626,7 +2645,9 @@ export type ProjectCheckoutLitePayload = z.infer; export type WorkspaceStateBucket = z.infer; export type WorkspaceDescriptorPayload = z.infer; -export type EditorTargetId = z.infer; +export type KnownEditorTargetId = z.infer; +export type LegacyEditorTargetId = z.infer; +export type EditorTargetId = LiteralUnion; export type EditorTargetDescriptorPayload = z.infer; export type FetchAgentsResponseMessage = z.infer; export type FetchWorkspacesResponseMessage = z.infer; diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index 2a879657..bf785602 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -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", @@ -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",