From 5cb424b2e6d525f4d1c4ba3fc35377128ab72584 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Tue, 7 Apr 2026 04:25:25 +0200 Subject: [PATCH] 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 --- .../app/assets/images/editor-apps/cursor.png | Bin 0 -> 2478 bytes .../images/editor-apps/file-explorer.png | Bin 0 -> 2638 bytes .../app/assets/images/editor-apps/finder.png | Bin 0 -> 3893 bytes .../app/assets/images/editor-apps/vscode.png | Bin 0 -> 2804 bytes .../app/assets/images/editor-apps/zed.png | Bin 0 -> 4789 bytes .../src/components/icons/editor-app-icons.tsx | 32 +++ .../src/hooks/use-preferred-editor.test.ts | 20 ++ .../app/src/hooks/use-preferred-editor.ts | 57 ++++ .../workspace-open-in-editor-button.tsx | 246 ++++++++++++++++++ .../screens/workspace/workspace-screen.tsx | 7 + packages/server/src/client/daemon-client.ts | 34 +++ .../server/src/server/editor-targets.test.ts | 100 +++++++ packages/server/src/server/editor-targets.ts | 168 ++++++++++++ packages/server/src/server/session.ts | 81 ++++++ .../src/server/session.workspaces.test.ts | 49 ++++ packages/server/src/shared/messages.ts | 55 ++++ .../src/shared/messages.workspaces.test.ts | 21 ++ 17 files changed, 870 insertions(+) create mode 100644 packages/app/assets/images/editor-apps/cursor.png create mode 100644 packages/app/assets/images/editor-apps/file-explorer.png create mode 100644 packages/app/assets/images/editor-apps/finder.png create mode 100644 packages/app/assets/images/editor-apps/vscode.png create mode 100644 packages/app/assets/images/editor-apps/zed.png create mode 100644 packages/app/src/components/icons/editor-app-icons.tsx create mode 100644 packages/app/src/hooks/use-preferred-editor.test.ts create mode 100644 packages/app/src/hooks/use-preferred-editor.ts create mode 100644 packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx create mode 100644 packages/server/src/server/editor-targets.test.ts create mode 100644 packages/server/src/server/editor-targets.ts diff --git a/packages/app/assets/images/editor-apps/cursor.png b/packages/app/assets/images/editor-apps/cursor.png new file mode 100644 index 0000000000000000000000000000000000000000..7e30a3b6c70d2be55f04382c783165c2f065e525 GIT binary patch literal 2478 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 literal 0 HcmV?d00001 diff --git a/packages/app/assets/images/editor-apps/finder.png b/packages/app/assets/images/editor-apps/finder.png new file mode 100644 index 0000000000000000000000000000000000000000..474dfe4b650faca3acbdd788c8143a49629de2cf GIT binary patch literal 3893 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 D0a|me literal 0 HcmV?d00001 diff --git a/packages/app/assets/images/editor-apps/vscode.png b/packages/app/assets/images/editor-apps/vscode.png new file mode 100644 index 0000000000000000000000000000000000000000..ced6ec21407ed4ca8e3a10663146d90f1dbb3129 GIT binary patch literal 2804 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 P00000NkvXXu0mjf1FbTA literal 0 HcmV?d00001 diff --git a/packages/app/src/components/icons/editor-app-icons.tsx b/packages/app/src/components/icons/editor-app-icons.tsx new file mode 100644 index 000000000..a29341678 --- /dev/null +++ b/packages/app/src/components/icons/editor-app-icons.tsx @@ -0,0 +1,32 @@ +import { Image, type ImageSourcePropType } from "react-native"; +import type { EditorTargetId } from "@server/shared/messages"; + +interface EditorAppIconProps { + editorId: EditorTargetId; + size?: number; + color?: string; +} + +/* eslint-disable @typescript-eslint/no-require-imports */ +const EDITOR_APP_IMAGES: Record = { + cursor: require("../../../assets/images/editor-apps/cursor.png"), + vscode: require("../../../assets/images/editor-apps/vscode.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 EditorAppIcon({ + editorId, + size = 16, +}: EditorAppIconProps) { + return ( + + ); +} diff --git a/packages/app/src/hooks/use-preferred-editor.test.ts b/packages/app/src/hooks/use-preferred-editor.test.ts new file mode 100644 index 000000000..6ac4ca761 --- /dev/null +++ b/packages/app/src/hooks/use-preferred-editor.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { resolvePreferredEditorId } from "./use-preferred-editor"; + +describe("resolvePreferredEditorId", () => { + it("keeps the stored editor when it is still available", () => { + expect(resolvePreferredEditorId(["cursor", "vscode"], "vscode")).toBe("vscode"); + }); + + it("falls back to the first available editor when the stored one is missing", () => { + expect(resolvePreferredEditorId(["zed", "finder"], "cursor")).toBe("zed"); + }); + + it("falls back when a platform-specific file manager target is unavailable", () => { + expect(resolvePreferredEditorId(["explorer", "vscode"], "finder")).toBe("explorer"); + }); + + it("returns null when no editors are available", () => { + expect(resolvePreferredEditorId([], "cursor")).toBeNull(); + }); +}); diff --git a/packages/app/src/hooks/use-preferred-editor.ts b/packages/app/src/hooks/use-preferred-editor.ts new file mode 100644 index 000000000..f4c97f7db --- /dev/null +++ b/packages/app/src/hooks/use-preferred-editor.ts @@ -0,0 +1,57 @@ +import { useCallback } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { EditorTargetIdSchema, type EditorTargetId } from "@server/shared/messages"; + +const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor"; +const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"]; + +async function loadPreferredEditor(): Promise { + const stored = await AsyncStorage.getItem(PREFERRED_EDITOR_STORAGE_KEY); + if (!stored) { + return null; + } + const parsed = EditorTargetIdSchema.safeParse(stored); + return parsed.success ? parsed.data : null; +} + +export function resolvePreferredEditorId( + availableEditorIds: readonly EditorTargetId[], + storedEditorId: EditorTargetId | null | undefined, +): EditorTargetId | null { + if ( + storedEditorId && + availableEditorIds.some((availableEditorId) => availableEditorId === storedEditorId) + ) { + return storedEditorId; + } + return availableEditorIds[0] ?? null; +} + +export function usePreferredEditor() { + const queryClient = useQueryClient(); + const { data, isPending } = useQuery({ + queryKey: PREFERRED_EDITOR_QUERY_KEY, + queryFn: loadPreferredEditor, + staleTime: Infinity, + gcTime: Infinity, + }); + + const updatePreferredEditor = useCallback( + async (editorId: EditorTargetId | null) => { + queryClient.setQueryData(PREFERRED_EDITOR_QUERY_KEY, editorId); + if (editorId) { + await AsyncStorage.setItem(PREFERRED_EDITOR_STORAGE_KEY, editorId); + return; + } + await AsyncStorage.removeItem(PREFERRED_EDITOR_STORAGE_KEY); + }, + [queryClient], + ); + + return { + preferredEditorId: data ?? null, + isLoading: isPending, + updatePreferredEditor, + }; +} diff --git a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx new file mode 100644 index 000000000..31457d675 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useMemo } from "react"; +import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Check, ChevronDown } from "lucide-react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import type { + EditorTargetDescriptorPayload, + EditorTargetId, +} from "@server/shared/messages"; +import { EditorAppIcon } from "@/components/icons/editor-app-icons"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useToast } from "@/contexts/toast-context"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { + resolvePreferredEditorId, + usePreferredEditor, +} from "@/hooks/use-preferred-editor"; +import { isAbsolutePath } from "@/utils/path"; + +interface WorkspaceOpenInEditorButtonProps { + serverId: string; + cwd: string; +} + +export function WorkspaceOpenInEditorButton({ + serverId, + cwd, +}: WorkspaceOpenInEditorButtonProps) { + const { theme } = useUnistyles(); + const toast = useToast(); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + const { preferredEditorId, updatePreferredEditor } = usePreferredEditor(); + + const shouldLoadEditors = + Platform.OS === "web" && + Boolean(client && isConnected) && + cwd.trim().length > 0 && + isAbsolutePath(cwd); + + const availableEditorsQuery = useQuery({ + queryKey: ["available-editors", serverId], + enabled: shouldLoadEditors, + staleTime: 60_000, + retry: false, + queryFn: async () => { + if (!client) { + return []; + } + try { + const payload = await client.listAvailableEditors(); + return payload.error ? [] : payload.editors; + } catch { + return []; + } + }, + }); + + const availableEditors = availableEditorsQuery.data ?? []; + const availableEditorIds = useMemo( + () => availableEditors.map((editor: EditorTargetDescriptorPayload) => editor.id), + [availableEditors], + ); + const effectivePreferredEditorId = useMemo( + () => resolvePreferredEditorId(availableEditorIds, preferredEditorId), + [availableEditorIds, preferredEditorId], + ); + const primaryOption = + availableEditors.find( + (editor: EditorTargetDescriptorPayload) => editor.id === effectivePreferredEditorId, + ) ?? null; + + useEffect(() => { + if (!effectivePreferredEditorId || effectivePreferredEditorId === preferredEditorId) { + return; + } + void updatePreferredEditor(effectivePreferredEditorId).catch(() => undefined); + }, [effectivePreferredEditorId, preferredEditorId, updatePreferredEditor]); + + const openMutation = useMutation({ + mutationFn: async (editorId: EditorTargetId) => { + if (!client) { + throw new Error("Host is not connected"); + } + const payload = await client.openInEditor(cwd, editorId); + if (payload.error) { + throw new Error(payload.error); + } + return editorId; + }, + onError: (error: unknown) => { + toast.error(error instanceof Error ? error.message : "Failed to open in editor"); + }, + }); + + const handleOpenEditor = useCallback( + (editorId: EditorTargetId) => { + void updatePreferredEditor(editorId).catch(() => undefined); + openMutation.mutate(editorId); + }, + [openMutation, updatePreferredEditor], + ); + + if (!shouldLoadEditors || !primaryOption || availableEditors.length === 0) { + return null; + } + + return ( + + + [ + styles.splitButtonPrimary, + (hovered || pressed) && styles.splitButtonPrimaryHovered, + openMutation.isPending && styles.splitButtonPrimaryDisabled, + ]} + onPress={() => handleOpenEditor(primaryOption.id)} + disabled={openMutation.isPending} + accessibilityRole="button" + accessibilityLabel={`Open workspace in ${primaryOption.label}`} + > + {openMutation.isPending ? ( + + ) : ( + + + Open + + )} + + {availableEditors.length > 1 ? ( + + [ + styles.splitButtonCaret, + (hovered || pressed || open) && styles.splitButtonCaretHovered, + ]} + accessibilityRole="button" + accessibilityLabel="Choose editor" + > + + + + {availableEditors.map((editor: EditorTargetDescriptorPayload) => ( + + } + trailing={ + editor.id === effectivePreferredEditorId + ? + : undefined + } + onSelect={() => handleOpenEditor(editor.id)} + > + {editor.label} + + ))} + + + ) : null} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + flexShrink: 0, + }, + splitButton: { + flexDirection: "row", + alignItems: "stretch", + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.borderAccent, + overflow: "hidden", + }, + splitButtonPrimary: { + paddingLeft: theme.spacing[3], + paddingRight: 10, + paddingVertical: theme.spacing[1], + justifyContent: "center", + position: "relative", + }, + splitButtonPrimaryHovered: { + backgroundColor: theme.colors.surface2, + }, + splitButtonPrimaryDisabled: { + opacity: 0.6, + }, + splitButtonText: { + fontSize: theme.fontSize.sm, + lineHeight: theme.fontSize.sm * 1.5, + color: theme.colors.foreground, + fontWeight: theme.fontWeight.normal, + }, + splitButtonContent: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[2], + }, + splitButtonSpinnerOnly: { + transform: [{ scale: 0.8 }], + }, + splitButtonCaret: { + width: 28, + alignItems: "center", + justifyContent: "center", + borderLeftWidth: theme.borderWidth[1], + borderLeftColor: theme.colors.borderAccent, + }, + splitButtonCaretHovered: { + backgroundColor: theme.colors.surface2, + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d4555c4ab..29c6ffb81 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -48,6 +48,7 @@ import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { SplitContainer } from "@/components/split-container"; import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon"; import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions"; +import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; import { useToast } from "@/contexts/toast-context"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; @@ -2045,6 +2046,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) } right={ + {!isMobile ? ( + + ) : null} {!isMobile && isGitCheckout ? ( <> { + 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 { + 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, diff --git a/packages/server/src/server/editor-targets.test.ts b/packages/server/src/server/editor-targets.test.ts new file mode 100644 index 000000000..315ecab61 --- /dev/null +++ b/packages/server/src/server/editor-targets.test.ts @@ -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"); + }); +}); diff --git a/packages/server/src/server/editor-targets.ts b/packages/server/src/server/editor-targets.ts new file mode 100644 index 000000000..4bcadacf6 --- /dev/null +++ b/packages/server/src/server/editor-targets.ts @@ -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 { + 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((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(); + }); + }); +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index a647e6495..ac0da00a8 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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 { + await openInEditorTarget(options); + } + + private async handleListAvailableEditorsRequest( + request: Extract, + ): Promise { + 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, + ): Promise { + 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, ): Promise { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 859192291..fb3bf0dad 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -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; diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 6804bb8ef..e32c682f9 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -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; export type WorkspaceStateBucket = z.infer; export type WorkspaceDescriptorPayload = z.infer; +export type EditorTargetId = z.infer; +export type EditorTargetDescriptorPayload = z.infer; export type FetchAgentsResponseMessage = z.infer; export type FetchWorkspacesResponseMessage = z.infer; export type OpenProjectResponseMessage = z.infer; +export type ListAvailableEditorsResponseMessage = z.infer< + typeof ListAvailableEditorsResponseMessageSchema +>; +export type OpenInEditorResponseMessage = z.infer; export type ArchiveWorkspaceResponseMessage = z.infer; export type FetchAgentResponseMessage = z.infer; export type FetchAgentTimelineResponseMessage = z.infer< @@ -2717,6 +2770,8 @@ export type PaseoWorktreeListRequest = z.infer; export type PaseoWorktreeArchiveRequest = z.infer; export type PaseoWorktreeArchiveResponse = z.infer; +export type ListAvailableEditorsRequest = z.infer; +export type OpenInEditorRequest = z.infer; export type OpenProjectRequest = z.infer; export type ArchiveWorkspaceRequest = z.infer; export type FileExplorerRequest = z.infer; diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index 18085890e..2a8796577 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -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",