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 000000000..7e30a3b6c Binary files /dev/null and b/packages/app/assets/images/editor-apps/cursor.png differ diff --git a/packages/app/assets/images/editor-apps/file-explorer.png b/packages/app/assets/images/editor-apps/file-explorer.png new file mode 100644 index 000000000..4d0ef95af Binary files /dev/null and b/packages/app/assets/images/editor-apps/file-explorer.png differ 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 000000000..474dfe4b6 Binary files /dev/null and b/packages/app/assets/images/editor-apps/finder.png differ 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 000000000..ced6ec214 Binary files /dev/null and b/packages/app/assets/images/editor-apps/vscode.png differ diff --git a/packages/app/assets/images/editor-apps/zed.png b/packages/app/assets/images/editor-apps/zed.png new file mode 100644 index 000000000..6111ab2ab Binary files /dev/null and b/packages/app/assets/images/editor-apps/zed.png differ 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",